@mason-data/cli 0.9.0 → 0.11.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 +6 -4
- package/dist/src/api/error.js +4 -0
- package/dist/src/api/postgres/proxy.js +6 -160
- package/dist/src/api/postgres/server.js +22 -4
- package/dist/src/cli/command.js +169 -5
- package/dist/src/cli/commands.js +295 -159
- package/dist/src/cli/deploy.js +134 -28
- package/dist/src/cli/dev.js +21 -5
- package/dist/src/cli/doctor.js +14 -11
- package/dist/src/cli/guides.js +20 -11
- package/dist/src/cli/hosted.js +1 -1
- package/dist/src/cli/init.js +110 -25
- package/dist/src/cli/login.js +20 -17
- package/dist/src/cli/machine.js +125 -40
- package/dist/src/cli/main.js +51 -8
- package/dist/src/cli/mcp.js +15 -12
- package/dist/src/cli/project.js +25 -2
- package/dist/src/cli/registry.js +80 -35
- package/dist/src/cli/replica-hosted.js +761 -102
- package/dist/src/cli/replica.js +43 -5
- package/dist/src/cli/replication-mode.js +5 -0
- package/dist/src/cli/request-filter.js +16 -6
- package/dist/src/cli/run-server.js +70 -31
- package/dist/src/cli/secret-input.js +193 -0
- package/dist/src/cli/security.js +7 -2
- package/dist/src/cli/serve.js +119 -9
- package/dist/src/cli/share.js +20 -15
- package/dist/src/cli/source-role.js +14 -15
- package/dist/src/cli/sources.js +20 -3
- package/dist/src/cli/style.js +14 -0
- package/dist/src/cli/sync.js +17 -8
- package/dist/src/cli/token-hosted.js +30 -24
- package/dist/src/cli/tunnel.js +1 -1
- package/dist/src/cli/ui-hosted.js +163 -0
- package/dist/src/cli/ui-read-only.js +23 -0
- package/dist/src/cli/ui-run-file.js +247 -0
- package/dist/src/cli/ui-socket.js +183 -0
- package/dist/src/cli/ui-ssh.js +3 -3
- package/dist/src/cli/ui.js +67 -26
- package/dist/src/cli/usage.js +19 -15
- package/dist/src/cli/user-hosted.js +78 -13
- package/dist/src/cli/user.js +168 -50
- package/dist/src/duck/users.js +105 -9
- package/dist/src/lib/cidr.js +177 -0
- package/dist/src/lib/table-sync.js +69 -19
- package/dist/src/web/app.css +5 -0
- package/dist/src/web/app.js +4 -4
- package/dist/src/web/index.html +5 -8
- package/dist/src/web/pages/queries.js +3 -3
- package/dist/src/web/pages/status.js +23 -17
- package/dist/src/web/pages/syncs.js +3 -3
- package/dist/src/web/pages/tables.js +18 -15
- package/dist/src/web/pages/usage.js +16 -12
- package/dist/src/web/pages/users.js +45 -14
- package/dist/src/web/shared.js +10 -4
- package/package.json +6 -6
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `mason replica create | list | status` — A HOSTED MASON SERVICE'S REPLICAS, by its documented API alone
|
|
2
|
+
* `mason replica create | list | status | delete` — A HOSTED MASON SERVICE'S REPLICAS, by its documented API alone
|
|
3
3
|
* (docs/HOSTED-API.md "Replicas"). Each is a client of it, as `mason deploy` and `mason user --replica` are:
|
|
4
4
|
*
|
|
5
5
|
* create <org>/<name> POST /v1/replicas/{org} { name, region, sizeGb, sources: [{ name, variable, url }] }
|
|
6
6
|
* 201 { replica, checks }, then GET …/{name} until it is ready, failed or past --timeout
|
|
7
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 }
|
|
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
|
|
9
12
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
29
|
* Its password is held as a secret of this command beside the token, in the one list every boundary reads
|
|
16
30
|
* (`urlPassword`): masked in free text, refused in any other value and by the output's gate, read whole whatever its
|
|
17
31
|
* alphabet; one under `MIN_PASSWORD` characters is not sent, since none of them could tell it from ordinary words.
|
|
@@ -30,25 +44,40 @@
|
|
|
30
44
|
* state, outcome, status, change rows (the CLI's words), --json closed sets (`STATES`, …)
|
|
31
45
|
* createdAt, nextAttemptAt rows, --json `isServiceTime`
|
|
32
46
|
* allowlist a row, --json each `CIDR`, at most `MAX_ALLOWLIST`
|
|
47
|
+
* page a row, --json on the service's own origin (`pageUrl`)
|
|
33
48
|
* a check's name, detail, fix; missing; stderr's lines, --json one line each, bounded, masked (`serviceText`)
|
|
34
49
|
* a pending change's failure
|
|
35
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
|
|
36
58
|
*
|
|
37
|
-
* What to run next, the code and the exit are the CLI's own: 0 once the replica is ready (create)
|
|
38
|
-
* status)
|
|
39
|
-
*
|
|
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).
|
|
40
63
|
*/
|
|
64
|
+
import { once } from "node:events";
|
|
65
|
+
import { createInterface } from "node:readline/promises";
|
|
41
66
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
42
67
|
import { endText, errorJson, exitOf, failed, failureText, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
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";
|
|
45
71
|
import { holdOutput, readingsCarry, readingsOf } from "./output-gate.js";
|
|
46
72
|
import { theProject } from "./project.js";
|
|
47
|
-
import {
|
|
73
|
+
import { readSourceFile, shownPath } from "./sources.js";
|
|
74
|
+
import { readHidden, readPiped } from "./secret-input.js";
|
|
48
75
|
import { parseSource } from "./preflight.js";
|
|
49
|
-
import { parseDuration } from "../lib/table-sync.js";
|
|
76
|
+
import { MAX_POLL_SECONDS, MIN_POLL_SECONDS, parseDuration } from "../lib/table-sync.js";
|
|
50
77
|
import { sayAgain } from "./commands.js";
|
|
78
|
+
import { version } from "./version.js";
|
|
51
79
|
import { didYouMean } from "../lib/did-you-mean.js";
|
|
80
|
+
import { dropStatements, listSlotsStatement } from "./replication-mode.js";
|
|
52
81
|
import { err as styleErr, out as styleOut, PLAIN } from "./style.js";
|
|
53
82
|
/** How long the creation's POST may take: the service answers once Mason's checks of the source are done. */
|
|
54
83
|
export const CREATE_MS = 60_000;
|
|
@@ -72,59 +101,98 @@ const OUTCOMES = ["passed", "failed", "unknown"];
|
|
|
72
101
|
const CHECK_STATUSES = ["ok", "warn", "failed", "unknown"];
|
|
73
102
|
export const REPLICA = {
|
|
74
103
|
name: "replica",
|
|
75
|
-
summary: "a hosted Mason service's replicas: create one for this project's database, list them, say how one is",
|
|
76
|
-
usage: `usage: mason replica create <org>/<name> --region <region> --size <gb> [--timeout <
|
|
77
|
-
mason replica list [<org>] [--json]
|
|
78
|
-
mason replica status <org>/<name> [--json]
|
|
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]
|
|
79
109
|
|
|
80
110
|
A replica is Mason run for you by a hosted Mason service, on a machine of its own, syncing this project's
|
|
81
111
|
database. mason deploy then sends it the project's files, and mason token create a CI token for it.
|
|
82
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
|
+
|
|
83
132
|
create makes one in the organisation <org>, named <name>, in --region (one the service offers), sized by
|
|
84
|
-
--size: the synced data it holds, in GB (one size the service offers, such as 50). The database is
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
89
141
|
characters, or is in the words this command prints itself, is refused too, before anything is sent: nothing
|
|
90
|
-
could keep it out of what is printed.
|
|
91
|
-
Mason's checks of the database (up to a minute) and says each check,
|
|
92
|
-
then until its region has made the replica (--timeout bounds that wait; the replica is made all the same)
|
|
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.
|
|
93
146
|
An admin or an owner of the organisation creates one. An account's FIRST replica is made in the service's web
|
|
94
147
|
app, where it asks a check that a person is creating it, which a command cannot answer: the service refuses it
|
|
95
148
|
here, and the replicas after it can be made here.
|
|
96
149
|
|
|
97
150
|
list says the replicas of <org>, or of every organisation of this login when none is named (a CI token names
|
|
98
151
|
one); status says one replica: its state (checking, creating, ready, failed, deleting, deleted), its region and
|
|
99
|
-
size, its host, its allowlist (the addresses that may query it),
|
|
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.
|
|
100
166
|
|
|
101
167
|
The login is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default.
|
|
102
168
|
Progress goes to stderr; the answer is printed when it ends.
|
|
103
169
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
state, createdAt, allowlist, pending: [{ change (create, allowlist), state (pending,
|
|
113
|
-
failed), attempts, failure, nextAttemptAt }] }), checks (create: [{ source, outcome
|
|
114
|
-
(passed, failed, unknown), checks: [{ name, status (ok, warn, failed, unknown), detail,
|
|
115
|
-
fix }], missing }], null when the service's answer was lost), replicas (list: each as
|
|
116
|
-
replica is), more (list: true when an organisation has more than the service lists),
|
|
117
|
-
error (create, when it was not made, failed or the wait timed out: { code, message,
|
|
118
|
-
why, fix }), next }
|
|
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.
|
|
119
178
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 }
|
|
125
192
|
`,
|
|
126
|
-
options: {
|
|
127
|
-
|
|
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"],
|
|
128
196
|
};
|
|
129
197
|
/** Everything this command prints can hold the service's text, so it is all drawn in serviceStyle (src/cli/command.ts). */
|
|
130
198
|
const outStyle = () => serviceStyle(styleOut());
|
|
@@ -143,7 +211,15 @@ const whole = (v, least, most) => Number.isInteger(v) && v >= least && v <= most
|
|
|
143
211
|
export function wordless(json) {
|
|
144
212
|
if (!obj(json))
|
|
145
213
|
return json;
|
|
146
|
-
|
|
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) } : {}) };
|
|
147
223
|
const checks = (c) => !obj(c) ? c : { ...c,
|
|
148
224
|
...(Array.isArray(c.missing) ? { missing: c.missing.map(() => null) } : {}),
|
|
149
225
|
...(Array.isArray(c.checks) ? { checks: c.checks.map((x) => obj(x) ? { ...x, name: null, detail: null, fix: null } : x) } : {}) };
|
|
@@ -155,9 +231,11 @@ export function wordless(json) {
|
|
|
155
231
|
const words = (v, max, secrets) => typeof v === "string" ? serviceText(v, max, secrets) : null;
|
|
156
232
|
/**
|
|
157
233
|
* A REPLICA, held to the contract: of the organisation asked (and the name asked, for one replica), every value to its
|
|
158
|
-
* grammar; null when any does not hold. The answer around it has been put to the exact test (`wordless`).
|
|
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.
|
|
159
237
|
*/
|
|
160
|
-
export function heldReplica(v, asked, secrets) {
|
|
238
|
+
export function heldReplica(v, asked, secrets, service) {
|
|
161
239
|
if (!obj(v) || v.org !== asked.org || typeof v.name !== "string" || !REPLICA_PART.test(v.name) || (asked.name !== undefined && v.name !== asked.name))
|
|
162
240
|
return null;
|
|
163
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))
|
|
@@ -168,6 +246,13 @@ export function heldReplica(v, asked, secrets) {
|
|
|
168
246
|
return null;
|
|
169
247
|
if (!Array.isArray(v.pending) || v.pending.length > MAX_PENDING)
|
|
170
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
|
+
}
|
|
171
256
|
const pending = [];
|
|
172
257
|
for (const p of v.pending) {
|
|
173
258
|
if (!obj(p) || (p.change !== "create" && p.change !== "allowlist") || (p.state !== "pending" && p.state !== "failed") || !whole(p.attempts, 0, 1_000_000))
|
|
@@ -180,7 +265,7 @@ export function heldReplica(v, asked, secrets) {
|
|
|
180
265
|
nextAttemptAt: typeof p.nextAttemptAt === "string" ? p.nextAttemptAt : null });
|
|
181
266
|
}
|
|
182
267
|
return { org: v.org, name: v.name, region: v.region, sizeGb: v.sizeGb, hostId: v.hostId, state: v.state,
|
|
183
|
-
createdAt: v.createdAt, allowlist: [...v.allowlist], pending };
|
|
268
|
+
createdAt: v.createdAt, allowlist: [...v.allowlist], page, pending };
|
|
184
269
|
}
|
|
185
270
|
/** Mason's checks, held: one entry at most, for the source sent, each check's status and the outcome closed sets, every
|
|
186
271
|
* word one line; null when they do not hold. */
|
|
@@ -207,6 +292,131 @@ export function heldChecks(v, source, secrets) {
|
|
|
207
292
|
}
|
|
208
293
|
return held;
|
|
209
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
|
+
}
|
|
210
420
|
// ------------------------------------------------------------------ in the CLI's words
|
|
211
421
|
const STATE_WORDS = {
|
|
212
422
|
checking: "checking: Mason checks its database; nothing is made yet",
|
|
@@ -230,14 +440,96 @@ function rows(r) {
|
|
|
230
440
|
return [
|
|
231
441
|
["state", STATE_WORDS[r.state]], ["region", r.region], ["size", `${r.sizeGb} GB of synced data`], ["host", r.hostId], ["created", r.createdAt],
|
|
232
442
|
["allowlist", r.allowlist.length ? r.allowlist.join(", ") : "none: no address may query it yet"],
|
|
443
|
+
...(r.page !== null ? [["page", r.page]] : []),
|
|
233
444
|
...r.pending.map((p) => ["pending", `${p.change === "create" ? "its creation" : "its allowlist"} ${p.state === "failed" ? "failed" : "is under way"}` +
|
|
234
445
|
` (${p.attempts} attempt${p.attempts === 1 ? "" : "s"}${p.nextAttemptAt ? `, the next at ${p.nextAttemptAt}` : ""})${p.failure ? `: ${p.failure}` : ""}`]),
|
|
235
446
|
];
|
|
236
447
|
}
|
|
237
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"];
|
|
238
450
|
const nextOf = (r) => r.state === "ready"
|
|
239
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"]]
|
|
240
|
-
: [statusStep(r)];
|
|
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
|
+
}
|
|
241
533
|
/**
|
|
242
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
|
|
243
535
|
* signs in again, naming the role that may (`may`); a 404 lists the organisation's replicas; else `again`.
|
|
@@ -250,36 +542,93 @@ export function refusedNext(status, org, may, again) {
|
|
|
250
542
|
return [again];
|
|
251
543
|
}
|
|
252
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
|
+
}
|
|
253
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"));
|
|
254
583
|
const f = parseCommand(REPLICA, argv);
|
|
255
584
|
if (typeof f === "number")
|
|
256
585
|
return f;
|
|
257
586
|
const json = f.values.json === true;
|
|
258
587
|
const usage = (message, fix) => fail({ message, code: "usage", ...(fix ? { fix } : {}), usage: REPLICA.usage }, json);
|
|
259
588
|
const [verb, arg, ...rest] = f.positionals;
|
|
260
|
-
const VERBS = ["create", "list", "status"];
|
|
589
|
+
const VERBS = ["create", "list", "status", "delete"];
|
|
261
590
|
if (verb === undefined)
|
|
262
|
-
return usage("create, list or
|
|
591
|
+
return usage("create, list, status or delete: mason replica list says the replicas");
|
|
263
592
|
if (!VERBS.includes(verb))
|
|
264
|
-
return usage(`there is no mason replica ${verb}: create, list or
|
|
593
|
+
return usage(`there is no mason replica ${verb}: create, list, status or delete${didYouMean(verb, VERBS)}`);
|
|
265
594
|
if (rest.length)
|
|
266
595
|
return usage(`mason replica ${verb} takes ${verb === "list" ? "one organisation at most" : "one replica"}: ${rest.join(" ")} is too many`);
|
|
267
|
-
for (const k of ["region", "size"
|
|
596
|
+
for (const k of ["region", "size"])
|
|
268
597
|
if (verb !== "create" && f.values[k] !== undefined)
|
|
269
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;
|
|
270
604
|
if (verb === "list") {
|
|
271
|
-
|
|
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);
|
|
272
608
|
if (org !== null && isFailure(org))
|
|
273
609
|
return fail(org, json);
|
|
274
610
|
return list(org, json);
|
|
275
611
|
}
|
|
276
|
-
if (
|
|
277
|
-
return usage(`mason replica ${verb} names the replica:
|
|
278
|
-
|
|
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>`);
|
|
279
618
|
if (isFailure(r))
|
|
280
619
|
return fail(r, json);
|
|
281
620
|
if (verb === "status")
|
|
282
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
|
+
}
|
|
283
632
|
const region = typeof f.values.region === "string" ? f.values.region : undefined;
|
|
284
633
|
const size = typeof f.values.size === "string" ? f.values.size : undefined;
|
|
285
634
|
if (region === undefined || size === undefined)
|
|
@@ -288,7 +637,7 @@ export async function replica(argv) {
|
|
|
288
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");
|
|
289
638
|
if (!SIZE.test(size))
|
|
290
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");
|
|
291
|
-
const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT);
|
|
640
|
+
const timeout = parseDuration(typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT, { min: MIN_POLL_SECONDS, max: MAX_POLL_SECONDS });
|
|
292
641
|
if ("problem" in timeout)
|
|
293
642
|
return usage(`--timeout ${timeout.problem}`);
|
|
294
643
|
return create(r, { region, sizeGb: Number(size), timeout }, json);
|
|
@@ -305,11 +654,17 @@ function signedIn(asked, again, json, more = []) {
|
|
|
305
654
|
holdOutput({ command: "replica", json, secrets: [token, ...more] });
|
|
306
655
|
return { service, token };
|
|
307
656
|
}
|
|
308
|
-
/** One replica's answer (`GET …/{name}
|
|
309
|
-
|
|
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) {
|
|
310
661
|
if (!obj(a.json) || !carriesNoSecret(wordless(a.json), secrets))
|
|
311
662
|
return null;
|
|
312
|
-
|
|
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 };
|
|
313
668
|
}
|
|
314
669
|
async function status(r, json) {
|
|
315
670
|
const s = signedIn("a replica's state is asked for", `mason replica status ${replicaText(r)}`, json);
|
|
@@ -321,15 +676,17 @@ async function status(r, json) {
|
|
|
321
676
|
return fail(a, json);
|
|
322
677
|
if (a.status !== 200)
|
|
323
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);
|
|
324
|
-
const
|
|
325
|
-
if (
|
|
679
|
+
const answer = replicaAnswer(service, a, r, secrets);
|
|
680
|
+
if (answer === null)
|
|
326
681
|
return fail(unexpected(service, a, "a replica's state was asked for"), json);
|
|
682
|
+
const { replica: held, deletion } = answer;
|
|
327
683
|
const next = nextOf(held);
|
|
328
684
|
if (json) {
|
|
329
|
-
out(jsonText(REPLICA, { replica: held }, next));
|
|
685
|
+
out(jsonText(REPLICA, { replica: held, deletion }, next));
|
|
330
686
|
return 0;
|
|
331
687
|
}
|
|
332
|
-
out(
|
|
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()));
|
|
333
690
|
return 0;
|
|
334
691
|
}
|
|
335
692
|
async function list(org, json) {
|
|
@@ -354,7 +711,7 @@ async function list(org, json) {
|
|
|
354
711
|
if (!obj(j) || !Array.isArray(j.replicas) || j.replicas.length > MAX_REPLICAS || typeof j.more !== "boolean" || !carriesNoSecret(wordless(j), secrets))
|
|
355
712
|
return fail(unexpected(service, a, "the replicas were asked for"), json);
|
|
356
713
|
for (const v of j.replicas) {
|
|
357
|
-
const h = heldReplica(v, { org: slug }, secrets);
|
|
714
|
+
const h = heldReplica(v, { org: slug }, secrets, service);
|
|
358
715
|
if (h === null)
|
|
359
716
|
return fail(unexpected(service, a, "the replicas were asked for"), json);
|
|
360
717
|
replicas.push(h);
|
|
@@ -419,10 +776,12 @@ export function createWords(c) {
|
|
|
419
776
|
const status = statusStep(c.r);
|
|
420
777
|
return {
|
|
421
778
|
again, status,
|
|
422
|
-
progress: `Creating ${name} on ${c.base}: Mason checks the database ${c.variable}
|
|
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`,
|
|
423
780
|
lost: (why, st) => ` The creation's answer was lost (${why}); the service has ${name}, ${STATE_WORDS[st]}`,
|
|
424
781
|
wait: (st, unsure) => unsure === null ? STATE_WORDS[st] : `${STATE_WORDS[st]}; the service could not say more just now (${unsure}): asking again`,
|
|
425
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`,
|
|
426
785
|
/** a 403 says who may, and where an account's first replica is made (the API refuses it: a person is asked there) */
|
|
427
786
|
refusedNext: (st) => st === 403
|
|
428
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}`]]
|
|
@@ -442,12 +801,13 @@ export function createWords(c) {
|
|
|
442
801
|
fix: `mason replica list ${c.r.org} says the organisation's replicas`, next: [[`mason replica list ${c.r.org}`, "the organisation's replicas"]] }),
|
|
443
802
|
report: (state, next, st) => reportText({ headline: `Created ${name} on ${c.base}: it is ready`,
|
|
444
803
|
details: rows(state).filter(([k]) => k !== "state"),
|
|
445
|
-
tips: [
|
|
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),
|
|
446
806
|
};
|
|
447
807
|
}
|
|
448
808
|
/** The `/v1/` failures the contract documents for a creation and a state's answer, each status with its codes. */
|
|
449
809
|
const DOCUMENTED = [[400, ["bad_request"]], [401, ["unauthenticated"]], [403, ["refused"]], [404, ["not_found"]],
|
|
450
|
-
[409, ["refused", "busy"]], [413, ["too_large"]], [415, ["bad_request"]], [422, ["checks_failed"]], [429, ["slow_down"]], [500, ["failed"]],
|
|
810
|
+
[409, ["refused", "busy"]], [413, ["too_large"]], [415, ["bad_request"]], [422, ["checks_failed"]], [426, ["refused", "upgrade_required"]], [429, ["slow_down"]], [500, ["failed"]],
|
|
451
811
|
[502, ["unavailable"]], [503, ["checks_unknown", "unavailable"]], [504, ["unavailable"]]];
|
|
452
812
|
/**
|
|
453
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,
|
|
@@ -459,7 +819,7 @@ export function printedCorpus(c, tail = 256) {
|
|
|
459
819
|
const w = createWords(c), service = { base: c.base }, P = "#";
|
|
460
820
|
const failure = (f) => failureText("replica", f, PLAIN);
|
|
461
821
|
const asJson = (v, next) => jsonText(REPLICA, v, next);
|
|
462
|
-
const sample = (state, pending = []) => ({ org: c.r.org, name: c.r.name, region: c.region, sizeGb: c.sizeGb, hostId: P, state, createdAt: P, allowlist: [], pending });
|
|
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 });
|
|
463
823
|
const pendings = [[], ...["create", "allowlist"].flatMap((change) => ["pending", "failed"].flatMap((st) => [0, 1, 2].map((attempts) => [{ change, state: st, attempts, failure: P, nextAttemptAt: P }])))];
|
|
464
824
|
const checks = OUTCOMES.map((outcome) => ({ source: c.source, outcome, checks: CHECK_STATUSES.map((status) => ({ name: P, status, detail: P, fix: P })), missing: [P] }));
|
|
465
825
|
const ends = [];
|
|
@@ -467,16 +827,16 @@ export function printedCorpus(c, tail = 256) {
|
|
|
467
827
|
const failures = [w.failed(P), w.failed(""), w.deleted("deleting"), w.deleted("deleted"), w.gone(),
|
|
468
828
|
...STATES.map((st) => w.timedOut(st)),
|
|
469
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))),
|
|
470
|
-
...DOCUMENTED.flatMap(([status, codes]) => codes.flatMap((code) => [P, undefined].
|
|
830
|
+
...DOCUMENTED.flatMap(([status, codes]) => codes.flatMap((code) => [P, undefined].flatMap((why) => {
|
|
471
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);
|
|
472
|
-
return status === 422 ? { ...f, next: w.checksFailedNext } : f;
|
|
832
|
+
return status === 422 ? [{ ...f, next: w.checksFailedNext }] : status === 503 ? [f, { ...f, why: w.retryIn(f.why, P) }] : [f];
|
|
473
833
|
}))),
|
|
474
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))))];
|
|
475
835
|
for (const f of failures)
|
|
476
|
-
ends.push({ stderr: failure(f) }, { stdout: asJson({ replica: sample("creating"), checks, error: errorJson(f) }, f.next ?? []) }, { stdout: asJson({ checks, error: errorJson(f) }, f.next ?? []) });
|
|
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 ?? []) });
|
|
477
837
|
for (const pending of pendings)
|
|
478
838
|
for (const next of nexts) {
|
|
479
|
-
ends.push({ stdout: w.report(sample("ready", pending), next, PLAIN) }, { stdout: asJson({ replica: sample("ready", pending), checks }, next) }, { stdout: asJson({ replica: sample("ready", pending), checks: null }, next) });
|
|
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) });
|
|
480
840
|
}
|
|
481
841
|
// the lines on stderr before an end, in the order they are printed: the progress, the checks or a lost answer's line,
|
|
482
842
|
// then the wait's lines (each state, as it is or unsure), in both orders
|
|
@@ -512,34 +872,133 @@ export function ownWordsHold(password, c) {
|
|
|
512
872
|
return reads.some((read) => readingsCarry(read, [password]));
|
|
513
873
|
}
|
|
514
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
|
+
}
|
|
515
954
|
async function create(r, o, json) {
|
|
516
|
-
// THE DATABASE: the project's sources file names
|
|
955
|
+
// THE DATABASE: the project's sources file names the variable the replica reads it by; its value is never read here
|
|
517
956
|
const t = theProject(process.cwd());
|
|
518
957
|
if ("problem" in t)
|
|
519
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);
|
|
520
959
|
const p = t.project;
|
|
521
|
-
const src =
|
|
960
|
+
const src = readSourceFile(p.folder, p.root);
|
|
522
961
|
if (isFailure(src))
|
|
523
962
|
return fail(src, json);
|
|
524
963
|
const file = shownPath(p.root, src.file);
|
|
964
|
+
const again = `mason replica create ${replicaText(r)} --region ${o.region} --size ${o.sizeGb}`;
|
|
525
965
|
if ("literal" in src.url) {
|
|
526
966
|
return fail({ message: `${file}: url: is written in the file`, code: "refused",
|
|
527
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",
|
|
528
|
-
fix: `write url: \${DATABASE_URL} in ${file},
|
|
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);
|
|
529
969
|
}
|
|
530
970
|
const variable = src.url.env;
|
|
531
|
-
// the
|
|
532
|
-
|
|
533
|
-
if (
|
|
534
|
-
return fail({ message: `${
|
|
535
|
-
why:
|
|
536
|
-
fix: `
|
|
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);
|
|
537
977
|
}
|
|
538
|
-
const url = src.resolved;
|
|
539
978
|
const service = serviceOf();
|
|
540
979
|
if (isFailure(service))
|
|
541
980
|
return fail(service, json);
|
|
542
|
-
const
|
|
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 };
|
|
543
1002
|
const w = createWords(run);
|
|
544
1003
|
// THE PASSWORD, before anything is held or sent (neither refusal names it): one too short to be told apart from
|
|
545
1004
|
// words, or one the CLI's own words hold, is not sent, since no honest run could print without the gate refusing it
|
|
@@ -547,16 +1006,14 @@ async function create(r, o, json) {
|
|
|
547
1006
|
const notSent = (message, why) => fail({ message, code: "refused", why,
|
|
548
1007
|
fix: "name a database user with another password in the URL, or create the replica in the service's web app" }, json);
|
|
549
1008
|
if ("short" in pw) {
|
|
550
|
-
return notSent(`the password in ${
|
|
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");
|
|
551
1010
|
}
|
|
552
1011
|
if (pw.password !== null && ownWordsHold(pw.password, run)) {
|
|
553
|
-
return notSent(`the password in ${
|
|
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");
|
|
554
1013
|
}
|
|
555
1014
|
const held = pw.password === null ? [] : [pw.password];
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
return fail(s, json);
|
|
559
|
-
const { token } = s;
|
|
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 });
|
|
560
1017
|
// ONE list of what this command holds, read by every boundary: the exact test, the masks and the output's gate
|
|
561
1018
|
const secrets = [token, ...held];
|
|
562
1019
|
const where = `/v1/replicas/${r.org}`;
|
|
@@ -565,14 +1022,14 @@ async function create(r, o, json) {
|
|
|
565
1022
|
say(w.progress);
|
|
566
1023
|
// THE CREATION: the service checks the source, then answers 201, or why it was not made (with the checks, said)
|
|
567
1024
|
const sent = await call(service, "POST", where, { token, timeoutMs: CREATE_MS,
|
|
568
|
-
json: { name: r.name, region: o.region, sizeGb: o.sizeGb, sources: [{ name: src.name, variable, url }] } });
|
|
1025
|
+
json: { name: r.name, region: o.region, sizeGb: o.sizeGb, sources: [{ name: src.name, variable, url }], client: { version: version() } } });
|
|
569
1026
|
let checks = null;
|
|
570
1027
|
let state;
|
|
571
1028
|
let waitMs = REPLICA_POLL_MS;
|
|
572
1029
|
if (isFailure(sent)) {
|
|
573
1030
|
// a POST that got no answer may have made the replica: its state says so (the contract)
|
|
574
1031
|
const a = await call(service, "GET", `${where}/${r.name}`, { token });
|
|
575
|
-
const found = !isFailure(a) && a.status === 200 ? replicaAnswer(a, r, secrets) : null;
|
|
1032
|
+
const found = !isFailure(a) && a.status === 200 ? replicaAnswer(service, a, r, secrets)?.replica ?? null : null;
|
|
576
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
|
|
577
1034
|
const why = serviceText(sent.why ?? sent.message, 300, secrets);
|
|
578
1035
|
if (found === null)
|
|
@@ -587,7 +1044,9 @@ async function create(r, o, json) {
|
|
|
587
1044
|
if (obj(sent.json) && sent.status !== 201 && "checks" in sent.json && carriesNoSecret(wordless({ checks: sent.json.checks }), secrets))
|
|
588
1045
|
checks = heldChecks(sent.json.checks, src.name, secrets);
|
|
589
1046
|
if (sent.status !== 201) {
|
|
590
|
-
const
|
|
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;
|
|
591
1050
|
if (!json && checks !== null)
|
|
592
1051
|
for (const l of checkLines(checks))
|
|
593
1052
|
say(l);
|
|
@@ -600,7 +1059,7 @@ async function create(r, o, json) {
|
|
|
600
1059
|
return fail(failure, false);
|
|
601
1060
|
}
|
|
602
1061
|
const j = sent.json;
|
|
603
|
-
const made = obj(j) && carriesNoSecret(wordless(j), secrets) ? heldReplica(j.replica, r, secrets) : null;
|
|
1062
|
+
const made = obj(j) && carriesNoSecret(wordless(j), secrets) ? heldReplica(j.replica, r, secrets, service) : null;
|
|
604
1063
|
checks = obj(j) ? heldChecks(j.checks, src.name, secrets) : null;
|
|
605
1064
|
if (made === null || checks === null)
|
|
606
1065
|
return fail(unexpected(service, sent, "a replica was created"), json);
|
|
@@ -657,9 +1116,209 @@ async function create(r, o, json) {
|
|
|
657
1116
|
return ended(w.gone());
|
|
658
1117
|
if (a.status !== 200)
|
|
659
1118
|
return ended(refusedOf(a, "a replica's state was asked for"));
|
|
660
|
-
const now = replicaAnswer(a, r, secrets);
|
|
1119
|
+
const now = replicaAnswer(service, a, r, secrets);
|
|
661
1120
|
if (now === null)
|
|
662
1121
|
return ended(unexpected(service, a, "a replica's state was asked for"));
|
|
663
|
-
state = now;
|
|
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;
|
|
664
1323
|
}
|
|
665
1324
|
}
|