@mason-data/cli 0.8.0 → 0.10.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 +19 -8
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +166 -32
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/proxy.js +129 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +527 -103
- package/dist/src/api/postgres/translate.js +47 -2
- package/dist/src/cli/background.js +18 -19
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/command.js +337 -15
- package/dist/src/cli/commands.js +458 -169
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +680 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +122 -36
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +116 -16
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +154 -33
- package/dist/src/cli/login.js +292 -0
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +59 -10
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +17 -14
- 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 +1 -1
- package/dist/src/cli/pin.js +6 -2
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +130 -39
- package/dist/src/cli/project.js +100 -1
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +89 -36
- package/dist/src/cli/replica-hosted.js +1324 -0
- package/dist/src/cli/replica.js +114 -0
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +179 -9
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +65 -9
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +23 -18
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +343 -0
- package/dist/src/cli/sources.js +50 -23
- package/dist/src/cli/style.js +16 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +343 -0
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +162 -17
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +364 -0
- package/dist/src/cli/user.js +275 -105
- package/dist/src/demo/seed.js +26 -0
- package/dist/src/duck/analyze.js +36 -20
- 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 +39 -8
- package/dist/src/duck/executor-worker.js +2 -2
- package/dist/src/duck/executor.js +6 -5
- package/dist/src/duck/explain.js +4 -0
- package/dist/src/duck/functions.js +448 -49
- package/dist/src/duck/gaps.js +34 -16
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +1 -1
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +4 -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 +9 -7
- 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 +275 -30
- package/dist/src/duck/publication.js +14 -0
- package/dist/src/duck/requests.js +9 -5
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4567 -583
- package/dist/src/duck/row-filter.js +12 -6
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +294 -68
- package/dist/src/duck/stream.js +9 -5
- package/dist/src/duck/sync.js +602 -126
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +63 -7
- package/dist/src/duck/usage.js +6 -4
- package/dist/src/duck/users.js +110 -9
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +13 -4
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +387 -49
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +84 -19
- package/dist/src/web/app.js +9 -4
- package/dist/src/web/pages/status.js +178 -13
- package/dist/src/web/pages/syncs.js +1 -1
- package/dist/src/web/pages/tables.js +8 -3
- package/dist/src/web/pages/users.js +4 -3
- package/dist/src/web/shared.js +19 -3
- package/package.json +8 -7
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason replica create | list | status | delete` — 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 } (with its deletion, while it is deleted)
|
|
9
|
+
* delete <org>/<name> DELETE /v1/replicas/{org}/{name} { confirm: "<org>/<name>" } 202 (or 200, done already)
|
|
10
|
+
* { replica }, then GET …/{name} until its deletion is finished, needs a person (a slot not
|
|
11
|
+
* verified gone: it stops at once, exit 1) or is past --timeout
|
|
12
|
+
*
|
|
13
|
+
* A DELETION'S SQL IS MASON'S OWN (docs/HOSTED-API.md, `DELETE /v1/replicas/{org}/{name}` and `DeletionSource`): for a source whose replication slot was not
|
|
14
|
+
* verified gone, the statement a person runs by hand is rendered here, by `dropStatements` (src/cli/replication-mode.ts,
|
|
15
|
+
* the one authority), from the slot's and the publication's names the service gives, each held to Postgres's name
|
|
16
|
+
* grammar (`PG_NAME`); a name it does not know is `listSlotsStatement`'s query. The service's own `byHand` is advisory:
|
|
17
|
+
* put to the exact test with the rest of the answer, never compared, never printed (#687's review, B1). EVERY statement a
|
|
18
|
+
* person pastes is written by `pasteLines` alone, on a line of its own, never broken by the CLI and never inside a
|
|
19
|
+
* failure's words (N1).
|
|
20
|
+
*
|
|
21
|
+
* THE DATABASE'S URL (create) is read here alone, from the person or the pipe (src/cli/secret-input.ts, issue 720): on a
|
|
22
|
+
* terminal a hidden prompt (the question on stderr, nothing echoed), else stdin read whole (2 KiB at most, one trailing
|
|
23
|
+
* newline trimmed). Never from a flag, an argument, .env.local, .env or the shell: a command line holding a URL is refused
|
|
24
|
+
* before anything else is read (`urlInArgs`, exit 2, the line to run instead), and the sources file gives only the
|
|
25
|
+
* variable's NAME (`url: ${NAME}`, src/cli/sources.ts `readSourceFile`, never its value), which the replica reads the
|
|
26
|
+
* URL by. It never reaches a log, the output or --json. It is sent once, in the creation's body, for Mason's checks of
|
|
27
|
+
* the source on the replica's side, the variable's name beside it. A `url:` written in the file is refused: the replica
|
|
28
|
+
* has no variable to read it from, and a literal would be kept in the project's files.
|
|
29
|
+
* Its password is held as a secret of this command beside the token, in the one list every boundary reads
|
|
30
|
+
* (`urlPassword`): masked in free text, refused in any other value and by the output's gate, read whole whatever its
|
|
31
|
+
* alphabet; one under `MIN_PASSWORD` characters is not sent, since none of them could tell it from ordinary words.
|
|
32
|
+
*
|
|
33
|
+
* A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts), and A GRAMMAR
|
|
34
|
+
* IS NO MASK (issue 627): before any value is held to its grammar, every string of each answer, keys included, is put to
|
|
35
|
+
* the exact test (`carriesNoSecret`) but the free text alone (a check's name, detail and fix, the names of the checks
|
|
36
|
+
* not run, a pending change's failure), which is masked as every service text is (`serviceText`). Every value printed:
|
|
37
|
+
*
|
|
38
|
+
* value where it is printed what holds it
|
|
39
|
+
* a failure's message, why, fix, code stderr's failure, --json error `serviceFailure` (src/cli/hosted.ts)
|
|
40
|
+
* a failure's next nowhere: never read the step is `refusedNext`'s, by the status
|
|
41
|
+
* org, name every row, headline and step the name asked (`REPLICA_PART` for a list's)
|
|
42
|
+
* region, hostId rows, --json `REGION`, `HOST_ID`
|
|
43
|
+
* sizeGb, attempts rows, --json whole numbers, bounded
|
|
44
|
+
* state, outcome, status, change rows (the CLI's words), --json closed sets (`STATES`, …)
|
|
45
|
+
* createdAt, nextAttemptAt rows, --json `isServiceTime`
|
|
46
|
+
* allowlist a row, --json each `CIDR`, at most `MAX_ALLOWLIST`
|
|
47
|
+
* page a row, --json on the service's own origin (`pageUrl`)
|
|
48
|
+
* a check's name, detail, fix; missing; stderr's lines, --json one line each, bounded, masked (`serviceText`)
|
|
49
|
+
* a pending change's failure
|
|
50
|
+
* a check's source stderr's lines, --json the source sent
|
|
51
|
+
* a deletion's stage, outcome, reason rows, lines (the CLI's words), --json closed sets (`STAGES`, …)
|
|
52
|
+
* a deletion's times, a step's doneAt rows, lines, --json `isServiceTime`
|
|
53
|
+
* a step's id --json `STEP_ID`, each once
|
|
54
|
+
* a step's what, a source's why, rows, stderr's lines, --json one line each, bounded, masked (`serviceText`)
|
|
55
|
+
* the handover's failure
|
|
56
|
+
* a source's slot, publication rows, the SQL's line, --json `PG_NAME`; Mason's own publication `OWN_PUBLICATION`
|
|
57
|
+
* a source's byHand nowhere: Mason's own rendering is a string or nothing, the exact test; never read
|
|
58
|
+
*
|
|
59
|
+
* What to run next, the code and the exit are the CLI's own: 0 once the replica is ready (create), said (list,
|
|
60
|
+
* status) or deleted with every slot dropped, none or confirmed by a person (delete); 1 refused (its words as they
|
|
61
|
+
* came), not made, failed, a deletion that needs a person (its SQL printed) or an answer outside the contract; 2 a wrong line; 124 the wait timed out
|
|
62
|
+
* (nothing stopped: `mason replica status` says how it goes).
|
|
63
|
+
*/
|
|
64
|
+
import { once } from "node:events";
|
|
65
|
+
import { createInterface } from "node:readline/promises";
|
|
66
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
67
|
+
import { endText, errorJson, exitOf, failed, failureText, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
68
|
+
import { parseArgs } from "node:util";
|
|
69
|
+
import { call, canonicalSecret, carriesNoSecret, isFailure, isServiceTime, pageUrl, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
70
|
+
import { loginOrgs, orgOf, REPLICA_ENV, REPLICA_PART, replicaOf, replicaText, tokenFor } from "./replica.js";
|
|
71
|
+
import { holdOutput, readingsCarry, readingsOf } from "./output-gate.js";
|
|
72
|
+
import { theProject } from "./project.js";
|
|
73
|
+
import { readSourceFile, shownPath } from "./sources.js";
|
|
74
|
+
import { readHidden, readPiped } from "./secret-input.js";
|
|
75
|
+
import { parseSource } from "./preflight.js";
|
|
76
|
+
import { MAX_POLL_SECONDS, MIN_POLL_SECONDS, parseDuration } from "../lib/table-sync.js";
|
|
77
|
+
import { sayAgain } from "./commands.js";
|
|
78
|
+
import { version } from "./version.js";
|
|
79
|
+
import { didYouMean } from "../lib/did-you-mean.js";
|
|
80
|
+
import { dropStatements, listSlotsStatement } from "./replication-mode.js";
|
|
81
|
+
import { err as styleErr, out as styleOut, PLAIN } from "./style.js";
|
|
82
|
+
/** How long the creation's POST may take: the service answers once Mason's checks of the source are done. */
|
|
83
|
+
export const CREATE_MS = 60_000;
|
|
84
|
+
/** How often a creation's state is asked for while its region makes it, when the service asks no other. */
|
|
85
|
+
export const REPLICA_POLL_MS = 5_000;
|
|
86
|
+
const DEFAULT_TIMEOUT = "15m";
|
|
87
|
+
/** A list's answer (a row a replica) is read up to this; every other answer is `call`'s own 64 KiB. */
|
|
88
|
+
export const MAX_LIST_BYTES = 1024 * 1024;
|
|
89
|
+
const MAX_REPLICAS = 1000;
|
|
90
|
+
const MAX_ALLOWLIST = 1000;
|
|
91
|
+
const MAX_PENDING = 16;
|
|
92
|
+
const MAX_CHECKS = 200;
|
|
93
|
+
/** A region's name and a host id: lower case letters, digits and `-`, as a replica's name is. */
|
|
94
|
+
const REGION = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
95
|
+
const HOST_ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
96
|
+
/** An address range of an allowlist: IPv4 or IPv6, with its prefix length. */
|
|
97
|
+
const CIDR = /^(?:\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}|[0-9a-fA-F:.]{2,45}\/\d{1,3})$/;
|
|
98
|
+
const SIZE = /^[1-9]\d{0,5}$/;
|
|
99
|
+
export const STATES = ["checking", "creating", "ready", "failed", "deleting", "deleted"];
|
|
100
|
+
const OUTCOMES = ["passed", "failed", "unknown"];
|
|
101
|
+
const CHECK_STATUSES = ["ok", "warn", "failed", "unknown"];
|
|
102
|
+
export const REPLICA = {
|
|
103
|
+
name: "replica",
|
|
104
|
+
summary: "a hosted Mason service's replicas: create one for this project's database, list them, say how one is, delete one",
|
|
105
|
+
usage: `usage: mason replica create <org>/<name> --region <region> --size <gb> [--timeout <duration>] [--json] [< url]
|
|
106
|
+
mason replica list [<org> | --org <org>] [--json]
|
|
107
|
+
mason replica status <org>/<name> | --replica <org>/<name> [--json]
|
|
108
|
+
mason replica delete <org>/<name> | --replica <org>/<name> [--yes] [--timeout <duration>] [--json]
|
|
109
|
+
|
|
110
|
+
A replica is Mason run for you by a hosted Mason service, on a machine of its own, syncing this project's
|
|
111
|
+
database. mason deploy then sends it the project's files, and mason token create a CI token for it.
|
|
112
|
+
|
|
113
|
+
mason replica create acme/shop --region <region> --size 50
|
|
114
|
+
my-secret-store read app-db-url | mason replica create --replica acme/shop --region <region> --size 50 --json
|
|
115
|
+
mason replica list
|
|
116
|
+
mason replica status acme/shop --json
|
|
117
|
+
mason replica delete acme/shop
|
|
118
|
+
|
|
119
|
+
--replica <org>/<name> create, status, delete: the replica, in place of the argument (else MASON_REPLICA)
|
|
120
|
+
--org <org> list: the organisation, in place of the argument
|
|
121
|
+
--region <region> create: the region it runs in, one the service offers
|
|
122
|
+
--size <gb> create: the synced data it holds, in GB, one size the service offers (50)
|
|
123
|
+
--timeout <duration> create: how long to wait for its region to make it; delete: for its deletion to finish
|
|
124
|
+
or to need a person (default ${DEFAULT_TIMEOUT}; 1s to 24h; a bare number is seconds)
|
|
125
|
+
--yes delete: delete it without asking for its name to be typed back (CI, a coding agent)
|
|
126
|
+
--json as data: its shape is at the end
|
|
127
|
+
|
|
128
|
+
The replica can also be named as every other command names one, --replica <org>/<name> (else MASON_REPLICA, said
|
|
129
|
+
on stderr when it chose): mason replica status --replica acme/shop. The organisation, as --org: mason replica
|
|
130
|
+
list --org acme.
|
|
131
|
+
|
|
132
|
+
create makes one in the organisation <org>, named <name>, in --region (one the service offers), sized by
|
|
133
|
+
--size: the synced data it holds, in GB (one size the service offers, such as 50). The database's URL is
|
|
134
|
+
asked for: on a terminal, create asks for it on stderr and reads it without showing it (Enter on an empty
|
|
135
|
+
line, or Ctrl-C, cancels before anything is sent); otherwise it reads it from stdin, whole (at most 2 KiB,
|
|
136
|
+
one trailing newline trimmed): mason replica create acme/shop --region <region> --size 50 < url.txt, or
|
|
137
|
+
piped from a secret store's command. It is never taken from a flag, an argument, .env or the shell: a
|
|
138
|
+
command line holding a URL is refused (exit 2). It is sent once to the service for Mason's checks of the
|
|
139
|
+
source, never printed and never in --json. The sources file names the variable the replica reads it by
|
|
140
|
+
(url: \${DATABASE_URL}); a url: written in the file is refused. A URL whose password is under 8
|
|
141
|
+
characters, or is in the words this command prints itself, is refused too, before anything is sent: nothing
|
|
142
|
+
could keep it out of what is printed.
|
|
143
|
+
create waits until the service has run Mason's checks of the database (up to a minute) and says each check,
|
|
144
|
+
one line each, as the service words them; then until its region has made the replica (--timeout bounds that wait; the replica is made all the same),
|
|
145
|
+
and prints its page in the service's web app, where its allowlist (the addresses that may query it) is set.
|
|
146
|
+
An admin or an owner of the organisation creates one. An account's FIRST replica is made in the service's web
|
|
147
|
+
app, where it asks a check that a person is creating it, which a command cannot answer: the service refuses it
|
|
148
|
+
here, and the replicas after it can be made here.
|
|
149
|
+
|
|
150
|
+
list says the replicas of <org>, or of every organisation of this login when none is named (a CI token names
|
|
151
|
+
one); status says one replica: its state (checking, creating, ready, failed, deleting, deleted), its region and
|
|
152
|
+
size, its host, its allowlist (the addresses that may query it), what of its region's work is not done, and,
|
|
153
|
+
while it is deleted, its deletion as delete says it.
|
|
154
|
+
|
|
155
|
+
delete deletes one, for good: the replica and its synced data go, and Mason's replication slot on its
|
|
156
|
+
database is dropped. At a terminal it asks for <org>/<name> to be typed back; elsewhere (and with --json) it
|
|
157
|
+
needs --yes. An owner of the organisation deletes one, signed in as a person within the last 15 minutes (mason
|
|
158
|
+
login; a CI token deletes none). It waits until the deletion is finished (--timeout bounds that wait; it goes
|
|
159
|
+
on all the same), saying each step as the service words it, then what it did to each database's replication
|
|
160
|
+
slot: dropped and verified gone, none there, or confirmed by a person. A slot NOT verified gone stops the wait
|
|
161
|
+
at once: the service keeps the replica until the slot is seen gone or a person confirms it was dropped, so
|
|
162
|
+
delete prints why and the statement to run on that database by hand, and exits 1; run it, then confirm on
|
|
163
|
+
the replica's page in the service's web app. That statement is Mason's own, rendered from the slot's name the
|
|
164
|
+
service gives; whatever statement the service sends beside it is never printed. A replica deleted another
|
|
165
|
+
way (as from the web app) may be said only as being deleted, when the service says no more of it.
|
|
166
|
+
|
|
167
|
+
The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
|
|
168
|
+
Progress goes to stderr; the answer is printed when it ends.
|
|
169
|
+
|
|
170
|
+
Exit status: 0 the replica is ready (create), said (list, status), or deleted, every replication slot dropped,
|
|
171
|
+
none there or confirmed by a person (delete); 1 not signed in, the URL not given (cancelled, stdin empty, more
|
|
172
|
+
than one line or past 2 KiB: nothing sent), refused by the service (its words printed as they came, the code
|
|
173
|
+
the CLI's own), not made (the checks failed: each is said), its region could not make it, its deletion needs a
|
|
174
|
+
person (a slot NOT verified gone: the statement to run is printed, and the replica is kept until it is
|
|
175
|
+
confirmed), or the service answered outside its contract; 2 a wrong command line (delete without a terminal
|
|
176
|
+
and without --yes); 124 the wait timed out (nothing stopped: mason replica status says how it goes). Whatever
|
|
177
|
+
the service sends, it never makes the exit 0, 2 or 124.
|
|
178
|
+
|
|
179
|
+
The --json answer, once it ends: { replica (create, status, delete: { org, name, region, sizeGb, hostId,
|
|
180
|
+
state, createdAt, allowlist, page (its page, null when the service names none), pending: [{ change
|
|
181
|
+
(create, allowlist), state (pending, failed), attempts, failure, nextAttemptAt }] }), deletion (status,
|
|
182
|
+
delete: null while it is in none, or when the service gave none; { stage (requested, in_region,
|
|
183
|
+
needs_person, finished), requestedAt, handover: { attempts, failure, nextAttemptAt } or null,
|
|
184
|
+
lastHeardAt, finishedAt, steps: [{ step, what, doneAt }], sources: [{ ordinal, outcome (dropped,
|
|
185
|
+
no_slot, not_verified, confirmed), reason (not_verified: unreachable, refused, in_use, still_there,
|
|
186
|
+
another_server, record_unreadable, failed, unprobed), why, slot, publication, publicationOwned, byHand
|
|
187
|
+
(not_verified: Mason's statement to run by hand), reportedAt }] }), checks (create: [{ source, outcome
|
|
188
|
+
(passed, failed, unknown), checks: [{ name, status (ok, warn, failed, unknown), detail, fix }],
|
|
189
|
+
missing }], null when the service's answer was lost), replicas (list: each as replica is), more (list:
|
|
190
|
+
true when an organisation has more than the service lists), error (create, delete: when it was not
|
|
191
|
+
made, failed, needs a person, was refused or the wait timed out: { code, message, why, fix }), next }
|
|
192
|
+
`,
|
|
193
|
+
options: { replica: { type: "string" }, org: { type: "string" }, region: { type: "string" }, size: { type: "string" }, timeout: { type: "string" },
|
|
194
|
+
yes: { type: "boolean", default: false }, json: { type: "boolean", default: false } },
|
|
195
|
+
json: ["replica", "deletion", "checks", "replicas", "more", "error", "next"],
|
|
196
|
+
};
|
|
197
|
+
/** Everything this command prints can hold the service's text, so it is all drawn in serviceStyle (src/cli/command.ts). */
|
|
198
|
+
const outStyle = () => serviceStyle(styleOut());
|
|
199
|
+
const errStyle = () => serviceStyle(styleErr());
|
|
200
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
201
|
+
const say = (m) => { process.stderr.write(`${strict(m, errStyle())}\n`); };
|
|
202
|
+
const fail = (f, json) => failed("replica", f, { json, style: errStyle() });
|
|
203
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
204
|
+
const whole = (v, least, most) => Number.isInteger(v) && v >= least && v <= most;
|
|
205
|
+
// ------------------------------------------------------------------ the answers, held
|
|
206
|
+
/**
|
|
207
|
+
* AN ANSWER WITHOUT ITS FREE TEXT: what is put to the exact test (`carriesNoSecret`) — every string but a check's
|
|
208
|
+
* name, detail and fix, the names of the checks not run, and a pending change's failure, which are words the service
|
|
209
|
+
* says (masked as a failure's words are, `serviceText`). Keys are kept, so each is tested.
|
|
210
|
+
*/
|
|
211
|
+
export function wordless(json) {
|
|
212
|
+
if (!obj(json))
|
|
213
|
+
return json;
|
|
214
|
+
// a deletion's words: each step's what, each source's why, the handover's failure (its byHand is no free text: tested,
|
|
215
|
+
// then never read)
|
|
216
|
+
const deletion = (d) => !obj(d) ? d : { ...d,
|
|
217
|
+
...(obj(d.handover) ? { handover: { ...d.handover, failure: null } } : {}),
|
|
218
|
+
...(Array.isArray(d.steps) ? { steps: d.steps.map((x) => obj(x) ? { ...x, what: null } : x) } : {}),
|
|
219
|
+
...(Array.isArray(d.sources) ? { sources: d.sources.map((x) => obj(x) ? { ...x, why: null } : x) } : {}) };
|
|
220
|
+
const replica = (r) => !obj(r) ? r : { ...r,
|
|
221
|
+
...(Array.isArray(r.pending) ? { pending: r.pending.map((p) => obj(p) ? { ...p, failure: null } : p) } : {}),
|
|
222
|
+
...("deletion" in r ? { deletion: deletion(r.deletion) } : {}) };
|
|
223
|
+
const checks = (c) => !obj(c) ? c : { ...c,
|
|
224
|
+
...(Array.isArray(c.missing) ? { missing: c.missing.map(() => null) } : {}),
|
|
225
|
+
...(Array.isArray(c.checks) ? { checks: c.checks.map((x) => obj(x) ? { ...x, name: null, detail: null, fix: null } : x) } : {}) };
|
|
226
|
+
return { ...json, ...("replica" in json ? { replica: replica(json.replica) } : {}),
|
|
227
|
+
...(Array.isArray(json.replicas) ? { replicas: json.replicas.map(replica) } : {}),
|
|
228
|
+
...(Array.isArray(json.checks) ? { checks: json.checks.map(checks) } : {}) };
|
|
229
|
+
}
|
|
230
|
+
/** Free text of the service's, as it is printed: one line, bounded, masked; null when it is not a string. */
|
|
231
|
+
const words = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
|
|
232
|
+
/**
|
|
233
|
+
* A REPLICA, held to the contract: of the organisation asked (and the name asked, for one replica), every value to its
|
|
234
|
+
* grammar; null when any does not hold. The answer around it has been put to the exact test (`wordless`). Its page, when
|
|
235
|
+
* named, is on `service`'s own origin (`pageUrl`: tested as it is printed); one elsewhere, or with no service to hold it
|
|
236
|
+
* to, does not hold.
|
|
237
|
+
*/
|
|
238
|
+
export function heldReplica(v, asked, secrets, service) {
|
|
239
|
+
if (!obj(v) || v.org !== asked.org || typeof v.name !== "string" || !REPLICA_PART.test(v.name) || (asked.name !== undefined && v.name !== asked.name))
|
|
240
|
+
return null;
|
|
241
|
+
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))
|
|
242
|
+
return null;
|
|
243
|
+
if (typeof v.state !== "string" || !STATES.includes(v.state) || !isServiceTime(v.createdAt))
|
|
244
|
+
return null;
|
|
245
|
+
if (!Array.isArray(v.allowlist) || v.allowlist.length > MAX_ALLOWLIST || !v.allowlist.every((c) => typeof c === "string" && CIDR.test(c)))
|
|
246
|
+
return null;
|
|
247
|
+
if (!Array.isArray(v.pending) || v.pending.length > MAX_PENDING)
|
|
248
|
+
return null;
|
|
249
|
+
let page = null;
|
|
250
|
+
if (v.page !== undefined && v.page !== null) {
|
|
251
|
+
const held = service === undefined ? null : pageUrl(service, v.page, secrets);
|
|
252
|
+
if (held === null || !("href" in held))
|
|
253
|
+
return null;
|
|
254
|
+
page = held.href;
|
|
255
|
+
}
|
|
256
|
+
const pending = [];
|
|
257
|
+
for (const p of v.pending) {
|
|
258
|
+
if (!obj(p) || (p.change !== "create" && p.change !== "allowlist") || (p.state !== "pending" && p.state !== "failed") || !whole(p.attempts, 0, 1_000_000))
|
|
259
|
+
return null;
|
|
260
|
+
if (p.failure !== undefined && p.failure !== null && typeof p.failure !== "string")
|
|
261
|
+
return null;
|
|
262
|
+
if (p.nextAttemptAt !== undefined && p.nextAttemptAt !== null && !isServiceTime(p.nextAttemptAt))
|
|
263
|
+
return null;
|
|
264
|
+
pending.push({ change: p.change, state: p.state, attempts: p.attempts, failure: words(p.failure, 500, secrets),
|
|
265
|
+
nextAttemptAt: typeof p.nextAttemptAt === "string" ? p.nextAttemptAt : null });
|
|
266
|
+
}
|
|
267
|
+
return { org: v.org, name: v.name, region: v.region, sizeGb: v.sizeGb, hostId: v.hostId, state: v.state,
|
|
268
|
+
createdAt: v.createdAt, allowlist: [...v.allowlist], page, pending };
|
|
269
|
+
}
|
|
270
|
+
/** Mason's checks, held: one entry at most, for the source sent, each check's status and the outcome closed sets, every
|
|
271
|
+
* word one line; null when they do not hold. */
|
|
272
|
+
export function heldChecks(v, source, secrets) {
|
|
273
|
+
if (!Array.isArray(v) || v.length > 1)
|
|
274
|
+
return null;
|
|
275
|
+
const held = [];
|
|
276
|
+
for (const s of v) {
|
|
277
|
+
if (!obj(s) || s.source !== source || typeof s.outcome !== "string" || !OUTCOMES.includes(s.outcome))
|
|
278
|
+
return null;
|
|
279
|
+
if (!Array.isArray(s.checks) || s.checks.length > MAX_CHECKS || !Array.isArray(s.missing) || s.missing.length > MAX_CHECKS)
|
|
280
|
+
return null;
|
|
281
|
+
const checks = [];
|
|
282
|
+
for (const c of s.checks) {
|
|
283
|
+
if (!obj(c) || typeof c.name !== "string" || typeof c.detail !== "string" || typeof c.status !== "string" || !CHECK_STATUSES.includes(c.status))
|
|
284
|
+
return null;
|
|
285
|
+
if (c.fix !== undefined && c.fix !== null && typeof c.fix !== "string")
|
|
286
|
+
return null;
|
|
287
|
+
checks.push({ name: words(c.name, 200, secrets), status: c.status, detail: words(c.detail, 2000, secrets), fix: words(c.fix, 2000, secrets) });
|
|
288
|
+
}
|
|
289
|
+
if (!s.missing.every((m) => typeof m === "string"))
|
|
290
|
+
return null;
|
|
291
|
+
held.push({ source, outcome: s.outcome, checks, missing: s.missing.map((m) => words(m, 200, secrets)) });
|
|
292
|
+
}
|
|
293
|
+
return held;
|
|
294
|
+
}
|
|
295
|
+
// ------------------------------------------------------------------ a deletion, held
|
|
296
|
+
/**
|
|
297
|
+
* A deletion's stages: asked for and not handed over yet, under way (nobody may query it), NEEDS A PERSON (a slot is not
|
|
298
|
+
* verified gone: the service keeps the replica until it is seen gone or a person confirms it, and the CLI stops waiting
|
|
299
|
+
* at once), finished (the tombstone: every slot dropped, none there, or confirmed by a person).
|
|
300
|
+
*/
|
|
301
|
+
export const STAGES = ["requested", "in_region", "needs_person", "finished"];
|
|
302
|
+
/** What a deletion did to Mason's replication slot on a source's database: `confirmed` is a slot not verified gone that a
|
|
303
|
+
* person said they dropped by hand, or whose database is no more (never seen gone by the service). */
|
|
304
|
+
export const SOURCE_OUTCOMES = ["dropped", "no_slot", "not_verified", "confirmed"];
|
|
305
|
+
/** Why a slot was not verified gone. */
|
|
306
|
+
export const REASONS = ["unreachable", "refused", "in_use", "still_there", "another_server", "record_unreadable", "failed", "unprobed"];
|
|
307
|
+
/** A step's id: lower case letters, digits and `_` (never printed for a person: its `what` is). */
|
|
308
|
+
const STEP_ID = /^[a-z0-9_]{1,64}$/;
|
|
309
|
+
/** A replication slot's or a publication's name as Postgres takes one unquoted in the statements a person runs: lower
|
|
310
|
+
* case letters, digits and `_`, 1 to 63 (src/cli/replication-mode.ts `PG_NAME_BYTES`). */
|
|
311
|
+
export const PG_NAME = /^[a-z0-9_]{1,63}$/;
|
|
312
|
+
/** Mason's own publication (src/cdc/source.ts `replicationNames`): `mason_` and the rest of a name. */
|
|
313
|
+
const OWN_PUBLICATION = /^mason_[a-z0-9_]{1,57}$/;
|
|
314
|
+
const MAX_STEPS = 64;
|
|
315
|
+
const MAX_SOURCES = 16;
|
|
316
|
+
/**
|
|
317
|
+
* THE STATEMENT A PERSON RUNS BY HAND for a slot not verified gone: Mason's own (`dropStatements`, the one authority),
|
|
318
|
+
* for the slot named and the publication only when the service says it is Mason's own; `listSlotsStatement` when the
|
|
319
|
+
* slot's name is not known. Every name was held to `PG_NAME` first.
|
|
320
|
+
*/
|
|
321
|
+
export function byHandOf(s) {
|
|
322
|
+
if (s.slot === null)
|
|
323
|
+
return listSlotsStatement();
|
|
324
|
+
const owned = s.publicationOwned === true && s.publication !== null;
|
|
325
|
+
return dropStatements({ slot: s.slot, publication: owned ? s.publication : "", owned });
|
|
326
|
+
}
|
|
327
|
+
const absent = (v) => v === undefined || v === null;
|
|
328
|
+
const timeOrNull = (v) => absent(v) || isServiceTime(v);
|
|
329
|
+
/** One source's outcome, held: its ordinal its place, every name to `PG_NAME`, its byHand Mason's own rendering (the
|
|
330
|
+
* service's is advisory: never compared, never printed); null when any does not hold. */
|
|
331
|
+
function heldSource(v, at, secrets) {
|
|
332
|
+
if (!obj(v) || v.ordinal !== at || typeof v.outcome !== "string" || !SOURCE_OUTCOMES.includes(v.outcome) || !isServiceTime(v.reportedAt))
|
|
333
|
+
return null;
|
|
334
|
+
const outcome = v.outcome;
|
|
335
|
+
const name = (x) => absent(x) || (typeof x === "string" && PG_NAME.test(x));
|
|
336
|
+
if (!name(v.slot) || !name(v.publication))
|
|
337
|
+
return null;
|
|
338
|
+
const slot = typeof v.slot === "string" ? v.slot : null, publication = typeof v.publication === "string" ? v.publication : null;
|
|
339
|
+
if (outcome === "dropped" && slot === null)
|
|
340
|
+
return null;
|
|
341
|
+
// whether the publication is Mason's own: absent for no_slot; else true (then it is named, and Mason's own), false, null
|
|
342
|
+
let owned = null;
|
|
343
|
+
if (outcome === "no_slot") {
|
|
344
|
+
if (!absent(v.publicationOwned))
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
if (v.publicationOwned !== true && v.publicationOwned !== false && v.publicationOwned !== null)
|
|
349
|
+
return null;
|
|
350
|
+
owned = v.publicationOwned;
|
|
351
|
+
if (owned === true && (publication === null || !OWN_PUBLICATION.test(publication)))
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
// a reason, its words and the SQL by hand: for a slot not verified gone alone
|
|
355
|
+
let reason = null, why = null, byHand = null;
|
|
356
|
+
if (outcome === "not_verified") {
|
|
357
|
+
if (typeof v.reason !== "string" || !REASONS.includes(v.reason) || (!absent(v.why) && typeof v.why !== "string"))
|
|
358
|
+
return null;
|
|
359
|
+
reason = v.reason;
|
|
360
|
+
why = typeof v.why === "string" ? serviceText(v.why, 500, secrets) : null;
|
|
361
|
+
// the statement by hand is Mason's own, from the names held above (#687's review, B1: a service's text compared
|
|
362
|
+
// with it was a fact derived in two places): the service's, a string or nothing, is read for no word of it
|
|
363
|
+
byHand = byHandOf({ slot, publication, publicationOwned: owned });
|
|
364
|
+
}
|
|
365
|
+
else if (!absent(v.reason) || !absent(v.why))
|
|
366
|
+
return null;
|
|
367
|
+
if (!absent(v.byHand) && typeof v.byHand !== "string")
|
|
368
|
+
return null;
|
|
369
|
+
return { ordinal: at, outcome, reason, why, slot, publication, publicationOwned: owned, byHand, reportedAt: v.reportedAt };
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* A REPLICA'S DELETION, held to the contract: carried only while its state is `deleting` (stage `requested`, `in_region`
|
|
373
|
+
* or `needs_person`) or `deleted` (stage `finished`, its time said), and always while `deleted`; a replica `deleting`
|
|
374
|
+
* may carry none (deleted another way, as from the service's web app: it is said as being deleted, nothing more). The
|
|
375
|
+
* handover at `requested` alone; every step's id once; every source in order; at `needs_person` a slot not verified
|
|
376
|
+
* gone, at `finished` none. `undefined` when it carries none (absent or `null`) and may; null when anything does not
|
|
377
|
+
* hold, a deletion present but malformed refused whole. The answer around it has been put to the exact test (`wordless`).
|
|
378
|
+
*/
|
|
379
|
+
export function heldDeletion(v, state, secrets) {
|
|
380
|
+
const deleting = state === "deleting" || state === "deleted";
|
|
381
|
+
if (absent(v))
|
|
382
|
+
return state === "deleted" ? null : undefined;
|
|
383
|
+
if (!deleting || !obj(v) || typeof v.stage !== "string" || !STAGES.includes(v.stage))
|
|
384
|
+
return null;
|
|
385
|
+
const stage = v.stage;
|
|
386
|
+
if ((state === "deleted") !== (stage === "finished"))
|
|
387
|
+
return null;
|
|
388
|
+
if (!isServiceTime(v.requestedAt) || !timeOrNull(v.lastHeardAt) || !timeOrNull(v.finishedAt) || (stage === "finished") !== isServiceTime(v.finishedAt))
|
|
389
|
+
return null;
|
|
390
|
+
let handover = null;
|
|
391
|
+
if (!absent(v.handover)) {
|
|
392
|
+
const h = v.handover;
|
|
393
|
+
if (stage !== "requested" || !obj(h) || !whole(h.attempts, 0, 1_000_000) || (!absent(h.failure) && typeof h.failure !== "string") || !timeOrNull(h.nextAttemptAt))
|
|
394
|
+
return null;
|
|
395
|
+
handover = { attempts: h.attempts, failure: words(h.failure, 500, secrets), nextAttemptAt: typeof h.nextAttemptAt === "string" ? h.nextAttemptAt : null };
|
|
396
|
+
}
|
|
397
|
+
if (!Array.isArray(v.steps) || v.steps.length > MAX_STEPS || !Array.isArray(v.sources) || v.sources.length > MAX_SOURCES)
|
|
398
|
+
return null;
|
|
399
|
+
const steps = [], ids = new Set();
|
|
400
|
+
for (const x of v.steps) {
|
|
401
|
+
if (!obj(x) || typeof x.step !== "string" || !STEP_ID.test(x.step) || ids.has(x.step) || typeof x.what !== "string" || !timeOrNull(x.doneAt))
|
|
402
|
+
return null;
|
|
403
|
+
ids.add(x.step);
|
|
404
|
+
steps.push({ step: x.step, what: serviceText(x.what, 300, secrets), doneAt: typeof x.doneAt === "string" ? x.doneAt : null });
|
|
405
|
+
}
|
|
406
|
+
const sources = [];
|
|
407
|
+
for (const [i, x] of v.sources.entries()) {
|
|
408
|
+
const s = heldSource(x, i, secrets);
|
|
409
|
+
if (s === null)
|
|
410
|
+
return null;
|
|
411
|
+
sources.push(s);
|
|
412
|
+
}
|
|
413
|
+
// a person is needed exactly while a slot is not verified gone at the deletion's end: never finished with one left
|
|
414
|
+
const left = sources.some((x) => x.outcome === "not_verified");
|
|
415
|
+
if ((stage === "needs_person" && !left) || (stage === "finished" && left))
|
|
416
|
+
return null;
|
|
417
|
+
return { stage, requestedAt: v.requestedAt, handover, lastHeardAt: typeof v.lastHeardAt === "string" ? v.lastHeardAt : null,
|
|
418
|
+
finishedAt: typeof v.finishedAt === "string" ? v.finishedAt : null, steps, sources };
|
|
419
|
+
}
|
|
420
|
+
// ------------------------------------------------------------------ in the CLI's words
|
|
421
|
+
const STATE_WORDS = {
|
|
422
|
+
checking: "checking: Mason checks its database; nothing is made yet",
|
|
423
|
+
creating: "creating: its region is making it",
|
|
424
|
+
ready: "ready",
|
|
425
|
+
failed: "failed: its region could not make it, after every attempt",
|
|
426
|
+
deleting: "deleting",
|
|
427
|
+
deleted: "deleted",
|
|
428
|
+
};
|
|
429
|
+
const MARKS = { ok: "ok", warn: "! ", failed: "!!", unknown: "? " };
|
|
430
|
+
/** Each check, one line each: its mark, its name and its detail, its fix beside it; the checks not run, a line. */
|
|
431
|
+
export function checkLines(checks) {
|
|
432
|
+
return checks.flatMap((s) => [
|
|
433
|
+
`Mason's checks of the database ${s.source} names: ${s.outcome}`,
|
|
434
|
+
...s.checks.map((c) => ` ${MARKS[c.status]} ${c.name}: ${c.detail}${c.fix ? ` (fix: ${c.fix})` : ""}`),
|
|
435
|
+
...(s.missing.length ? [` ? not checked: ${s.missing.join(", ")}`] : []),
|
|
436
|
+
]);
|
|
437
|
+
}
|
|
438
|
+
/** A replica's facts, as rows. */
|
|
439
|
+
function rows(r) {
|
|
440
|
+
return [
|
|
441
|
+
["state", STATE_WORDS[r.state]], ["region", r.region], ["size", `${r.sizeGb} GB of synced data`], ["host", r.hostId], ["created", r.createdAt],
|
|
442
|
+
["allowlist", r.allowlist.length ? r.allowlist.join(", ") : "none: no address may query it yet"],
|
|
443
|
+
...(r.page !== null ? [["page", r.page]] : []),
|
|
444
|
+
...r.pending.map((p) => ["pending", `${p.change === "create" ? "its creation" : "its allowlist"} ${p.state === "failed" ? "failed" : "is under way"}` +
|
|
445
|
+
` (${p.attempts} attempt${p.attempts === 1 ? "" : "s"}${p.nextAttemptAt ? `, the next at ${p.nextAttemptAt}` : ""})${p.failure ? `: ${p.failure}` : ""}`]),
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
const statusStep = (r) => [`mason replica status ${r.org}/${r.name}`, "how it is"];
|
|
449
|
+
const listStep = (org) => [`mason replica list ${org}`, "the organisation's replicas"];
|
|
450
|
+
const nextOf = (r) => r.state === "ready"
|
|
451
|
+
? [[`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"]]
|
|
452
|
+
: r.state === "deleted" ? [listStep(r.org)] : [statusStep(r)];
|
|
453
|
+
const STAGE_WORDS = {
|
|
454
|
+
requested: "requested: the service has not handed it to its region yet, and the addresses of its allowlist may still query it",
|
|
455
|
+
in_region: "under way: nobody may query it now",
|
|
456
|
+
needs_person: "needs a person: a replication slot is NOT verified gone, and the service keeps the replica until it is seen gone or a person confirms it was dropped",
|
|
457
|
+
finished: "finished",
|
|
458
|
+
};
|
|
459
|
+
const REASON_WORDS = {
|
|
460
|
+
unreachable: "its database could not be reached",
|
|
461
|
+
refused: "its database refused the drop",
|
|
462
|
+
in_use: "a reader still holds the slot",
|
|
463
|
+
still_there: "it was still there after the drop",
|
|
464
|
+
another_server: "the database answering is another server than the one the slot is on",
|
|
465
|
+
record_unreadable: "the replica's record of its slot could not be read",
|
|
466
|
+
failed: "the drop failed",
|
|
467
|
+
unprobed: "whether it is gone was not checked",
|
|
468
|
+
};
|
|
469
|
+
/** A deletion's stage, as a line: its words, and the handover's attempts while it is not handed over. */
|
|
470
|
+
export function stageLine(d) {
|
|
471
|
+
const h = d.handover;
|
|
472
|
+
return `${STAGE_WORDS[d.stage]}${h ? ` (${h.attempts} attempt${h.attempts === 1 ? "" : "s"} to hand it over${h.nextAttemptAt ? `, the next at ${h.nextAttemptAt}` : ""})${h.failure ? `: ${h.failure}` : ""}` : ""}`;
|
|
473
|
+
}
|
|
474
|
+
/** What a deletion did to one source's replication slot, in the CLI's words (and a reason's words, the service's). */
|
|
475
|
+
export function sourceWords(s) {
|
|
476
|
+
const slot = s.slot === null ? "its replication slot" : `replication slot ${s.slot}`;
|
|
477
|
+
if (s.outcome === "dropped")
|
|
478
|
+
return `${slot}: dropped, verified gone`;
|
|
479
|
+
if (s.outcome === "no_slot")
|
|
480
|
+
return s.slot === null ? "no replication slot: nothing to drop" : `${slot}: not there, nothing to drop`;
|
|
481
|
+
if (s.outcome === "confirmed")
|
|
482
|
+
return `${slot}: not verified gone by the service; a person confirmed it was dropped by hand, or that its database is no more`;
|
|
483
|
+
return `${slot}: NOT verified gone, ${REASON_WORDS[s.reason]}${s.why ? ` (${s.why})` : ""}`;
|
|
484
|
+
}
|
|
485
|
+
/** A replica being deleted whose service gave no deletion (deleted another way): what is known, nothing invented. */
|
|
486
|
+
const NO_DELETION = "being deleted: the service said no more of it";
|
|
487
|
+
/** What a person does once a deletion needs one: the statements, then the confirmation, where the service takes it. */
|
|
488
|
+
const NEEDS_PERSON_TIP = "run each statement above on its database, then confirm it on the replica's page in the service's web app (or there, that the database is no more): the service deletes the replica only then";
|
|
489
|
+
/** A replica's deletion as rows: its own (`deletionRows`), the one row that says it is being deleted when the service
|
|
490
|
+
* gave none, else none. */
|
|
491
|
+
export function deletionDetails(state, d) {
|
|
492
|
+
return d !== null ? deletionRows(d) : state === "deleting" ? [["deletion", NO_DELETION]] : [];
|
|
493
|
+
}
|
|
494
|
+
/** A deletion, as rows: its stage, its times, each step (done or not yet), each source's outcome. */
|
|
495
|
+
export function deletionRows(d) {
|
|
496
|
+
return [
|
|
497
|
+
["deletion", `${stageLine(d)}; asked for at ${d.requestedAt}`],
|
|
498
|
+
...(d.lastHeardAt ? [["last heard", d.lastHeardAt]] : []),
|
|
499
|
+
...(d.finishedAt ? [["finished", d.finishedAt]] : []),
|
|
500
|
+
...d.steps.map((x) => ["step", x.doneAt ? `done: ${x.what} (at ${x.doneAt})` : `not yet: ${x.what}`]),
|
|
501
|
+
...d.sources.map((x) => [`source ${x.ordinal}`, sourceWords(x)]),
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* THE STATEMENTS A PERSON RUNS BY HAND, for each source whose slot is not verified gone: what the CLI says of where, and
|
|
506
|
+
* the statement (Mason's own, `byHandOf`). `[said, sql]` pairs, printed by `pasteLines`; none when every slot was dropped
|
|
507
|
+
* or there was none. The CLI's words hold no piece of SQL: only the statement is pasted.
|
|
508
|
+
*/
|
|
509
|
+
export function byHandLines(d) {
|
|
510
|
+
return d.sources.filter((x) => x.outcome === "not_verified").map((x) => [x.slot === null
|
|
511
|
+
? `by hand, for source ${x.ordinal}: this lists Mason's replication slots on its database; drop this replica's slot from them, as a user that may drop a replication slot`
|
|
512
|
+
: `by hand, for source ${x.ordinal}: run this on its database, as a user that may drop a replication slot (its owner, or one with REPLICATION)`, byHandOf(x)]);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* THE ONE WRITER OF A STATEMENT A PERSON PASTES (#687's review, N1: a statement inside a failure's words was broken at the
|
|
516
|
+
* width, mid-word on a narrow terminal; PR 660's B2 met the class with a CI token): for each, the CLI's line saying where,
|
|
517
|
+
* broken as every line holding a service's value is, then the statement alone on a line of its own, indented, WRITTEN
|
|
518
|
+
* UNBROKEN: a break inside a name would be pasted with it, and a terminal narrower than it wraps it softly. Its characters
|
|
519
|
+
* are Mason's own SQL and names held to `PG_NAME`, so no piece of it begins a line of anyone's making.
|
|
520
|
+
*/
|
|
521
|
+
export function pasteLines(pairs, st) {
|
|
522
|
+
return pairs.flatMap(([said, sql], i) => [...(i ? [""] : []), strict(` ${said}:`, st), ` ${sql}`]);
|
|
523
|
+
}
|
|
524
|
+
/** A replica's report with its deletion: the rows, the statements by hand (`pasteLines`), then the tips and next steps. */
|
|
525
|
+
function deletionReport(r, st) {
|
|
526
|
+
const hand = r.deletion === null ? [] : byHandLines(r.deletion);
|
|
527
|
+
const report = reportText({ headline: r.headline, details: r.details, ...(hand.length ? {} : { tips: r.tips, next: r.next }) }, st);
|
|
528
|
+
if (!hand.length)
|
|
529
|
+
return report;
|
|
530
|
+
const end = endText({ tips: r.tips, next: r.next }, st);
|
|
531
|
+
return [report, "", ...pasteLines(hand, st), ...(end ? [end] : [])].join("\n");
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 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
|
|
535
|
+
* signs in again, naming the role that may (`may`); a 404 lists the organisation's replicas; else `again`.
|
|
536
|
+
*/
|
|
537
|
+
export function refusedNext(status, org, may, again) {
|
|
538
|
+
if (status === 401 || status === 403)
|
|
539
|
+
return [["mason login", `sign in as ${may} of ${org}`]];
|
|
540
|
+
if (status === 404)
|
|
541
|
+
return [[`mason replica list ${org}`, "the organisation's replicas"]];
|
|
542
|
+
return [again];
|
|
543
|
+
}
|
|
544
|
+
// ------------------------------------------------------------------ the command
|
|
545
|
+
/** An argument that holds a database's URL, or a connection string's password: `://` anywhere (a scheme, `--url=…`,
|
|
546
|
+
* `DATABASE_URL=…`), `postgres:` / `postgresql:` without it, or `password=`. Never repeated: it is a secret. */
|
|
547
|
+
export const holdsUrl = (a) => a.includes("://") || /(?:^|[=\s])postgres(?:ql)?:/i.test(a) || /(?:^|[\s?&;])password\s*=/i.test(a);
|
|
548
|
+
/**
|
|
549
|
+
* A DATABASE URL ON THE COMMAND LINE, refused before anything else reads the line (a wrong line, exit 2), and never
|
|
550
|
+
* repeated (the parser's refusal of `--url=…`, or a check of `--region`, would quote it): the way out is the exact line
|
|
551
|
+
* to run, the URL on stdin, every other value kept where it holds its grammar and a placeholder where it does not.
|
|
552
|
+
*/
|
|
553
|
+
export function urlInArgs(argv) {
|
|
554
|
+
if (!argv.some(holdsUrl))
|
|
555
|
+
return null;
|
|
556
|
+
// the line without it, and without the flag it was the value of (`--region <url>`, `--url <url>`)
|
|
557
|
+
const rest = [];
|
|
558
|
+
for (const a of argv) {
|
|
559
|
+
if (!holdsUrl(a)) {
|
|
560
|
+
rest.push(a);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const before = rest[rest.length - 1];
|
|
564
|
+
if (before !== undefined && /^--[\w-]+$/.test(before) && (REPLICA.options[before.slice(2)]?.type ?? "string") === "string")
|
|
565
|
+
rest.pop();
|
|
566
|
+
}
|
|
567
|
+
const p = parseArgs({ args: rest, options: REPLICA.options, allowPositionals: true, strict: false });
|
|
568
|
+
const str = (v) => typeof v === "string" ? v : undefined;
|
|
569
|
+
const named = [p.positionals[0] === "create" ? p.positionals[1] : undefined, str(p.values.replica)].find((x) => x !== undefined && /^[a-z0-9][a-z0-9-]{0,62}\/[a-z0-9][a-z0-9-]{0,62}$/.test(x));
|
|
570
|
+
const region = str(p.values.region), size = str(p.values.size), timeout = str(p.values.timeout);
|
|
571
|
+
const line = `mason replica create ${named ?? "<org>/<name>"} --region ${region !== undefined && REGION.test(region) ? region : "<region>"} --size ${size !== undefined && SIZE.test(size) ? size : "<gb>"}` +
|
|
572
|
+
`${timeout !== undefined && /^[0-9]{1,6}[smhd]?$/.test(timeout) ? ` --timeout ${timeout}` : ""}${p.values.json === true ? " --json" : ""} < url.txt`;
|
|
573
|
+
return { message: "a database URL is never taken on mason replica's command line", code: "usage",
|
|
574
|
+
why: "a command line is kept in the shell's history and shown to every process on this machine: if that URL holds a real password, change it",
|
|
575
|
+
fix: `give it on stdin: ${line} (or from a secret store's command, piped); at a terminal, mason replica create asks for it and does not show it`,
|
|
576
|
+
next: [[line, "create it, the URL read from stdin"]] };
|
|
577
|
+
}
|
|
578
|
+
export async function replica(argv) {
|
|
579
|
+
// before the parser, whose refusal of an option would quote its value
|
|
580
|
+
const inArgs = urlInArgs(argv);
|
|
581
|
+
if (inArgs !== null)
|
|
582
|
+
return fail(inArgs, argv.includes("--json"));
|
|
583
|
+
const f = parseCommand(REPLICA, argv);
|
|
584
|
+
if (typeof f === "number")
|
|
585
|
+
return f;
|
|
586
|
+
const json = f.values.json === true;
|
|
587
|
+
const usage = (message, fix) => fail({ message, code: "usage", ...(fix ? { fix } : {}), usage: REPLICA.usage }, json);
|
|
588
|
+
const [verb, arg, ...rest] = f.positionals;
|
|
589
|
+
const VERBS = ["create", "list", "status", "delete"];
|
|
590
|
+
if (verb === undefined)
|
|
591
|
+
return usage("create, list, status or delete: mason replica list says the replicas");
|
|
592
|
+
if (!VERBS.includes(verb))
|
|
593
|
+
return usage(`there is no mason replica ${verb}: create, list, status or delete${didYouMean(verb, VERBS)}`);
|
|
594
|
+
if (rest.length)
|
|
595
|
+
return usage(`mason replica ${verb} takes ${verb === "list" ? "one organisation at most" : "one replica"}: ${rest.join(" ")} is too many`);
|
|
596
|
+
for (const k of ["region", "size"])
|
|
597
|
+
if (verb !== "create" && f.values[k] !== undefined)
|
|
598
|
+
return usage(`--${k} goes with mason replica create`);
|
|
599
|
+
if (verb !== "create" && verb !== "delete" && f.values.timeout !== undefined)
|
|
600
|
+
return usage("--timeout goes with mason replica create and mason replica delete");
|
|
601
|
+
if (verb !== "delete" && f.values.yes === true)
|
|
602
|
+
return usage("--yes goes with mason replica delete");
|
|
603
|
+
const flag = (k) => typeof f.values[k] === "string" ? f.values[k] : undefined;
|
|
604
|
+
if (verb === "list") {
|
|
605
|
+
if (flag("replica") !== undefined)
|
|
606
|
+
return usage("--replica goes with mason replica create and status", "name the organisation: mason replica list <org>, or --org <org>");
|
|
607
|
+
const org = orgOf(flag("org"), arg, REPLICA.usage);
|
|
608
|
+
if (org !== null && isFailure(org))
|
|
609
|
+
return fail(org, json);
|
|
610
|
+
return list(org, json);
|
|
611
|
+
}
|
|
612
|
+
if (flag("org") !== undefined)
|
|
613
|
+
return usage(`--org goes with mason replica list`, `mason replica ${verb} names the replica: <org>/<name>, or --replica <org>/<name>`);
|
|
614
|
+
// the one resolver of every command that names a replica: the argument, else --replica, else MASON_REPLICA (said)
|
|
615
|
+
const r = replicaOf(flag("replica"), REPLICA.usage, process.env, { argument: arg });
|
|
616
|
+
if (r === null)
|
|
617
|
+
return usage(`mason replica ${verb} names the replica: mason replica ${verb} <org>/<name>`, `or --replica <org>/<name>, or ${REPLICA_ENV}=<org>/<name>`);
|
|
618
|
+
if (isFailure(r))
|
|
619
|
+
return fail(r, json);
|
|
620
|
+
if (verb === "status")
|
|
621
|
+
return status(r, json);
|
|
622
|
+
if (verb === "delete") {
|
|
623
|
+
const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
|
|
624
|
+
if ("problem" in timeout)
|
|
625
|
+
return usage(`--timeout ${timeout.problem}`);
|
|
626
|
+
// a deletion is asked for twice: on the line, and typed back by a person at a terminal, else by --yes
|
|
627
|
+
if (f.values.yes !== true && !canAsk(json)) {
|
|
628
|
+
return usage(`mason replica delete asks for ${replicaText(r)} to be typed back at a terminal, and ${json ? "--json is no terminal" : "there is none here"}`, `say it on the line: mason replica delete ${replicaText(r)} --yes`);
|
|
629
|
+
}
|
|
630
|
+
return remove(r, { timeout, yes: f.values.yes === true }, json);
|
|
631
|
+
}
|
|
632
|
+
const region = typeof f.values.region === "string" ? f.values.region : undefined;
|
|
633
|
+
const size = typeof f.values.size === "string" ? f.values.size : undefined;
|
|
634
|
+
if (region === undefined || size === undefined)
|
|
635
|
+
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)");
|
|
636
|
+
if (!REGION.test(region))
|
|
637
|
+
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");
|
|
638
|
+
if (!SIZE.test(size))
|
|
639
|
+
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");
|
|
640
|
+
const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
|
|
641
|
+
if ("problem" in timeout)
|
|
642
|
+
return usage(`--timeout ${timeout.problem}`);
|
|
643
|
+
return create(r, { region, sizeGb: Number(size), timeout }, json);
|
|
644
|
+
}
|
|
645
|
+
/** The service and the login a request is sent with, the output's gate holding the token; or the Failure. */
|
|
646
|
+
function signedIn(asked, again, json, more = []) {
|
|
647
|
+
const service = serviceOf();
|
|
648
|
+
if (isFailure(service))
|
|
649
|
+
return service;
|
|
650
|
+
const token = tokenFor(service, { asked, again });
|
|
651
|
+
if (isFailure(token))
|
|
652
|
+
return token;
|
|
653
|
+
// from here every byte written is put to the exact test against what this command holds (src/cli/output-gate.ts)
|
|
654
|
+
holdOutput({ command: "replica", json, secrets: [token, ...more] });
|
|
655
|
+
return { service, token };
|
|
656
|
+
}
|
|
657
|
+
/** One replica's answer (`GET …/{name}`, a deletion's): its strings put to the exact test first, its words masked, by
|
|
658
|
+
* the one list; the replica and its deletion (null when it is in none, or the service gave none while it is being
|
|
659
|
+
* deleted), or null when either does not hold. */
|
|
660
|
+
function replicaAnswer(service, a, asked, secrets) {
|
|
661
|
+
if (!obj(a.json) || !carriesNoSecret(wordless(a.json), secrets))
|
|
662
|
+
return null;
|
|
663
|
+
const replica = heldReplica(a.json.replica, asked, secrets, service);
|
|
664
|
+
if (replica === null)
|
|
665
|
+
return null;
|
|
666
|
+
const deletion = heldDeletion(a.json.replica.deletion, replica.state, secrets);
|
|
667
|
+
return deletion === null ? null : { replica, deletion: deletion ?? null };
|
|
668
|
+
}
|
|
669
|
+
async function status(r, json) {
|
|
670
|
+
const s = signedIn("a replica's state is asked for", `mason replica status ${replicaText(r)}`, json);
|
|
671
|
+
if (isFailure(s))
|
|
672
|
+
return fail(s, json);
|
|
673
|
+
const { service, token } = s, secrets = [token];
|
|
674
|
+
const a = await call(service, "GET", `/v1/replicas/${r.org}/${r.name}`, { token });
|
|
675
|
+
if (isFailure(a))
|
|
676
|
+
return fail(a, json);
|
|
677
|
+
if (a.status !== 200)
|
|
678
|
+
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);
|
|
679
|
+
const answer = replicaAnswer(service, a, r, secrets);
|
|
680
|
+
if (answer === null)
|
|
681
|
+
return fail(unexpected(service, a, "a replica's state was asked for"), json);
|
|
682
|
+
const { replica: held, deletion } = answer;
|
|
683
|
+
const next = nextOf(held);
|
|
684
|
+
if (json) {
|
|
685
|
+
out(jsonText(REPLICA, { replica: held, deletion }, next));
|
|
686
|
+
return 0;
|
|
687
|
+
}
|
|
688
|
+
out(deletionReport({ headline: `${replicaText(r)} on ${service.base}: ${held.state}`, details: [...rows(held), ...deletionDetails(held.state, deletion)], deletion,
|
|
689
|
+
...(deletion?.stage === "needs_person" ? { tips: [NEEDS_PERSON_TIP] } : {}), next }, outStyle()));
|
|
690
|
+
return 0;
|
|
691
|
+
}
|
|
692
|
+
async function list(org, json) {
|
|
693
|
+
const again = "mason replica list <org>";
|
|
694
|
+
const s = signedIn("the replicas are asked for", org === null ? "mason replica list" : `mason replica list ${org}`, json);
|
|
695
|
+
if (isFailure(s))
|
|
696
|
+
return fail(s, json);
|
|
697
|
+
const { service, token } = s, secrets = [token];
|
|
698
|
+
const orgs = org !== null ? [org] : await loginOrgs(service, token, again);
|
|
699
|
+
if (isFailure(orgs))
|
|
700
|
+
return fail(orgs, json);
|
|
701
|
+
const slugs = orgs.map((o) => typeof o === "string" ? o : o.slug);
|
|
702
|
+
const replicas = [];
|
|
703
|
+
const more = [];
|
|
704
|
+
for (const slug of slugs) {
|
|
705
|
+
const a = await call(service, "GET", `/v1/replicas/${slug}`, { token, maxBytes: MAX_LIST_BYTES });
|
|
706
|
+
if (isFailure(a))
|
|
707
|
+
return fail(a, json);
|
|
708
|
+
if (a.status !== 200)
|
|
709
|
+
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);
|
|
710
|
+
const j = a.json;
|
|
711
|
+
if (!obj(j) || !Array.isArray(j.replicas) || j.replicas.length > MAX_REPLICAS || typeof j.more !== "boolean" || !carriesNoSecret(wordless(j), secrets))
|
|
712
|
+
return fail(unexpected(service, a, "the replicas were asked for"), json);
|
|
713
|
+
for (const v of j.replicas) {
|
|
714
|
+
const h = heldReplica(v, { org: slug }, secrets, service);
|
|
715
|
+
if (h === null)
|
|
716
|
+
return fail(unexpected(service, a, "the replicas were asked for"), json);
|
|
717
|
+
replicas.push(h);
|
|
718
|
+
}
|
|
719
|
+
if (j.more)
|
|
720
|
+
more.push(slug);
|
|
721
|
+
}
|
|
722
|
+
const where = slugs.length === 1 ? slugs[0] : slugs.length === 0 ? "no organisation" : `${slugs.length} organisations`;
|
|
723
|
+
const next = replicas.length
|
|
724
|
+
? [statusStep(replicas[0]), [`mason replica create ${slugs[0]}/<name> --region <region> --size <gb>`, "another"]]
|
|
725
|
+
: slugs.length ? [[`mason replica create ${slugs[0]}/<name> --region <region> --size <gb>`, "make one (an account's first, in the service's web app)"]] : [];
|
|
726
|
+
if (json) {
|
|
727
|
+
out(jsonText(REPLICA, { replicas, more: more.length > 0 }, next));
|
|
728
|
+
return 0;
|
|
729
|
+
}
|
|
730
|
+
const st = outStyle();
|
|
731
|
+
const head = replicas.length ? `${replicas.length} replica${replicas.length === 1 ? "" : "s"} in ${where}, on ${service.base}`
|
|
732
|
+
: slugs.length ? `No replicas yet in ${where}, on ${service.base}` : `This login is a member of no organisation on ${service.base}`;
|
|
733
|
+
const w = Math.max(0, ...replicas.map((r) => `${r.org}/${r.name}`.length)), sw = Math.max(0, ...replicas.map((r) => r.state.length));
|
|
734
|
+
const lines = replicas.map((r) => ` ${`${r.org}/${r.name}`.padEnd(w)} ${r.state.padEnd(sw)} ${r.region}, ${r.sizeGb} GB, host ${r.hostId}`);
|
|
735
|
+
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`);
|
|
736
|
+
out(strict(`${st.bold(head)}${lines.length ? `\n${lines.join("\n")}` : ""}${endText({ tips, next }, st) ? `\n${endText({ tips, next }, st)}` : ""}`, st));
|
|
737
|
+
return 0;
|
|
738
|
+
}
|
|
739
|
+
/** A password shorter than this, in the one reading (src/cli/hosted.ts `canonicalSecret`), is not sent: no boundary can
|
|
740
|
+
* tell it apart from ordinary words in what is printed. */
|
|
741
|
+
export const MIN_PASSWORD = 8;
|
|
742
|
+
/**
|
|
743
|
+
* THE URL'S PASSWORD, AS THIS COMMAND HOLDS IT (PR 660's review, B1: one rule for every boundary): decoded (a malformed
|
|
744
|
+
* escape as written), held beside the token in the ONE list of secrets every boundary reads — the exact test of every
|
|
745
|
+
* value (`carriesNoSecret`), the masks of free text (`serviceText`) and the output's gate — each by the one reading
|
|
746
|
+
* (src/cli/hosted.ts `readUnits`: whole, and by its runs when its body is long enough). None for a URL without one;
|
|
747
|
+
* `short` for one under `MIN_PASSWORD` characters in that reading, which is not sent.
|
|
748
|
+
*/
|
|
749
|
+
export function urlPassword(url) {
|
|
750
|
+
let raw = "";
|
|
751
|
+
try {
|
|
752
|
+
raw = new URL(url).password;
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
raw = "";
|
|
756
|
+
} // not a URL: parseSource refused it before this
|
|
757
|
+
// decoded, as a mask reads text; a malformed escape held as it is written, never dropped
|
|
758
|
+
const password = (() => { try {
|
|
759
|
+
return decodeURIComponent(raw);
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
return raw;
|
|
763
|
+
} })();
|
|
764
|
+
if (password === "")
|
|
765
|
+
return { password: null };
|
|
766
|
+
return [...canonicalSecret(password)].length < MIN_PASSWORD ? { short: true } : { password };
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* EVERY WORD A CREATION PRINTS OF ITS OWN, by the builders it prints with (`create` prints through these alone): its
|
|
770
|
+
* progress, the lines of a lost answer and of the wait, and each failure and report it can end with. What the service
|
|
771
|
+
* sends goes in where a builder takes it, through the masks and the exact test. `printedCorpus` renders them.
|
|
772
|
+
*/
|
|
773
|
+
export function createWords(c) {
|
|
774
|
+
const name = replicaText(c.r);
|
|
775
|
+
const again = [`mason replica create ${name} --region ${c.region} --size ${c.sizeGb}`, "create it again"];
|
|
776
|
+
const status = statusStep(c.r);
|
|
777
|
+
return {
|
|
778
|
+
again, status,
|
|
779
|
+
progress: `Creating ${name} on ${c.base}: Mason checks the database (${c.variable}, its URL ${c.from}) from the replica's side, up to a minute`,
|
|
780
|
+
lost: (why, st) => ` The creation's answer was lost (${why}); the service has ${name}, ${STATE_WORDS[st]}`,
|
|
781
|
+
wait: (st, unsure) => unsure === null ? STATE_WORDS[st] : `${STATE_WORDS[st]}; the service could not say more just now (${unsure}): asking again`,
|
|
782
|
+
unsureOf: (what, statusCode) => `${what} answered ${statusCode}`,
|
|
783
|
+
/** a 503 with Retry-After: when the service asks for another try (the CLI does not wait for it) */
|
|
784
|
+
retryIn: (why, seconds) => `${why ? `${why}; ` : ""}the service asks for another try in ${seconds} s: run the same command again then`,
|
|
785
|
+
/** a 403 says who may, and where an account's first replica is made (the API refuses it: a person is asked there) */
|
|
786
|
+
refusedNext: (st) => st === 403
|
|
787
|
+
? [["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}`]]
|
|
788
|
+
: refusedNext(st, c.r.org, "an admin or an owner", again),
|
|
789
|
+
/** the checks failed: they are Mason's own, which mason doctor runs here against the same database */
|
|
790
|
+
checksFailedNext: [["mason doctor", "the same checks of the database, run here"], again],
|
|
791
|
+
lostFailure: (sent, absent) => ({ ...sent,
|
|
792
|
+
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"}`,
|
|
793
|
+
fix: `mason replica status ${name} says whether it was; if not, create it again`, next: [status, again] }),
|
|
794
|
+
failed: (why) => ({ message: `${name} was not made: its region could not make it, after every attempt`, code: "failed", ...(why ? { why } : {}),
|
|
795
|
+
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] }),
|
|
796
|
+
deleted: (st) => ({ message: `${name} is ${st === "deleting" ? "being deleted" : "deleted"}`, code: "refused",
|
|
797
|
+
why: "it was deleted while it was made", fix: `create it again, as ${name} or under another name`, next: [again] }),
|
|
798
|
+
timedOut: (st) => ({ message: `${name} was not ready within ${c.timeout.every}`, code: "timeout", why: `it was last ${STATE_WORDS[st]}`,
|
|
799
|
+
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] }),
|
|
800
|
+
gone: () => ({ message: `the service has no ${name} now`, code: "not_found", why: "it was made, and is gone: deleted, or its creation undone",
|
|
801
|
+
fix: `mason replica list ${c.r.org} says the organisation's replicas`, next: [[`mason replica list ${c.r.org}`, "the organisation's replicas"]] }),
|
|
802
|
+
report: (state, next, st) => reportText({ headline: `Created ${name} on ${c.base}: it is ready`,
|
|
803
|
+
details: rows(state).filter(([k]) => k !== "state"),
|
|
804
|
+
tips: [state.page !== null ? "Its allowlist, the addresses that may query it, is set on its page, above." : `Its allowlist, the addresses that may query it, is set on its page in the service's web app, ${c.base}`,
|
|
805
|
+
"The service does not keep the database's URL: the replica reads it by its variable, from its own environment."], next }, st),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
/** The `/v1/` failures the contract documents for a creation and a state's answer, each status with its codes. */
|
|
809
|
+
const DOCUMENTED = [[400, ["bad_request"]], [401, ["unauthenticated"]], [403, ["refused"]], [404, ["not_found"]],
|
|
810
|
+
[409, ["refused", "busy"]], [413, ["too_large"]], [415, ["bad_request"]], [422, ["checks_failed"]], [426, ["refused", "upgrade_required"]], [429, ["slow_down"]], [500, ["failed"]],
|
|
811
|
+
[502, ["unavailable"]], [503, ["checks_unknown", "unavailable"]], [504, ["unavailable"]]];
|
|
812
|
+
/**
|
|
813
|
+
* EVERYTHING A CREATION CAN PRINT OF ITS OWN, rendered (R2-B2: a password made of the CLI's own words failed an honest run,
|
|
814
|
+
* after the replica was made): each builder of `createWords`, with this run's own values, in each order it is printed —
|
|
815
|
+
* a person's stderr, stdout and the two together, and --json — every service's value a placeholder that carries no
|
|
816
|
+
* word. The output's gate reads what a run writes; what it could read of the CLI's own is in here.
|
|
817
|
+
*/
|
|
818
|
+
export function printedCorpus(c, tail = 256) {
|
|
819
|
+
const w = createWords(c), service = { base: c.base }, P = "#";
|
|
820
|
+
const failure = (f) => failureText("replica", f, PLAIN);
|
|
821
|
+
const asJson = (v, next) => jsonText(REPLICA, v, next);
|
|
822
|
+
const sample = (state, pending = [], page = P) => ({ org: c.r.org, name: c.r.name, region: c.region, sizeGb: c.sizeGb, hostId: P, state, createdAt: P, allowlist: [], page, pending });
|
|
823
|
+
const pendings = [[], ...["create", "allowlist"].flatMap((change) => ["pending", "failed"].flatMap((st) => [0, 1, 2].map((attempts) => [{ change, state: st, attempts, failure: P, nextAttemptAt: P }])))];
|
|
824
|
+
const checks = OUTCOMES.map((outcome) => ({ source: c.source, outcome, checks: CHECK_STATUSES.map((status) => ({ name: P, status, detail: P, fix: P })), missing: [P] }));
|
|
825
|
+
const ends = [];
|
|
826
|
+
const nexts = [nextOf(sample("ready")), [w.status]];
|
|
827
|
+
const failures = [w.failed(P), w.failed(""), w.deleted("deleting"), w.deleted("deleted"), w.gone(),
|
|
828
|
+
...STATES.map((st) => w.timedOut(st)),
|
|
829
|
+
...[{ 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))),
|
|
830
|
+
...DOCUMENTED.flatMap(([status, codes]) => codes.flatMap((code) => [P, undefined].flatMap((why) => {
|
|
831
|
+
const f = serviceFailure({ status, json: { error: { code, message: P, ...(why ? { why, fix: P } : {}) } }, what: `POST /v1/replicas/${c.r.org}`, retryAfter: null }, [], w.refusedNext);
|
|
832
|
+
return status === 422 ? [{ ...f, next: w.checksFailedNext }] : status === 503 ? [f, { ...f, why: w.retryIn(f.why, P) }] : [f];
|
|
833
|
+
}))),
|
|
834
|
+
...[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))))];
|
|
835
|
+
for (const f of failures)
|
|
836
|
+
ends.push({ stderr: failure(f) }, { stdout: asJson({ replica: sample("creating"), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ replica: sample("creating", [], null), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ checks, error: errorJson(f) }, f.next ?? []) });
|
|
837
|
+
for (const pending of pendings)
|
|
838
|
+
for (const next of nexts) {
|
|
839
|
+
ends.push({ stdout: w.report(sample("ready", pending), next, PLAIN) }, { stdout: w.report(sample("ready", pending, null), next, PLAIN) }, { stdout: asJson({ replica: sample("ready", pending), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending, null), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending), checks: null }, next) });
|
|
840
|
+
}
|
|
841
|
+
// the lines on stderr before an end, in the order they are printed: the progress, the checks or a lost answer's line,
|
|
842
|
+
// then the wait's lines (each state, as it is or unsure), in both orders
|
|
843
|
+
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)`];
|
|
844
|
+
const waits = ["checking", "creating"].flatMap((st) => unsure.map((u) => ` ${w.wait(st, u)}`));
|
|
845
|
+
const before = [[w.progress, ...checkLines(checks), ...waits], [w.progress, ...STATES.map((st) => w.lost(P, st)), ...[...waits].reverse()]];
|
|
846
|
+
const spinner = `mason ${REPLICA.name}`;
|
|
847
|
+
const corpus = [];
|
|
848
|
+
for (const lines of before) {
|
|
849
|
+
const err = lines.join("\n");
|
|
850
|
+
corpus.push(`${spinner}\n${err}`);
|
|
851
|
+
// each end after what came before it: the gate reads across writes as far as its tail reaches (`tail`)
|
|
852
|
+
for (const end of ends)
|
|
853
|
+
corpus.push(`${err.slice(-tail)}\n${"stdout" in end ? end.stdout : end.stderr}`);
|
|
854
|
+
}
|
|
855
|
+
for (const end of ends)
|
|
856
|
+
corpus.push("stdout" in end ? end.stdout : end.stderr);
|
|
857
|
+
return corpus;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* WHETHER THE CLI'S OWN WORDS HOLD THE PASSWORD, as the output's gate would read them (its own test, `readingsCarry`, on
|
|
861
|
+
* every text of `printedCorpus`): then an honest run would be refused by the gate, after the replica was made, blaming
|
|
862
|
+
* the service. Such a password is refused before anything is sent, in words that say so. The corpus's readings are made
|
|
863
|
+
* once a run.
|
|
864
|
+
*/
|
|
865
|
+
export function ownWordsHold(password, c) {
|
|
866
|
+
const tail = Math.max(256, 3 * password.length), key = `${tail}\u0000${JSON.stringify(c)}`;
|
|
867
|
+
let reads = CORPUS.get(key);
|
|
868
|
+
if (reads === undefined) {
|
|
869
|
+
reads = printedCorpus(c, tail).map((text) => readingsOf(text));
|
|
870
|
+
CORPUS.set(key, reads);
|
|
871
|
+
}
|
|
872
|
+
return reads.some((read) => readingsCarry(read, [password]));
|
|
873
|
+
}
|
|
874
|
+
const CORPUS = new Map();
|
|
875
|
+
/** How long stdin may stay silent when it is not a terminal: a pipe left open with nothing in it never holds create. */
|
|
876
|
+
export const STDIN_MS = 60_000;
|
|
877
|
+
/** The URL's own bound: what the contract takes (docs/HOSTED-API.md). */
|
|
878
|
+
export const MAX_URL = 2048;
|
|
879
|
+
/** The one variable a replica sets for its source (docs/HOSTED-API.md): a service refuses any other `variable`. */
|
|
880
|
+
export const REPLICA_VARIABLE = "DATABASE_URL";
|
|
881
|
+
/** The lowest port a replica connects to: below it, a service refuses the URL. */
|
|
882
|
+
export const MIN_PORT = 1024;
|
|
883
|
+
/** libpq's parameter names, said by name when a URL carries one; any other is "a parameter", never its text. */
|
|
884
|
+
const LIBPQ_PARAMS = new Set(["host", "hostaddr", "port", "dbname", "user", "password", "passfile", "options", "application_name", "fallback_application_name",
|
|
885
|
+
"connect_timeout", "client_encoding", "keepalives", "target_session_attrs", "sslcert", "sslkey", "sslrootcert", "sslcrl", "sslpassword", "sslnegotiation",
|
|
886
|
+
"gssencmode", "krbsrvname", "service", "replication", "channel_binding", "require_auth", "load_balance_hosts"]);
|
|
887
|
+
/**
|
|
888
|
+
* WHAT A SERVICE REFUSES OF A URL (docs/HOSTED-API.md "The URL"), checked here before anything is sent: more than one
|
|
889
|
+
* host, no host, no password, no database name, a query parameter other than `sslmode`, a port below `MIN_PORT`. Each
|
|
890
|
+
* is said by what is wrong, never by a part of the URL (a parameter by libpq's name alone). Null when none applies,
|
|
891
|
+
* or when it is not a URL at all (Mason's own grammar, `parseSource`, says so after).
|
|
892
|
+
*/
|
|
893
|
+
export function replicaUrlProblem(url) {
|
|
894
|
+
const after = url.trim().replace(/^[a-z]+:\/\//i, "");
|
|
895
|
+
const authority = after.split(/[/?#]/, 1)[0] ?? "";
|
|
896
|
+
if (authority.slice(authority.lastIndexOf("@") + 1).includes(","))
|
|
897
|
+
return "it names more than one host: a replica connects to one host, postgres://user:password@host:5432/your_app";
|
|
898
|
+
let u;
|
|
899
|
+
try {
|
|
900
|
+
u = new URL(url.trim());
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
if (u.hostname === "")
|
|
906
|
+
return "it names no host: postgres://user:password@host:5432/your_app";
|
|
907
|
+
if (u.password === "")
|
|
908
|
+
return "it has no password: a replica connects as a user with one, postgres://user:password@host:5432/your_app";
|
|
909
|
+
if (u.pathname.replace(/^\//, "") === "")
|
|
910
|
+
return "it names no database: add it after the port, postgres://user:password@host:5432/your_app";
|
|
911
|
+
const others = [...new Set([...u.searchParams.keys()].filter((k) => k !== "sslmode"))];
|
|
912
|
+
if (others.length) {
|
|
913
|
+
const named = others.map((k) => k.toLowerCase()).filter((k) => LIBPQ_PARAMS.has(k));
|
|
914
|
+
const what = named.length === others.length ? `${named.length === 1 ? "the parameter" : "the parameters"} ${named.join(", ")}` : named.length ? `the parameters ${named.join(", ")} and another` : "a parameter";
|
|
915
|
+
return `it has ${what} after the ?: a replica takes sslmode alone; leave the rest out`;
|
|
916
|
+
}
|
|
917
|
+
if (u.port !== "" && Number(u.port) < MIN_PORT)
|
|
918
|
+
return `its port is below ${MIN_PORT}: a replica connects to a database on port ${MIN_PORT} or above (Postgres's own is 5432)`;
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* THE DATABASE'S URL, from the person or the pipe (src/cli/secret-input.ts): stdin a terminal, a hidden prompt, its
|
|
923
|
+
* question on stderr; else stdin read whole. Never a flag, an argument or a file. What went wrong is said by kind,
|
|
924
|
+
* never by value, with the line to run instead (`again`).
|
|
925
|
+
*/
|
|
926
|
+
export async function readUrl(variable, again, input = process.stdin, output = process.stderr, o = {}) {
|
|
927
|
+
const tty = input.isTTY === true;
|
|
928
|
+
const from = tty ? "typed at the prompt" : "read from stdin";
|
|
929
|
+
const piped = `${again} < url.txt`;
|
|
930
|
+
let read;
|
|
931
|
+
try {
|
|
932
|
+
read = tty ? await readHidden(input, output, `${variable}, the database's URL (not shown; Ctrl-C cancels): `, MAX_URL)
|
|
933
|
+
: await readPiped(input, MAX_URL, o.stdinMs ?? STDIN_MS);
|
|
934
|
+
}
|
|
935
|
+
catch (e) {
|
|
936
|
+
// the stream's own error (a terminal gone, a pipe broken): its words are the system's, and hold nothing typed
|
|
937
|
+
return { message: `the database's URL could not be ${tty ? "read at the prompt" : "read from stdin"}: nothing was sent`, code: "failed",
|
|
938
|
+
why: serviceText(e instanceof Error ? e.message : String(e), 200), fix: tty ? `run it again, or give the URL on stdin: ${piped}` : `run it again: ${piped}` };
|
|
939
|
+
}
|
|
940
|
+
if ("value" in read)
|
|
941
|
+
return { url: read.value, from };
|
|
942
|
+
const fix = tty ? `run it again and paste the URL when it asks, or give it on stdin: ${piped}` : `give the URL on stdin: ${piped} (or from a secret store's command, piped); at a terminal, it is asked for`;
|
|
943
|
+
const said = {
|
|
944
|
+
cancelled: { message: "cancelled: no URL was given, and nothing was sent", why: tty ? "Ctrl-C, or the input ended, at the prompt" : "stdin ended" },
|
|
945
|
+
empty: tty ? { message: "cancelled: no URL was typed, and nothing was sent", why: "an empty line cancels the prompt" }
|
|
946
|
+
: { message: "stdin was empty: no URL was given, and nothing was sent", why: "stdin is not a terminal, so mason replica create reads the database's URL from it, whole" },
|
|
947
|
+
too_long: { message: `the database's URL ${from} is longer than ${MAX_URL} bytes: nothing was sent`, why: "a URL is sent as postgres://… or postgresql://…, at most 2 KiB" },
|
|
948
|
+
lines: { message: "stdin holds more than one line: nothing was sent", why: "the database's URL is read from stdin whole, one line (one trailing newline is trimmed)" },
|
|
949
|
+
timeout: { message: `nothing arrived on stdin within ${Math.round((o.stdinMs ?? STDIN_MS) / 1000)} s: nothing was sent`, why: "stdin is not a terminal, so mason replica create reads the database's URL from it, and it stayed open with nothing in it" },
|
|
950
|
+
closed: { message: "the prompt's input ended before the line did: nothing was sent", why: "the terminal closed while the URL was typed" },
|
|
951
|
+
};
|
|
952
|
+
return { ...said[read.none], code: "refused", fix };
|
|
953
|
+
}
|
|
954
|
+
async function create(r, o, json) {
|
|
955
|
+
// THE DATABASE: the project's sources file names the variable the replica reads it by; its value is never read here
|
|
956
|
+
const t = theProject(process.cwd());
|
|
957
|
+
if ("problem" in t)
|
|
958
|
+
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);
|
|
959
|
+
const p = t.project;
|
|
960
|
+
const src = readSourceFile(p.folder, p.root);
|
|
961
|
+
if (isFailure(src))
|
|
962
|
+
return fail(src, json);
|
|
963
|
+
const file = shownPath(p.root, src.file);
|
|
964
|
+
const again = `mason replica create ${replicaText(r)} --region ${o.region} --size ${o.sizeGb}`;
|
|
965
|
+
if ("literal" in src.url) {
|
|
966
|
+
return fail({ message: `${file}: url: is written in the file`, code: "refused",
|
|
967
|
+
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",
|
|
968
|
+
fix: `write url: \${DATABASE_URL} in ${file}, then ${again} again: it asks for the URL at a terminal, or reads it from stdin (${again} < url.txt)` }, json);
|
|
969
|
+
}
|
|
970
|
+
const variable = src.url.env;
|
|
971
|
+
// the one variable a replica sets for its database: a service refuses any other name (400), so it is refused here,
|
|
972
|
+
// before the URL is asked for
|
|
973
|
+
if (variable !== REPLICA_VARIABLE) {
|
|
974
|
+
return fail({ message: `${file}: url: \${${variable}} names a variable a replica does not set`, code: "refused",
|
|
975
|
+
why: `a replica sets one variable for its database, ${REPLICA_VARIABLE}, and reads the URL by that name; a service refuses any other`,
|
|
976
|
+
fix: `write url: \${${REPLICA_VARIABLE}} in ${file} (and ${REPLICA_VARIABLE}= in .env, for mason start), then ${again} again` }, json);
|
|
977
|
+
}
|
|
978
|
+
const service = serviceOf();
|
|
979
|
+
if (isFailure(service))
|
|
980
|
+
return fail(service, json);
|
|
981
|
+
const s = signedIn("a replica is created", `mason replica create ${replicaText(r)}`, json);
|
|
982
|
+
if (isFailure(s))
|
|
983
|
+
return fail(s, json);
|
|
984
|
+
const { token } = s;
|
|
985
|
+
// THE URL: asked for, or read from stdin; never a flag, an argument or a file
|
|
986
|
+
const got = await readUrl(variable, again);
|
|
987
|
+
if (isFailure(got))
|
|
988
|
+
return fail(got, json);
|
|
989
|
+
const { url, from } = got;
|
|
990
|
+
const named = `the URL given for ${variable} (${from})`;
|
|
991
|
+
// the value is held to the grammar Mason connects by before it leaves this machine, never repeated (preflight's rule)
|
|
992
|
+
// first its form, then what a service refuses (each said by what is wrong, never by a part of it), then the grammar
|
|
993
|
+
// Mason connects by
|
|
994
|
+
const form = !/^postgres(?:ql)?:\/\//i.test(url.trim()) || url.length > MAX_URL ? "it is sent as postgres://… or postgresql://…, at most 2 KiB" : replicaUrlProblem(url);
|
|
995
|
+
const parsed = form === null ? parseSource(url, named) : null;
|
|
996
|
+
const problem = form ?? (parsed !== null && "problem" in parsed ? parsed.problem : null);
|
|
997
|
+
if (problem !== null) {
|
|
998
|
+
return fail({ message: `${named} is not a database URL a replica can be made for`, code: "refused", why: problem,
|
|
999
|
+
fix: `give the database's URL, postgres://user:password@host:5432/your_app: ${again} < url.txt, or at the prompt` }, json);
|
|
1000
|
+
}
|
|
1001
|
+
const run = { r, region: o.region, sizeGb: o.sizeGb, timeout: o.timeout, base: service.base, variable, from, source: src.name };
|
|
1002
|
+
const w = createWords(run);
|
|
1003
|
+
// THE PASSWORD, before anything is held or sent (neither refusal names it): one too short to be told apart from
|
|
1004
|
+
// words, or one the CLI's own words hold, is not sent, since no honest run could print without the gate refusing it
|
|
1005
|
+
const pw = urlPassword(url);
|
|
1006
|
+
const notSent = (message, why) => fail({ message, code: "refused", why,
|
|
1007
|
+
fix: "name a database user with another password in the URL, or create the replica in the service's web app" }, json);
|
|
1008
|
+
if ("short" in pw) {
|
|
1009
|
+
return notSent(`the password in ${named} 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");
|
|
1010
|
+
}
|
|
1011
|
+
if (pw.password !== null && ownWordsHold(pw.password, run)) {
|
|
1012
|
+
return notSent(`the password in ${named} 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");
|
|
1013
|
+
}
|
|
1014
|
+
const held = pw.password === null ? [] : [pw.password];
|
|
1015
|
+
// the gate holds it beside the token before anything more is written (src/cli/output-gate.ts)
|
|
1016
|
+
holdOutput({ command: "replica", json, secrets: held });
|
|
1017
|
+
// ONE list of what this command holds, read by every boundary: the exact test, the masks and the output's gate
|
|
1018
|
+
const secrets = [token, ...held];
|
|
1019
|
+
const where = `/v1/replicas/${r.org}`;
|
|
1020
|
+
const refusedOf = (a, step) => serviceFailure(a, secrets, w.refusedNext) ?? unexpected(service, a, step);
|
|
1021
|
+
if (!json)
|
|
1022
|
+
say(w.progress);
|
|
1023
|
+
// THE CREATION: the service checks the source, then answers 201, or why it was not made (with the checks, said)
|
|
1024
|
+
const sent = await call(service, "POST", where, { token, timeoutMs: CREATE_MS,
|
|
1025
|
+
json: { name: r.name, region: o.region, sizeGb: o.sizeGb, sources: [{ name: src.name, variable, url }], client: { version: version() } } });
|
|
1026
|
+
let checks = null;
|
|
1027
|
+
let state;
|
|
1028
|
+
let waitMs = REPLICA_POLL_MS;
|
|
1029
|
+
if (isFailure(sent)) {
|
|
1030
|
+
// a POST that got no answer may have made the replica: its state says so (the contract)
|
|
1031
|
+
const a = await call(service, "GET", `${where}/${r.name}`, { token });
|
|
1032
|
+
const found = !isFailure(a) && a.status === 200 ? replicaAnswer(service, a, r, secrets)?.replica ?? null : null;
|
|
1033
|
+
// 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
|
|
1034
|
+
const why = serviceText(sent.why ?? sent.message, 300, secrets);
|
|
1035
|
+
if (found === null)
|
|
1036
|
+
return fail(w.lostFailure({ ...sent, why }, !isFailure(a) && a.status === 404), json);
|
|
1037
|
+
if (!json)
|
|
1038
|
+
say(w.lost(why, found.state));
|
|
1039
|
+
state = found;
|
|
1040
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
1041
|
+
waitMs = a.retryAfter * 1000;
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
if (obj(sent.json) && sent.status !== 201 && "checks" in sent.json && carriesNoSecret(wordless({ checks: sent.json.checks }), secrets))
|
|
1045
|
+
checks = heldChecks(sent.json.checks, src.name, secrets);
|
|
1046
|
+
if (sent.status !== 201) {
|
|
1047
|
+
const refused = refusedOf(sent, "a replica was created");
|
|
1048
|
+
// a launch the service could not start: its words, and when it asks to be tried again (the CLI does not wait)
|
|
1049
|
+
const f = sent.status === 503 && sent.retryAfter !== null ? { ...refused, why: w.retryIn(refused.why, sent.retryAfter) } : refused;
|
|
1050
|
+
if (!json && checks !== null)
|
|
1051
|
+
for (const l of checkLines(checks))
|
|
1052
|
+
say(l);
|
|
1053
|
+
const next = sent.status === 422 ? w.checksFailedNext : f.next ?? [];
|
|
1054
|
+
const failure = { ...f, next };
|
|
1055
|
+
if (json) {
|
|
1056
|
+
out(jsonText(REPLICA, { checks, error: errorJson(failure) }, next));
|
|
1057
|
+
return exitOf(failure);
|
|
1058
|
+
}
|
|
1059
|
+
return fail(failure, false);
|
|
1060
|
+
}
|
|
1061
|
+
const j = sent.json;
|
|
1062
|
+
const made = obj(j) && carriesNoSecret(wordless(j), secrets) ? heldReplica(j.replica, r, secrets, service) : null;
|
|
1063
|
+
checks = obj(j) ? heldChecks(j.checks, src.name, secrets) : null;
|
|
1064
|
+
if (made === null || checks === null)
|
|
1065
|
+
return fail(unexpected(service, sent, "a replica was created"), json);
|
|
1066
|
+
if (!json)
|
|
1067
|
+
for (const l of checkLines(checks))
|
|
1068
|
+
say(l);
|
|
1069
|
+
state = made;
|
|
1070
|
+
if (sent.retryAfter !== null)
|
|
1071
|
+
waitMs = sent.retryAfter * 1000;
|
|
1072
|
+
}
|
|
1073
|
+
// THE WAIT: until its region has made it (ready) or could not (failed), bounded by --timeout
|
|
1074
|
+
const started = Date.now(), deadline = started + o.timeout.seconds * 1000;
|
|
1075
|
+
let said = "", saidAt = 0;
|
|
1076
|
+
const show = (line) => { if (!json && sayAgain(line, said, saidAt, Date.now())) {
|
|
1077
|
+
say(` ${line}`);
|
|
1078
|
+
said = line;
|
|
1079
|
+
saidAt = Date.now();
|
|
1080
|
+
} };
|
|
1081
|
+
const ended = (error) => {
|
|
1082
|
+
const next = error === null ? nextOf(state) : error.next ?? [];
|
|
1083
|
+
if (json) {
|
|
1084
|
+
out(jsonText(REPLICA, { replica: state, checks, ...(error ? { error: errorJson(error) } : {}) }, next));
|
|
1085
|
+
return error ? exitOf(error) : 0;
|
|
1086
|
+
}
|
|
1087
|
+
if (error !== null)
|
|
1088
|
+
return fail(error, false);
|
|
1089
|
+
out(w.report(state, next, outStyle()));
|
|
1090
|
+
return 0;
|
|
1091
|
+
};
|
|
1092
|
+
for (;;) {
|
|
1093
|
+
if (state.state === "ready")
|
|
1094
|
+
return ended(null);
|
|
1095
|
+
if (state.state === "failed")
|
|
1096
|
+
return ended(w.failed(state.pending.map((x) => x.failure).filter((x) => x !== null).join("; ")));
|
|
1097
|
+
if (state.state === "deleting" || state.state === "deleted")
|
|
1098
|
+
return ended(w.deleted(state.state));
|
|
1099
|
+
show(w.wait(state.state, null));
|
|
1100
|
+
const left = deadline - Date.now();
|
|
1101
|
+
if (left <= 0)
|
|
1102
|
+
return ended(w.timedOut(state.state));
|
|
1103
|
+
await sleep(Math.min(waitMs, left));
|
|
1104
|
+
const a = await call(service, "GET", `${where}/${r.name}`, { token });
|
|
1105
|
+
waitMs = REPLICA_POLL_MS;
|
|
1106
|
+
// an answer that cannot say how it goes is a wait, never an outcome: the timeout bounds it
|
|
1107
|
+
if (isFailure(a) || a.status === 429 || a.status >= 500) {
|
|
1108
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
1109
|
+
waitMs = a.retryAfter * 1000;
|
|
1110
|
+
show(w.wait(state.state, isFailure(a) ? serviceText(a.why ?? a.message, 300, secrets) : w.unsureOf(a.what, a.status)));
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (a.retryAfter !== null)
|
|
1114
|
+
waitMs = a.retryAfter * 1000;
|
|
1115
|
+
if (a.status === 404)
|
|
1116
|
+
return ended(w.gone());
|
|
1117
|
+
if (a.status !== 200)
|
|
1118
|
+
return ended(refusedOf(a, "a replica's state was asked for"));
|
|
1119
|
+
const now = replicaAnswer(service, a, r, secrets);
|
|
1120
|
+
if (now === null)
|
|
1121
|
+
return ended(unexpected(service, a, "a replica's state was asked for"));
|
|
1122
|
+
state = now.replica;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// ------------------------------------------------------------------ delete
|
|
1126
|
+
/** Whether a person is there to type the replica's name back: a terminal both ways, and no --json (never an environment
|
|
1127
|
+
* variable: src/cli/share.ts's rule). */
|
|
1128
|
+
const canAsk = (json) => !json && process.stdin.isTTY === true && process.stderr.isTTY === true;
|
|
1129
|
+
/**
|
|
1130
|
+
* A PERSON TYPES THE REPLICA'S NAME BACK: whether they typed exactly it (nothing trimmed or re-cased, as the service takes
|
|
1131
|
+
* the confirm). What was typed is never printed: it may be anything, a secret pasted in the wrong place. The terminal
|
|
1132
|
+
* echoes it, as a terminal does; the CLI writes nothing of it.
|
|
1133
|
+
*/
|
|
1134
|
+
async function typedBack(name) {
|
|
1135
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: false });
|
|
1136
|
+
const closed = once(rl, "close").then(() => null);
|
|
1137
|
+
const answer = await Promise.race([rl.question("> "), closed]);
|
|
1138
|
+
rl.close();
|
|
1139
|
+
// the input ended with nothing typed (a terminal echoes no line for it): the prompt's line ends here
|
|
1140
|
+
if (answer === null)
|
|
1141
|
+
process.stderr.write("\n");
|
|
1142
|
+
return answer === name;
|
|
1143
|
+
}
|
|
1144
|
+
/** EVERY WORD A DELETION PRINTS OF ITS OWN, by the builders it prints with; what the service sends goes in where a
|
|
1145
|
+
* builder takes it, held and masked. */
|
|
1146
|
+
export function deleteWords(r, base, every) {
|
|
1147
|
+
const name = replicaText(r);
|
|
1148
|
+
const again = [`mason replica delete ${name}`, "delete it again (a repeat changes nothing)"];
|
|
1149
|
+
const status = statusStep(r);
|
|
1150
|
+
const at = (seconds) => new Date(Date.now() + seconds * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1151
|
+
return {
|
|
1152
|
+
again, status,
|
|
1153
|
+
ask: `Deleting ${name} on ${base} cannot be undone: the replica and its synced data go, and Mason's replication slot on its database is dropped. Type ${name} to delete it, anything else to keep it.`,
|
|
1154
|
+
notTyped: { message: `${name} was not deleted: what was typed is not its name`, code: "refused",
|
|
1155
|
+
why: "a deletion is asked for twice: on the command line, and typed back exactly (nothing trimmed, nothing re-cased)",
|
|
1156
|
+
fix: `run it again and type ${name}; where no person is asked, mason replica delete ${name} --yes`, next: [[`mason replica delete ${name}`, "ask again"]] },
|
|
1157
|
+
progress: `Deleting ${name} on ${base}: each step is said as the service reports it, waiting up to ${every}`,
|
|
1158
|
+
lost: (why, d) => ` The deletion's answer was lost (${why}); the service has ${name}, its deletion ${d ? stageLine(d) : NO_DELETION}`,
|
|
1159
|
+
unsure: (d, why) => `${d ? stageLine(d) : NO_DELETION}; the service could not say more just now (${why}): asking again`,
|
|
1160
|
+
noDeletion: NO_DELETION,
|
|
1161
|
+
headline: `Deleted ${name} on ${base}`,
|
|
1162
|
+
waiting: `${name} on ${base} is not deleted yet: its deletion needs a person`,
|
|
1163
|
+
/** a refusal's next step, the CLI's own, by the status (and a 403's code, which the service's words carry) */
|
|
1164
|
+
refusedNext: (status, code, retryAfter) => {
|
|
1165
|
+
if (status === 403 && code === "fresh_login_needed") {
|
|
1166
|
+
return [["mason login", `sign in again, then mason replica delete ${name} within 15 minutes of it; or delete it on its page in the service's web app, ${base}`]];
|
|
1167
|
+
}
|
|
1168
|
+
if (status === 401 || status === 403)
|
|
1169
|
+
return [["mason login", `sign in as an owner of ${r.org}: an owner deletes a replica, signed in as a person (a CI token deletes none)`]];
|
|
1170
|
+
if (status === 404)
|
|
1171
|
+
return [listStep(r.org)];
|
|
1172
|
+
if (status === 429 || status === 503)
|
|
1173
|
+
return [[again[0], retryAfter !== null ? `again after ${at(retryAfter)} (in ${retryAfter} s), when the service takes it` : "again later: the service did not say when"]];
|
|
1174
|
+
return [again];
|
|
1175
|
+
},
|
|
1176
|
+
lostFailure: (sent, seen) => ({ ...sent,
|
|
1177
|
+
why: `${sent.why ?? "no answer"}; ${seen === null ? "whether its deletion began is not known" : "absent" in seen ? `asked since, the service has no ${name} for this login` : `asked since, it is ${seen.state}: its deletion did not begin`}`,
|
|
1178
|
+
fix: `mason replica status ${name} says whether it is being deleted; if it is not, delete it again (a repeat changes nothing)`, next: [status, again] }),
|
|
1179
|
+
/**
|
|
1180
|
+
* NEEDS A PERSON: a slot not verified gone, the replica kept until it is seen gone or confirmed. The statements were
|
|
1181
|
+
* printed; the wait stops at once (nothing more comes without the person), and the exit is 1: it is not deleted.
|
|
1182
|
+
*/
|
|
1183
|
+
needsPerson: (d) => {
|
|
1184
|
+
const left = d.sources.filter((x) => x.outcome === "not_verified");
|
|
1185
|
+
const one = left.length === 1;
|
|
1186
|
+
return { message: `${name} is not deleted yet: ${one ? `the replication slot of source ${left[0].ordinal} is` : `the replication slots of ${left.length} sources are`} NOT verified gone`, code: "failed",
|
|
1187
|
+
why: "a replication slot holds its database's WAL while it exists, whatever the disk, so the service keeps the replica until each slot is seen gone or a person confirms it was dropped",
|
|
1188
|
+
fix: `run the statement printed for ${one ? "it" : "each"} on ${one ? "its database" : "each one's database"} (with --json, deletion.sources[].byHand), then confirm it on the replica's page in the service's web app (or there, that the database is no more): its deletion then finishes`,
|
|
1189
|
+
next: [[status[0], "how its deletion goes, once confirmed"]] };
|
|
1190
|
+
},
|
|
1191
|
+
timedOut: (d) => ({ message: `${name}'s deletion did not finish within ${every}`, code: "timeout", why: `it was last ${d ? stageLine(d) : NO_DELETION}`,
|
|
1192
|
+
fix: `nothing was stopped, and its deletion goes on: mason replica status ${name} says how it goes; a longer wait is --timeout 30m`, next: [status] }),
|
|
1193
|
+
/** gone midway: the statement that lists Mason's slots, pasted (`pasteLines`) — under --json, where nothing is
|
|
1194
|
+
* broken, in the fix too */
|
|
1195
|
+
gone: (json) => ({ message: `the service has no ${name} now`, code: "not_found",
|
|
1196
|
+
why: "it was being deleted, and the service has no record of it now: what its deletion did to each replication slot is not known",
|
|
1197
|
+
fix: json ? `look for Mason's slots on each database it synced, and drop this replica's: ${listSlotsStatement()}` : "look for Mason's slots on each database it synced with the statement printed above, and drop this replica's",
|
|
1198
|
+
next: [listStep(r.org)] }),
|
|
1199
|
+
goneSaid: "by hand, on each database it synced: this lists Mason's replication slots; drop this replica's slot from them, as a user that may drop a replication slot",
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
async function remove(r, o, json) {
|
|
1203
|
+
const name = replicaText(r);
|
|
1204
|
+
const s = signedIn("a replica is deleted", `mason replica delete ${name}`, json);
|
|
1205
|
+
if (isFailure(s))
|
|
1206
|
+
return fail(s, json);
|
|
1207
|
+
const { service, token } = s, secrets = [token];
|
|
1208
|
+
const w = deleteWords(r, service.base, o.timeout.every);
|
|
1209
|
+
// A PERSON TYPES IT BACK (else --yes said it on the line): nothing is sent before
|
|
1210
|
+
if (!o.yes) {
|
|
1211
|
+
say(w.ask);
|
|
1212
|
+
if (!(await typedBack(name)))
|
|
1213
|
+
return fail(w.notTyped, json);
|
|
1214
|
+
}
|
|
1215
|
+
const where = `/v1/replicas/${r.org}/${r.name}`;
|
|
1216
|
+
const step = "a replica's deletion was asked for";
|
|
1217
|
+
if (!json)
|
|
1218
|
+
say(w.progress);
|
|
1219
|
+
// THE DELETION: 202 under way, 200 done already (a repeat changes nothing), else why not, as the service worded it
|
|
1220
|
+
const sent = await call(service, "DELETE", where, { token, json: { confirm: name } });
|
|
1221
|
+
// its deletion: null while the service gives none (a replica deleting may carry none)
|
|
1222
|
+
let state, deletion, waitMs = REPLICA_POLL_MS;
|
|
1223
|
+
const begun = (x) => x.state === "deleting" || x.state === "deleted";
|
|
1224
|
+
if (isFailure(sent)) {
|
|
1225
|
+
// a DELETE that got no answer may have begun it: its state says so
|
|
1226
|
+
const a = await call(service, "GET", where, { token });
|
|
1227
|
+
const found = !isFailure(a) && a.status === 200 ? replicaAnswer(service, a, r, secrets) : null;
|
|
1228
|
+
const why = serviceText(sent.why ?? sent.message, 300, secrets);
|
|
1229
|
+
if (found === null || !begun(found.replica)) {
|
|
1230
|
+
return fail(w.lostFailure({ ...sent, why }, found !== null ? { state: found.replica.state } : !isFailure(a) && a.status === 404 ? { absent: true } : null), json);
|
|
1231
|
+
}
|
|
1232
|
+
if (!json)
|
|
1233
|
+
say(w.lost(why, found.deletion));
|
|
1234
|
+
state = found.replica;
|
|
1235
|
+
deletion = found.deletion;
|
|
1236
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
1237
|
+
waitMs = a.retryAfter * 1000;
|
|
1238
|
+
}
|
|
1239
|
+
else {
|
|
1240
|
+
if (sent.status !== 200 && sent.status !== 202) {
|
|
1241
|
+
const e = obj(sent.json) && obj(sent.json.error) && typeof sent.json.error.code === "string" ? sent.json.error.code : "";
|
|
1242
|
+
return fail(serviceFailure(sent, secrets, (st) => w.refusedNext(st, e, sent.retryAfter)) ?? unexpected(service, sent, step), json);
|
|
1243
|
+
}
|
|
1244
|
+
const held = replicaAnswer(service, sent, r, secrets);
|
|
1245
|
+
// 202 is a deletion under way (its deletion, if the service gives one, not finished), 200 one finished: anything else
|
|
1246
|
+
// is outside the contract
|
|
1247
|
+
if (held === null || !begun(held.replica) || (sent.status === 202) !== (held.replica.state === "deleting"))
|
|
1248
|
+
return fail(unexpected(service, sent, step), json);
|
|
1249
|
+
state = held.replica;
|
|
1250
|
+
deletion = held.deletion;
|
|
1251
|
+
if (sent.retryAfter !== null)
|
|
1252
|
+
waitMs = sent.retryAfter * 1000;
|
|
1253
|
+
}
|
|
1254
|
+
// THE WAIT: until its deletion is finished, bounded by --timeout; each step said once, as it is done
|
|
1255
|
+
const deadline = Date.now() + o.timeout.seconds * 1000;
|
|
1256
|
+
let said = "", saidAt = 0;
|
|
1257
|
+
const told = new Set();
|
|
1258
|
+
const show = (line) => { if (!json && sayAgain(line, said, saidAt, Date.now())) {
|
|
1259
|
+
say(` ${line}`);
|
|
1260
|
+
said = line;
|
|
1261
|
+
saidAt = Date.now();
|
|
1262
|
+
} };
|
|
1263
|
+
const done = (d) => { if (!json)
|
|
1264
|
+
for (const x of d.steps)
|
|
1265
|
+
if (x.doneAt !== null && !told.has(x.step)) {
|
|
1266
|
+
told.add(x.step);
|
|
1267
|
+
say(` done: ${x.what}`);
|
|
1268
|
+
} };
|
|
1269
|
+
const ended = (error) => {
|
|
1270
|
+
const next = error === null ? nextOf(state) : error.next ?? [];
|
|
1271
|
+
if (json) {
|
|
1272
|
+
out(jsonText(REPLICA, { replica: state, deletion, ...(error ? { error: errorJson(error) } : {}) }, next));
|
|
1273
|
+
return error ? exitOf(error) : 0;
|
|
1274
|
+
}
|
|
1275
|
+
// finished, or needing a person: what it did to each slot, and the statements by hand (a failure after them says why
|
|
1276
|
+
// the exit is 1)
|
|
1277
|
+
if (deletion?.stage === "finished" || deletion?.stage === "needs_person") {
|
|
1278
|
+
out(deletionReport({ headline: deletion.stage === "finished" ? w.headline : w.waiting, details: deletionRows(deletion), deletion, next: error === null ? next : [] }, outStyle()));
|
|
1279
|
+
}
|
|
1280
|
+
return error === null ? 0 : fail(error, false);
|
|
1281
|
+
};
|
|
1282
|
+
for (;;) {
|
|
1283
|
+
if (deletion?.stage === "finished") {
|
|
1284
|
+
done(deletion);
|
|
1285
|
+
return ended(null);
|
|
1286
|
+
}
|
|
1287
|
+
// a person is needed: nothing more comes of waiting, so it stops at once, whatever --timeout
|
|
1288
|
+
if (deletion?.stage === "needs_person") {
|
|
1289
|
+
done(deletion);
|
|
1290
|
+
return ended(w.needsPerson(deletion));
|
|
1291
|
+
}
|
|
1292
|
+
show(deletion ? stageLine(deletion) : w.noDeletion);
|
|
1293
|
+
if (deletion)
|
|
1294
|
+
done(deletion);
|
|
1295
|
+
const left = deadline - Date.now();
|
|
1296
|
+
if (left <= 0)
|
|
1297
|
+
return ended(w.timedOut(deletion));
|
|
1298
|
+
await sleep(Math.min(waitMs, left));
|
|
1299
|
+
const a = await call(service, "GET", where, { token });
|
|
1300
|
+
waitMs = REPLICA_POLL_MS;
|
|
1301
|
+
// an answer that cannot say how it goes is a wait, never an outcome: the timeout bounds it
|
|
1302
|
+
if (isFailure(a) || a.status === 429 || a.status >= 500) {
|
|
1303
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
1304
|
+
waitMs = a.retryAfter * 1000;
|
|
1305
|
+
show(w.unsure(deletion, isFailure(a) ? serviceText(a.why ?? a.message, 300, secrets) : `${a.what} answered ${a.status}`));
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (a.retryAfter !== null)
|
|
1309
|
+
waitMs = a.retryAfter * 1000;
|
|
1310
|
+
if (a.status === 404) {
|
|
1311
|
+
if (!json)
|
|
1312
|
+
out(pasteLines([[w.goneSaid, listSlotsStatement()]], outStyle()).join("\n"));
|
|
1313
|
+
return ended(w.gone(json));
|
|
1314
|
+
}
|
|
1315
|
+
if (a.status !== 200)
|
|
1316
|
+
return ended(serviceFailure(a, secrets, (st) => refusedNext(st, r.org, "a member", w.status)) ?? unexpected(service, a, step));
|
|
1317
|
+
const now = replicaAnswer(service, a, r, secrets);
|
|
1318
|
+
// a deletion does not end otherwise than finished: a replica in none now is outside the contract
|
|
1319
|
+
if (now === null || !begun(now.replica))
|
|
1320
|
+
return ended(unexpected(service, a, step));
|
|
1321
|
+
state = now.replica;
|
|
1322
|
+
deletion = now.deletion;
|
|
1323
|
+
}
|
|
1324
|
+
}
|