@mason-data/cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- 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 +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +505 -99
- 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 +169 -11
- package/dist/src/cli/commands.js +207 -51
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +2 -2
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +148 -27
- package/dist/src/cli/doctor.js +116 -33
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +97 -6
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +47 -11
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +1 -1
- package/dist/src/cli/main.js +8 -2
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +2 -2
- 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 +99 -0
- package/dist/src/cli/readiness.js +42 -6
- package/dist/src/cli/registry.js +12 -4
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/roles.js +2 -1
- package/dist/src/cli/run-server.js +154 -7
- package/dist/src/cli/security.js +57 -10
- package/dist/src/cli/serve.js +63 -7
- package/dist/src/cli/share-run.js +51 -8
- package/dist/src/cli/share.js +3 -3
- package/dist/src/cli/source-edit.js +10 -4
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +30 -20
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +15 -23
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +115 -11
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +126 -74
- 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 +5 -0
- package/dist/src/duck/verified.js +159 -2
- package/dist/src/duck/views.js +4 -3
- 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 +24 -9
- 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,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mason deploy` — THE PROJECT IN THIS FOLDER SENT TO A REPLICA ON A HOSTED MASON SERVICE, and its outcome waited for
|
|
3
|
+
* (docs/HOSTED-API.md "Deploying a project"). Only the project's files travel: what Mason reads to run it, never this
|
|
4
|
+
* machine's `.env`, `.mason/` or anything else beside them.
|
|
5
|
+
*
|
|
6
|
+
* 1. THE PROJECT IS CHECKED HERE FIRST, OFFLINE, by Mason's own readers, as `mason start` checks it before it connects
|
|
7
|
+
* (src/cli/dev.ts): the version pin (src/lib/project-version.ts, src/cli/pin.ts), the sources file's text
|
|
8
|
+
* (src/cli/sources.ts `readSourceFile`, the first half of `readSource`), the roles file against the tables the
|
|
9
|
+
* sources file names (src/cli/roles.ts `roleProblems`, the check `resolveRoles` makes, without the database's
|
|
10
|
+
* columns), and the generated tables (src/cli/generated-files.ts `readGenerated`, whose refusals are said and never
|
|
11
|
+
* stop a project, as at a start). No database is connected to: the URL is the replica's environment's there, and the
|
|
12
|
+
* `.env` here may name a development database. A project the check refuses is refused here, and nothing is sent.
|
|
13
|
+
*
|
|
14
|
+
* 2. THE FILES ARE `projectFiles` (src/lib/project-files.ts), the one list of a project's files, read whole: each a
|
|
15
|
+
* regular file inside the project folder (a symbolic link that leaves it is refused), valid UTF-8, at most
|
|
16
|
+
* `MAX_FILE_BYTES`, all of them at most `MAX_TOTAL_BYTES` and `MAX_FILES`, each path in the grammar the contract
|
|
17
|
+
* names (`PATH_SEGMENT`). Every file the readers above read must be one of them: a file read through a linked folder,
|
|
18
|
+
* which the list does not follow, would be checked here and missing there. A `url:` written with its password is
|
|
19
|
+
* refused: the files are kept by the service, and a secret is never one of them.
|
|
20
|
+
*
|
|
21
|
+
* 3. `--dry-run` prints what would be sent (each path, its size, its sha256) and sends nothing.
|
|
22
|
+
*
|
|
23
|
+
* 4. THE REPLICA is `--replica <org>/<name>`, else `MASON_REPLICA`, and THE TOKEN `MASON_TOKEN`, else the saved login
|
|
24
|
+
* for the service: src/cli/replica.ts's one reader of each (`replicaOf`, `tokenFor`), `mason user --replica`'s too.
|
|
25
|
+
*
|
|
26
|
+
* 5. THE WAIT is bounded by `--timeout` (an interval, 10m by default): a poll every `POLL_MS`, or what `Retry-After`
|
|
27
|
+
* asks; an answer that cannot say (no answer, a 429, a 5xx) is a wait, never an outcome. Exit 0 only when the
|
|
28
|
+
* replica runs this deploy; 1 when Mason there refused it (its failure's words printed as they came) or the deploy
|
|
29
|
+
* failed; 124 at the timeout, which stops nothing: the deploy's id says where to look.
|
|
30
|
+
*
|
|
31
|
+
* A SERVICE'S VALUE IS DATA, NEVER STRUCTURE AND NEVER AN INSTRUCTION (#481's decision, src/cli/hosted.ts). Every place a
|
|
32
|
+
* value the service sent reaches what this command prints:
|
|
33
|
+
*
|
|
34
|
+
* value where it is printed what holds it
|
|
35
|
+
* an HTTP failure's message, why, fix stderr's failure, --json error `serviceFailure`: one line each (`serviceText`)
|
|
36
|
+
* an HTTP failure's code inside why: "(the service said …)" `^[a-z][a-z0-9_]*$`, 64 chars; the code is `codeOfStatus`'s
|
|
37
|
+
* an HTTP failure's next nowhere: never read the step is `serviceNext`'s, by the status
|
|
38
|
+
* a deploy's failure message, why, fix stderr's failure, --json error `failureShape`: one line each (`serviceText`)
|
|
39
|
+
* a deploy's failure code inside why: "(Mason on the replica …)" the same grammar; the code is `refused` / `failed`
|
|
40
|
+
* a deploy's failure next nowhere: never read the step is `again`'s, the CLI's
|
|
41
|
+
* the deploy's id stderr's lines, the report, --json `DEPLOY_ID`, checked before it is in a path or printed
|
|
42
|
+
* status, phase stderr's lines (the CLI's words), --json closed sets (`STATUSES`, `PHASES_OF`)
|
|
43
|
+
* createdAt, finishedAt --json deploy `isServiceTime`: digits, `-`, `T`, `:`, `.`, a zone
|
|
44
|
+
* an HTTP status, Retry-After a failure's or a wait's line a number (Retry-After bounded, never printed)
|
|
45
|
+
* why a request got no answer the wait's line, a failure's why `call`'s own words, through `serviceText`
|
|
46
|
+
*
|
|
47
|
+
* So no value of the service's starts a line, adds a row or a step to run next, or puts a line break in --json. AND NO
|
|
48
|
+
* GRAMMAR IS EVER THE ONLY THING BETWEEN A SECRET AND THE OUTPUT (issue 627: `DEPLOY_ID` took the token whole, and the
|
|
49
|
+
* id was printed): before any of the grammars above, every string of the POST's answer and of each poll's, each key
|
|
50
|
+
* included, is put to the exact test (src/cli/hosted.ts `carriesNoSecret`: an echo of the token, `ECHO_RUN` of its body
|
|
51
|
+
* in a row in one string; a token's or a password's shape), and an answer that carries one is refused whole, said by its
|
|
52
|
+
* status. A failure's words (message, why, fix) alone are free text, masked by `serviceText` as every failure's are; its
|
|
53
|
+
* code is put to the same test (`failureShape`).
|
|
54
|
+
*/
|
|
55
|
+
import * as fs from "node:fs";
|
|
56
|
+
import * as path from "node:path";
|
|
57
|
+
import { createHash } from "node:crypto";
|
|
58
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
59
|
+
import { errorJson, exitOf, failed, jsonText, parseCommand, reportText, serviceStyle, strict } from "./command.js";
|
|
60
|
+
import { call, carriesNoSecret, failureShape, isFailure, isServiceTime, serviceFailure, serviceOf, serviceText, unexpected } from "./hosted.js";
|
|
61
|
+
import { replicaOf, replicaText, REPLICA_ENV, tokenFor } from "./replica.js";
|
|
62
|
+
import { holdOutput } from "./output-gate.js";
|
|
63
|
+
import { maskedUrl } from "./preflight.js";
|
|
64
|
+
import { folderPlace, theProject } from "./project.js";
|
|
65
|
+
import { commandPinProblem } from "./pin.js";
|
|
66
|
+
import { readPin } from "../lib/project-version.js";
|
|
67
|
+
import { projectFiles } from "../lib/project-files.js";
|
|
68
|
+
import { readSourceFile, shownPath } from "./sources.js";
|
|
69
|
+
import { readRoles, roleProblems, rolesFailure, rolesFile } from "./roles.js";
|
|
70
|
+
import { readGenerated } from "./generated-files.js";
|
|
71
|
+
import { refusalText } from "../duck/generated-plan.js";
|
|
72
|
+
import { parseDuration } from "../lib/table-sync.js";
|
|
73
|
+
import { textEntry } from "../lib/log.js";
|
|
74
|
+
import { sayAgain } from "./commands.js";
|
|
75
|
+
import { err as styleErr, out as styleOut, stepRenderer } from "./style.js";
|
|
76
|
+
import { version } from "./version.js";
|
|
77
|
+
/** The bounds of what a deploy sends: one file, all of them, how many. */
|
|
78
|
+
export const MAX_FILE_BYTES = 256 * 1024;
|
|
79
|
+
export const MAX_TOTAL_BYTES = 1024 * 1024;
|
|
80
|
+
export const MAX_FILES = 500;
|
|
81
|
+
/** One part of a sent path: what the contract takes (a dotfile is never a project file). */
|
|
82
|
+
export const PATH_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
83
|
+
const MAX_PATH = 255;
|
|
84
|
+
/** How often the deploy's state is asked for, when the service asks no other. */
|
|
85
|
+
export const POLL_MS = 2_000;
|
|
86
|
+
const DEFAULT_TIMEOUT = "10m";
|
|
87
|
+
/** A deploy's id, as it goes into a path: the contract's grammar. */
|
|
88
|
+
const DEPLOY_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
89
|
+
const STATUSES = ["queued", "applying", "starting", "running", "refused", "failed"];
|
|
90
|
+
const FINAL = new Set(["running", "refused", "failed"]);
|
|
91
|
+
/** Everything this command prints can hold the service's text, so it is ALL drawn in serviceStyle (src/cli/command.ts):
|
|
92
|
+
* on a terminal every line is broken by the CLI at the width, never by the terminal, each continuation at column 4 or
|
|
93
|
+
* deeper — the report, the progress, the spinner's line, a failure and a warning alike. */
|
|
94
|
+
const outStyle = () => serviceStyle(styleOut());
|
|
95
|
+
const errStyle = () => serviceStyle(styleErr());
|
|
96
|
+
const out = (m) => { process.stdout.write(`${m}\n`); };
|
|
97
|
+
/** Progress on stderr, a line or several: on a terminal through the strict renderer's last pass (`strict`,
|
|
98
|
+
* src/cli/command.ts), every line within the terminal's width whatever it is, each continuation where no line of the
|
|
99
|
+
* CLI's starts — the phase lines, a step's end (its ✔ or ✘ line), the wait's note and a warning alike. */
|
|
100
|
+
const say = (m) => { process.stderr.write(`${strict(m, errStyle())}\n`); };
|
|
101
|
+
/** A step's label as the spinner draws it on one line (`⠋ label …`, six columns beside it): cut to fit within the
|
|
102
|
+
* terminal's width, never reaching its edge; null when not even a word fits, and then no spinner turns. */
|
|
103
|
+
const clipped = (label) => {
|
|
104
|
+
const width = errStyle().columns;
|
|
105
|
+
if (width === undefined)
|
|
106
|
+
return label;
|
|
107
|
+
const room = width - 1 - 6, chars = [...label];
|
|
108
|
+
if (room < 8)
|
|
109
|
+
return null;
|
|
110
|
+
let n = 0, i = 0;
|
|
111
|
+
for (; i < chars.length; i++) {
|
|
112
|
+
n += chars[i].codePointAt(0) <= 0x7e ? 1 : 2;
|
|
113
|
+
if (n > room)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
return i < chars.length ? `${chars.slice(0, Math.max(0, i - 1)).join("")}…` : label;
|
|
117
|
+
};
|
|
118
|
+
/** A failure said for a person in serviceStyle, or under --json as data. */
|
|
119
|
+
const fail = (f, o) => failed("deploy", f, { json: o.json, style: errStyle() });
|
|
120
|
+
export const DEPLOY = {
|
|
121
|
+
needs: "project",
|
|
122
|
+
name: "deploy",
|
|
123
|
+
summary: "send this project to a replica on a hosted Mason service, and wait until the replica runs it",
|
|
124
|
+
usage: `usage: mason deploy [--replica <org>/<name>] [--dry-run] [--timeout <interval>] [--json]
|
|
125
|
+
|
|
126
|
+
Sends this project's files (what mason/ holds that Mason reads: the sources file, mason.yml, roles.yml, the
|
|
127
|
+
generated tables — never .env, .mason/, a dotfile or a file that is not YAML) to a replica on a hosted Mason
|
|
128
|
+
service, then waits until Mason on the replica runs them, or refuses them. The project is checked here first, as
|
|
129
|
+
mason start checks it, without connecting to any database (the replica's own environment names its URL); a
|
|
130
|
+
project that would be refused is refused here, and nothing is sent. A url: written with its password is refused:
|
|
131
|
+
write url: \${DATABASE_URL} and set it on the replica.
|
|
132
|
+
|
|
133
|
+
The replica is --replica <org>/<name>, else MASON_REPLICA (each part lower case letters, digits and -). The login
|
|
134
|
+
is MASON_TOKEN, else this machine's (mason login); the service is MASON_CLOUD_URL, else the default. Progress
|
|
135
|
+
goes to stderr; the answer is printed when it ends.
|
|
136
|
+
|
|
137
|
+
mason deploy --replica acme/shop
|
|
138
|
+
mason deploy --replica acme/shop --dry-run what would be sent, sending nothing
|
|
139
|
+
MASON_REPLICA=acme/shop mason deploy --timeout 30m
|
|
140
|
+
|
|
141
|
+
--replica <org>/<name> the replica to deploy to (else MASON_REPLICA)
|
|
142
|
+
--dry-run print each file that would be sent, its size and its sha256, and send nothing (no
|
|
143
|
+
login needed)
|
|
144
|
+
--timeout <interval> how long to wait for the outcome (default ${DEFAULT_TIMEOUT}; 1s to 24h); the deploy goes on after it
|
|
145
|
+
--json as data, once it ends: { dryRun, replica (null when none is named on a dry run),
|
|
146
|
+
service, files: [{ path, size, sha256 }], totalBytes, warnings (the generated tables
|
|
147
|
+
refused, as mason start says them, each one line), deploy ({ id, status (queued,
|
|
148
|
+
applying, starting, running, refused, failed), phase (null, checking, writing,
|
|
149
|
+
restarting, syncing), createdAt, finishedAt };
|
|
150
|
+
null on a dry run), error (when the replica refused it, it failed or the wait timed out:
|
|
151
|
+
{ code, message, why, fix }), next }
|
|
152
|
+
|
|
153
|
+
Exit status: 0 the replica runs it (or a dry run printed); 1 refused here or by the service, refused by Mason on
|
|
154
|
+
the replica (its words printed as they came, the code the CLI's own), the deploy failed, or the service answered
|
|
155
|
+
outside its contract; 2 a wrong command line; 124 the wait timed out (nothing stopped: the deploy's id says where
|
|
156
|
+
to look). Whatever the service sends, it never makes the exit 0, 2 or 124.
|
|
157
|
+
`,
|
|
158
|
+
options: { replica: { type: "string" }, "dry-run": { type: "boolean", default: false }, timeout: { type: "string" }, json: { type: "boolean", default: false } },
|
|
159
|
+
json: ["dryRun", "replica", "service", "files", "totalBytes", "warnings", "deploy", "error", "next"],
|
|
160
|
+
};
|
|
161
|
+
/** A `url:` that carries a password: in the URL's user part, or as a `password=` parameter. */
|
|
162
|
+
function writesPassword(url) {
|
|
163
|
+
return maskedUrl(url) !== url || /(?:^|[\s?&;])password\s*=/i.test(url);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* THE PROJECT CHECKED AS `mason start` CHECKS IT BEFORE IT CONNECTS, offline: the pin, the sources file, the roles
|
|
167
|
+
* file against the tables the sources file names, the generated tables (said, never refusing). A Failure for what a
|
|
168
|
+
* start would refuse; else the warnings, and every file the check read (each must be among the files sent).
|
|
169
|
+
*/
|
|
170
|
+
export function checkProject(p, cliVersion = version()) {
|
|
171
|
+
const read = [];
|
|
172
|
+
// the pin: a mason.yml that cannot be read is refused whatever this command's version (a checkout's `dev` checks no
|
|
173
|
+
// version, and the replica's image reads the same file)
|
|
174
|
+
const pin = readPin(p.folder);
|
|
175
|
+
if ("problem" in pin)
|
|
176
|
+
return { message: pin.problem, code: "refused", fix: `edit ${shownPath(p.root, pin.file)}: write version: <x.y.z>, the Mason version the project is written for` };
|
|
177
|
+
if (pin.pin !== null)
|
|
178
|
+
read.push(pin.file);
|
|
179
|
+
const pinned = commandPinProblem(p.folder, cliVersion);
|
|
180
|
+
if (pinned)
|
|
181
|
+
return { message: pinned.problem, code: "refused", ...(pinned.ways.length ? { fix: pinned.ways.join("; or ") } : {}) };
|
|
182
|
+
const src = readSourceFile(p.folder, p.root);
|
|
183
|
+
if ("message" in src)
|
|
184
|
+
return src;
|
|
185
|
+
read.push(src.file);
|
|
186
|
+
if ("literal" in src.url && writesPassword(src.url.literal)) {
|
|
187
|
+
const shown = shownPath(p.root, src.file);
|
|
188
|
+
return { message: `${shown}: url: is written with its password`, code: "refused",
|
|
189
|
+
why: "a deploy sends the project's files to the service, which keeps them: a password is never one of them",
|
|
190
|
+
fix: `write url: \${DATABASE_URL} in ${shown}, and set DATABASE_URL on the replica (its own environment, on the service); keep the URL here in .env` };
|
|
191
|
+
}
|
|
192
|
+
const generated = readGenerated(p.folder, p.root);
|
|
193
|
+
for (const g of [...generated.definitions, ...generated.refused])
|
|
194
|
+
read.push(path.resolve(p.root, g.file));
|
|
195
|
+
const warnings = generated.refused.flatMap((g) => g.refusals.map((r) => refusalText(g.file, r)));
|
|
196
|
+
const roles = readRoles(p.folder, p.root);
|
|
197
|
+
if (roles !== null && "message" in roles)
|
|
198
|
+
return roles;
|
|
199
|
+
if (roles !== null) {
|
|
200
|
+
read.push(rolesFile(p.folder));
|
|
201
|
+
// the tables the sources file names and the generated tables, as the start holds the roles to them — their columns
|
|
202
|
+
// are the database's, which a deploy does not read: the replica's start holds them
|
|
203
|
+
const named = [...src.tables.map((e) => ({ source: e.table, columns: null })),
|
|
204
|
+
...[...generated.definitions, ...generated.refused].map((g) => ({ source: g.name, columns: [], pending: true }))];
|
|
205
|
+
const problems = roleProblems(roles, named);
|
|
206
|
+
if (problems.length)
|
|
207
|
+
return rolesFailure(roles.file, p.root, problems);
|
|
208
|
+
}
|
|
209
|
+
return { warnings, read };
|
|
210
|
+
}
|
|
211
|
+
const bytes = (n) => `${n.toLocaleString("en-US")} bytes`;
|
|
212
|
+
/** A file refused, with the way out. */
|
|
213
|
+
const refuse = (message, why, fix) => ({ message, code: "refused", why, fix });
|
|
214
|
+
/**
|
|
215
|
+
* THE PROJECT'S OWN FOLDER, or why a deploy sends nothing from it: its files must be a folder strictly inside the
|
|
216
|
+
* project's root (`folderPlace`, src/cli/project.ts, the one authority, which `mason init` asks too). Its root itself
|
|
217
|
+
* (`"folder": "."`, mason init --folder .) is the application's folder, and anything outside the root (`..`, `../app`,
|
|
218
|
+
* an absolute path, a link that leaves it) is anyone's: a deploy would send every .yml and .yaml there. Fail closed:
|
|
219
|
+
* asked before any of those files is read.
|
|
220
|
+
*/
|
|
221
|
+
export function ownFolderRefusal(p) {
|
|
222
|
+
const place = folderPlace(p.root, p.folder);
|
|
223
|
+
if (place.place === "inside")
|
|
224
|
+
return null;
|
|
225
|
+
const config = shownPath(p.root, path.join(p.dir, "config.json"));
|
|
226
|
+
// the config's value as it is written, bounded (a path may be long)
|
|
227
|
+
const named = JSON.stringify(p.config.folder.length > 200 ? `${p.config.folder.slice(0, 200)}…` : p.config.folder);
|
|
228
|
+
const fix = `move mason.yml, roles.yml, sources/ and generated_tables/ into a folder of their own inside ${p.root} (mason/), and set "folder": "mason" in ${config}`;
|
|
229
|
+
// a place that cannot be told is refused, saying it could not tell (fail closed), never thrown
|
|
230
|
+
if (place.place === "unknown") {
|
|
231
|
+
return refuse(`this project's files cannot be found: ${config} names "folder": ${named}, which cannot be resolved`, place.why, `set "folder" in ${config} to the project's folder, a folder of its own inside ${p.root} (mason, as mason init writes it)`);
|
|
232
|
+
}
|
|
233
|
+
return place.place === "root"
|
|
234
|
+
? refuse("this project's files are its folder's own (mason init --folder .), beside the application's", "a deploy sends every .yml and .yaml under the project's files, and here that is every one of the application's", fix)
|
|
235
|
+
: refuse(`this project's files are outside its folder (${config} names ${named}, which is ${p.folder.length > 300 ? `${p.folder.slice(0, 300)}…` : p.folder})`, `a deploy sends every .yml and .yaml under the project's files, and outside ${p.root} that is anyone's`, fix);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* THE FILES A DEPLOY SENDS: `projectFiles` of the project folder, each read and held to the contract's bounds, and
|
|
239
|
+
* every file the check read among them. A Failure naming the file and the bound otherwise.
|
|
240
|
+
*/
|
|
241
|
+
export function deployFiles(p, read) {
|
|
242
|
+
const folder = p.folder, shownFolder = `${shownPath(p.root, folder)}/`;
|
|
243
|
+
// never a file of a folder that is not the project's own, whoever calls this (deploy() asks first, before the check)
|
|
244
|
+
const own = ownFolderRefusal(p);
|
|
245
|
+
if (own !== null)
|
|
246
|
+
return own;
|
|
247
|
+
let list, realFolder;
|
|
248
|
+
try {
|
|
249
|
+
list = projectFiles(folder);
|
|
250
|
+
realFolder = fs.realpathSync(folder);
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return { message: `the project's files in ${shownFolder} cannot be listed`, code: "failed", why: e.message, fix: "make the folder readable, then mason deploy again" };
|
|
254
|
+
}
|
|
255
|
+
if (list.length > MAX_FILES)
|
|
256
|
+
return refuse(`${shownFolder} has ${list.length} project files, over the ${MAX_FILES} a deploy sends`, "a project file is any .yml or .yaml under it", `move the files Mason does not read out of ${shownFolder}`);
|
|
257
|
+
const files = [];
|
|
258
|
+
let total = 0;
|
|
259
|
+
for (const file of list) {
|
|
260
|
+
const shown = shownPath(p.root, file);
|
|
261
|
+
const rel = path.relative(folder, file).split(path.sep).join("/");
|
|
262
|
+
const parts = rel.split("/");
|
|
263
|
+
if (rel.length > MAX_PATH || !parts.every((s) => PATH_SEGMENT.test(s))) {
|
|
264
|
+
return refuse(`${shown}: its path is not one a deploy sends`, `each part of a path is letters, digits, ., _ and -, starting with a letter, a digit or _, and the path at most ${MAX_PATH} characters`, "rename it (a generated table's file is named for its table)");
|
|
265
|
+
}
|
|
266
|
+
let real, st;
|
|
267
|
+
try {
|
|
268
|
+
real = fs.realpathSync(file);
|
|
269
|
+
st = fs.statSync(real);
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
return { message: `${shown} cannot be read`, code: "failed", why: e.message, fix: "make it readable, or move it out of the project folder" };
|
|
273
|
+
}
|
|
274
|
+
const inside = path.relative(realFolder, real);
|
|
275
|
+
if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside)) {
|
|
276
|
+
return refuse(`${shown} is a symbolic link to ${real}, outside the project folder`, "a deploy sends the project folder's own files, and never follows a link out of it", `put the file itself in ${shownFolder}, or remove the link`);
|
|
277
|
+
}
|
|
278
|
+
if (!st.isFile())
|
|
279
|
+
return refuse(`${shown} is not a file`, "a project file is a regular file", `move it out of ${shownFolder}`);
|
|
280
|
+
if (st.size > MAX_FILE_BYTES)
|
|
281
|
+
return refuse(`${shown} is ${bytes(st.size)}, over the ${bytes(MAX_FILE_BYTES)} (256 KiB) a deploy sends of one file`, "a project's files name tables, columns and SQL: none is that large", `shorten it, or move it out of ${shownFolder} if Mason does not read it`);
|
|
282
|
+
let buf;
|
|
283
|
+
try {
|
|
284
|
+
buf = fs.readFileSync(real);
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
return { message: `${shown} cannot be read`, code: "failed", why: e.message, fix: "make it readable, or move it out of the project folder" };
|
|
288
|
+
}
|
|
289
|
+
if (buf.length > MAX_FILE_BYTES)
|
|
290
|
+
return refuse(`${shown} is ${bytes(buf.length)}, over the ${bytes(MAX_FILE_BYTES)} (256 KiB) a deploy sends of one file`, "it grew while it was read", "mason deploy again once it is written");
|
|
291
|
+
let content;
|
|
292
|
+
try {
|
|
293
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return refuse(`${shown} is not UTF-8 text`, "a deploy sends each file as UTF-8 text, as Mason reads it", "save it as UTF-8");
|
|
297
|
+
}
|
|
298
|
+
total += buf.length;
|
|
299
|
+
files.push({ path: rel, size: buf.length, sha256: createHash("sha256").update(buf).digest("hex"), content });
|
|
300
|
+
}
|
|
301
|
+
if (total > MAX_TOTAL_BYTES) {
|
|
302
|
+
const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 3).map((f) => `${f.path} ${bytes(f.size)}`).join(", ");
|
|
303
|
+
return refuse(`the project's files are ${bytes(total)} in all, over the ${bytes(MAX_TOTAL_BYTES)} (1 MiB) a deploy sends`, `the largest: ${largest}`, `move the files Mason does not read out of ${shownFolder}`);
|
|
304
|
+
}
|
|
305
|
+
// every file the check read is sent: one read through a linked folder, which the list does not follow, would be
|
|
306
|
+
// checked here and missing on the replica
|
|
307
|
+
const sent = new Set(list.map((f) => path.resolve(f)));
|
|
308
|
+
const missing = read.filter((f) => !sent.has(path.resolve(f)));
|
|
309
|
+
if (missing.length) {
|
|
310
|
+
return refuse(`${shownPath(p.root, missing[0])} is read by Mason, and is not among the files a deploy sends`, "a folder of the project is a symbolic link, which a deploy does not follow", `replace the link with the folder itself, inside ${shownFolder}`);
|
|
311
|
+
}
|
|
312
|
+
return files;
|
|
313
|
+
}
|
|
314
|
+
/** A deploy's phase within its status: a closed set, each said in the CLI's own words (never the service's text). */
|
|
315
|
+
export const PHASES = {
|
|
316
|
+
checking: "Mason on the replica checks the files",
|
|
317
|
+
writing: "the files are written on the replica",
|
|
318
|
+
restarting: "Mason on the replica restarts on them",
|
|
319
|
+
syncing: "the tables the files add have their first sync",
|
|
320
|
+
};
|
|
321
|
+
/** The phases each status may carry; a phase beside any other status is outside the contract. */
|
|
322
|
+
const PHASES_OF = { applying: ["checking", "writing"], starting: ["restarting", "syncing"] };
|
|
323
|
+
const obj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
324
|
+
/**
|
|
325
|
+
* `{ deploy: { … } }` held to its shape, the id this command asked for (or, for the POST's answer, one in `DEPLOY_ID`'s
|
|
326
|
+
* grammar, since it goes into the next request's path); null when it is not that shape. Nothing in it steers the CLI:
|
|
327
|
+
* the status and the phase are each one of a closed set, and a refusal's or a failure's words are one line each, its code
|
|
328
|
+
* the CLI's own (`refused`, `failed`, by the status), the service's kept in `why`, and its next step `again`, the CLI's:
|
|
329
|
+
* the service's `next` is never read.
|
|
330
|
+
*/
|
|
331
|
+
export function deployStateOf(json, id, secrets, again) {
|
|
332
|
+
if (!obj(json) || !obj(json.deploy))
|
|
333
|
+
return null;
|
|
334
|
+
const d = json.deploy;
|
|
335
|
+
// BEFORE ANY GRAMMAR, every string of the answer, each key included, to the exact test (src/cli/hosted.ts
|
|
336
|
+
// `carriesNoSecret`, issue 627: an id its grammar took was the token whole, and printed): the id, the status, the
|
|
337
|
+
// phase, the times, the failure's code, a key outside the shape. A failure's words alone are left to its mask
|
|
338
|
+
// (`failureShape`), as every failure's are.
|
|
339
|
+
const words = obj(d.failure) ? { ...json, deploy: { ...d, failure: { ...d.failure, message: null, why: null, fix: null } } } : json;
|
|
340
|
+
if (!carriesNoSecret(words, secrets))
|
|
341
|
+
return null;
|
|
342
|
+
if (typeof d.id !== "string" || !DEPLOY_ID.test(d.id) || (id !== null && d.id !== id))
|
|
343
|
+
return null;
|
|
344
|
+
if (typeof d.status !== "string" || !STATUSES.includes(d.status))
|
|
345
|
+
return null;
|
|
346
|
+
const status = d.status;
|
|
347
|
+
let phase = null;
|
|
348
|
+
if (d.phase !== undefined && d.phase !== null) {
|
|
349
|
+
if (typeof d.phase !== "string" || !(PHASES_OF[status] ?? []).includes(d.phase))
|
|
350
|
+
return null;
|
|
351
|
+
phase = d.phase;
|
|
352
|
+
}
|
|
353
|
+
if (!isServiceTime(d.createdAt) || (d.finishedAt !== undefined && d.finishedAt !== null && !isServiceTime(d.finishedAt)))
|
|
354
|
+
return null;
|
|
355
|
+
// a refusal or a failure carries the failure shape (its code, message, why, fix and next in one object), and nothing else does
|
|
356
|
+
let failure = null;
|
|
357
|
+
if (status === "refused" || status === "failed") {
|
|
358
|
+
if (!obj(d.failure))
|
|
359
|
+
return null;
|
|
360
|
+
failure = failureShape(d.failure, secrets, status === "refused"
|
|
361
|
+
? { code: "refused", said: (c) => `Mason on the replica said ${c}`, next: again }
|
|
362
|
+
: { code: "failed", said: (c) => `the service said ${c}`, next: again });
|
|
363
|
+
if (failure === null)
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
else if (d.failure !== undefined && d.failure !== null)
|
|
367
|
+
return null;
|
|
368
|
+
return { id: d.id, status, phase, createdAt: d.createdAt, finishedAt: typeof d.finishedAt === "string" ? d.finishedAt : null, failure };
|
|
369
|
+
}
|
|
370
|
+
/** A status for a person, its phase beside it. */
|
|
371
|
+
const STATUS_WORDS = {
|
|
372
|
+
queued: "Queued: waiting for its turn on the replica",
|
|
373
|
+
applying: "Applying: Mason on the replica checks and takes the files",
|
|
374
|
+
starting: "Starting: Mason on the replica starts on the new files",
|
|
375
|
+
running: "Running: the replica runs the new files",
|
|
376
|
+
refused: "Refused by Mason on the replica",
|
|
377
|
+
failed: "The deploy failed",
|
|
378
|
+
};
|
|
379
|
+
const NEXT_AGAIN = (r) => [[`mason deploy --replica ${replicaText(r)}`, "deploy again, after the next edit"]];
|
|
380
|
+
const stateLine = (s) => `${STATUS_WORDS[s.status]}${s.phase ? ` (${PHASES[s.phase]})` : ""}`;
|
|
381
|
+
export async function deploy(argv) {
|
|
382
|
+
const f = parseCommand(DEPLOY, argv);
|
|
383
|
+
if (typeof f === "number")
|
|
384
|
+
return f;
|
|
385
|
+
const json = f.values.json === true;
|
|
386
|
+
const dryRun = f.values["dry-run"] === true;
|
|
387
|
+
if (f.positionals.length)
|
|
388
|
+
return fail({ message: "it takes no argument", code: "usage", why: "the project is the folder's, the replica --replica's", usage: DEPLOY.usage }, { json });
|
|
389
|
+
const timeoutText = typeof f.values.timeout === "string" ? f.values.timeout : DEFAULT_TIMEOUT;
|
|
390
|
+
const timeout = parseDuration(timeoutText);
|
|
391
|
+
if ("problem" in timeout)
|
|
392
|
+
return fail({ message: `--timeout ${timeout.problem}`, code: "usage", usage: DEPLOY.usage }, { json });
|
|
393
|
+
const replica = replicaOf(typeof f.values.replica === "string" ? f.values.replica : undefined, DEPLOY.usage);
|
|
394
|
+
if (replica !== null && isFailure(replica))
|
|
395
|
+
return fail(replica, { json });
|
|
396
|
+
if (replica === null && !dryRun) {
|
|
397
|
+
return fail({ message: "no replica named", code: "usage", why: "a deploy goes to one replica of the service",
|
|
398
|
+
fix: `name it: mason deploy --replica <org>/<name>, or set ${REPLICA_ENV}=<org>/<name>`, usage: DEPLOY.usage }, { json });
|
|
399
|
+
}
|
|
400
|
+
const service = serviceOf();
|
|
401
|
+
if (isFailure(service))
|
|
402
|
+
return fail(service, { json });
|
|
403
|
+
// THE PROJECT, checked as a start checks it, offline; then its files
|
|
404
|
+
const t = theProject(process.cwd());
|
|
405
|
+
if ("problem" in t)
|
|
406
|
+
return fail(t, { json });
|
|
407
|
+
const project = t.project;
|
|
408
|
+
// the project's own folder first: nothing of a folder that is not one is read, let alone sent
|
|
409
|
+
const own = ownFolderRefusal(project);
|
|
410
|
+
if (own !== null)
|
|
411
|
+
return fail(own, { json });
|
|
412
|
+
const checked = checkProject(project);
|
|
413
|
+
if (isFailure(checked))
|
|
414
|
+
return fail(checked, { json });
|
|
415
|
+
const files = deployFiles(project, checked.read);
|
|
416
|
+
if (isFailure(files))
|
|
417
|
+
return fail(files, { json });
|
|
418
|
+
const totalBytes = files.reduce((n, x) => n + x.size, 0);
|
|
419
|
+
const listed = files.map(({ path: p, size, sha256 }) => ({ path: p, size, sha256 }));
|
|
420
|
+
// --json carries each warning as one line (a string with no line break); a person reads its lines on stderr
|
|
421
|
+
const base = { replica: replica === null ? null : replicaText(replica), service: service.base, files: listed, totalBytes,
|
|
422
|
+
warnings: checked.warnings.map((w) => w.split("\n").map((l) => l.trim()).filter(Boolean).join(" ")) };
|
|
423
|
+
if (!json)
|
|
424
|
+
for (const w of checked.warnings)
|
|
425
|
+
say(textEntry(" ! ", w).trimEnd());
|
|
426
|
+
if (dryRun) {
|
|
427
|
+
const next = replica === null
|
|
428
|
+
? [["mason deploy --replica <org>/<name>", "send it to a replica"]]
|
|
429
|
+
: [[`mason deploy --replica ${replicaText(replica)}`, "send it"]];
|
|
430
|
+
if (json) {
|
|
431
|
+
out(jsonText(DEPLOY, { dryRun: true, ...base, deploy: null }, next));
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
const w = Math.max(...files.map((x) => x.path.length)), sw = Math.max(...files.map((x) => bytes(x.size).length));
|
|
435
|
+
out(reportText({
|
|
436
|
+
headline: `Would send ${files.length} file${files.length === 1 ? "" : "s"}, ${bytes(totalBytes)}, ${replica === null ? `to ${service.base} (no replica named)` : `to ${replicaText(replica)} on ${service.base}`}; nothing was sent`,
|
|
437
|
+
details: files.map((x) => [x.path.padEnd(w), `${bytes(x.size).padStart(sw)} sha256 ${x.sha256}`]),
|
|
438
|
+
next,
|
|
439
|
+
}, outStyle()));
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
const r = replica;
|
|
443
|
+
const token = tokenFor(service, { asked: "a deploy is sent", again: "mason deploy" });
|
|
444
|
+
if (isFailure(token))
|
|
445
|
+
return fail(token, { json });
|
|
446
|
+
// from here every byte written is put to the exact test against the token (src/cli/output-gate.ts)
|
|
447
|
+
holdOutput({ command: "deploy", json, secrets: [token] });
|
|
448
|
+
const secrets = [token];
|
|
449
|
+
const where = `/v1/replicas/${r.org}/${r.name}/deploys`;
|
|
450
|
+
const started = Date.now(), deadline = started + timeout.seconds * 1000;
|
|
451
|
+
// THE DEPLOY, SENT: 201 and its id, else the service's failure as it worded it
|
|
452
|
+
const sent = await call(service, "POST", where, { token, json: { files: files.map(({ path: p, sha256, content }) => ({ path: p, sha256, content })), client: { version: version() } } });
|
|
453
|
+
if (isFailure(sent)) {
|
|
454
|
+
return fail({ ...sent, why: `${sent.why ?? ""}${sent.why ? "; " : ""}whether the deploy was made is not known`,
|
|
455
|
+
fix: `${sent.fix ?? "check this machine is online"}, then mason deploy again (a deploy still running is refused as busy)` }, { json });
|
|
456
|
+
}
|
|
457
|
+
if (sent.status !== 201)
|
|
458
|
+
return fail(serviceFailure(sent, secrets) ?? unexpected(service, sent, "a deploy was sent"), { json });
|
|
459
|
+
// what to run after a refused or failed deploy: the CLI's own step, never one the service names
|
|
460
|
+
const again = [[`mason deploy --replica ${replicaText(r)}`, "deploy again, once what it says is fixed"]];
|
|
461
|
+
const first = deployStateOf(sent.json, null, secrets, again);
|
|
462
|
+
if (first === null || first.status !== "queued")
|
|
463
|
+
return fail(unexpected(service, sent, "a deploy was sent"), { json });
|
|
464
|
+
const id = first.id;
|
|
465
|
+
const s = errStyle();
|
|
466
|
+
// a spinner turns only on a terminal wide enough for a label beside its frame; else each line is said once, as on a pipe
|
|
467
|
+
const terminal = !json && process.stderr.isTTY === true && clipped("x") !== null;
|
|
468
|
+
const steps = stepRenderer({ line: say, live: terminal ? process.stderr : null, style: s });
|
|
469
|
+
if (!json)
|
|
470
|
+
say(`Sent ${files.length} file${files.length === 1 ? "" : "s"} (${bytes(totalBytes)}) to ${replicaText(r)} on ${service.base}: deploy ${id}`);
|
|
471
|
+
// THE WAIT: a line when the state changes (and every 30 s while it does not), a spinner beside it on a terminal
|
|
472
|
+
let state = first, said = "", saidAt = 0, unsure = null, unsureSaid = null;
|
|
473
|
+
const show = (line) => {
|
|
474
|
+
if (json)
|
|
475
|
+
return;
|
|
476
|
+
// on a terminal: the step that ends is said whole (a line the CLI breaks), the one that turns cut to one line
|
|
477
|
+
if (terminal) {
|
|
478
|
+
if (line !== said) {
|
|
479
|
+
if (said !== "")
|
|
480
|
+
steps.done(said);
|
|
481
|
+
steps.begin(clipped(line));
|
|
482
|
+
said = line;
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (sayAgain(line, said, saidAt, Date.now())) {
|
|
487
|
+
say(` ${line}`);
|
|
488
|
+
said = line;
|
|
489
|
+
saidAt = Date.now();
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
show(stateLine(state));
|
|
493
|
+
let waitMs = sent.retryAfter !== null ? sent.retryAfter * 1000 : POLL_MS;
|
|
494
|
+
const ended = (error) => {
|
|
495
|
+
const value = { dryRun: false, ...base, deploy: { id, status: state.status, phase: state.phase, createdAt: state.createdAt, finishedAt: state.finishedAt } };
|
|
496
|
+
if (json) {
|
|
497
|
+
if (error === null) {
|
|
498
|
+
out(jsonText(DEPLOY, value, NEXT_AGAIN(r)));
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
out(jsonText(DEPLOY, { ...value, error: errorJson(error) }, error.next ?? []));
|
|
502
|
+
return exitOf(error);
|
|
503
|
+
}
|
|
504
|
+
// the step it ended on: the outcome's own line when it has one, else (a timeout, an answer outside the contract) the last
|
|
505
|
+
const line = FINAL.has(state.status) ? stateLine(state) : null;
|
|
506
|
+
// (on a terminal the step turning is ticked and the outcome is a line of its own; a refusal replaces the step it ended)
|
|
507
|
+
if (terminal) {
|
|
508
|
+
if (error === null) {
|
|
509
|
+
if (line !== null && line !== said) {
|
|
510
|
+
steps.done(said);
|
|
511
|
+
steps.begin(clipped(line));
|
|
512
|
+
said = line;
|
|
513
|
+
}
|
|
514
|
+
steps.done(said);
|
|
515
|
+
}
|
|
516
|
+
else
|
|
517
|
+
steps.fail(line ?? said);
|
|
518
|
+
}
|
|
519
|
+
else if (line !== null && line !== said)
|
|
520
|
+
say(` ${line}`);
|
|
521
|
+
if (error !== null)
|
|
522
|
+
return fail(error, { json: false });
|
|
523
|
+
out(reportText({ headline: `Deployed to ${replicaText(r)}: the replica runs it`,
|
|
524
|
+
details: [["deploy", id], ["service", service.base], ["files", `${files.length} (${bytes(totalBytes)})`], ["took", `${Math.max(1, Math.round((Date.now() - started) / 1000))} s`]],
|
|
525
|
+
next: NEXT_AGAIN(r) }, outStyle()));
|
|
526
|
+
return 0;
|
|
527
|
+
};
|
|
528
|
+
for (;;) {
|
|
529
|
+
if (FINAL.has(state.status)) {
|
|
530
|
+
if (state.status === "running")
|
|
531
|
+
return ended(null);
|
|
532
|
+
// Mason on the replica refused it, or it failed: its words as they came, never re-worded; its code the CLI's (exit 1)
|
|
533
|
+
return ended(state.failure);
|
|
534
|
+
}
|
|
535
|
+
const left = deadline - Date.now();
|
|
536
|
+
if (left <= 0) {
|
|
537
|
+
return ended({ message: `deploy ${id} to ${replicaText(r)} did not finish within ${timeout.every}`, code: "timeout",
|
|
538
|
+
why: unsure !== null ? `the service could not say how it is going: ${unsure}` : `it was last: ${stateLine(state)}`,
|
|
539
|
+
fix: `nothing was stopped, and it may still finish: the service's web app shows deploy ${id} of ${replicaText(r)}, as GET ${where}/${id} does (the hosted API); a longer wait is --timeout 30m`,
|
|
540
|
+
next: [] });
|
|
541
|
+
}
|
|
542
|
+
await sleep(Math.min(waitMs, left));
|
|
543
|
+
const a = await call(service, "GET", `${where}/${id}`, { token });
|
|
544
|
+
waitMs = POLL_MS;
|
|
545
|
+
// an answer that cannot say how it goes is a wait, never an outcome: the timeout bounds it
|
|
546
|
+
if (isFailure(a) || a.status === 429 || a.status >= 500) {
|
|
547
|
+
unsure = isFailure(a) ? serviceText(`${a.message}${a.why ? ` (${a.why})` : ""}`, 300, secrets) : `${a.what} answered ${a.status}`;
|
|
548
|
+
if (!isFailure(a) && a.retryAfter !== null)
|
|
549
|
+
waitMs = a.retryAfter * 1000;
|
|
550
|
+
// on a terminal a note above the turning step, which goes on (the step is not over); elsewhere the step's line again
|
|
551
|
+
if (terminal) {
|
|
552
|
+
if (unsure !== unsureSaid)
|
|
553
|
+
steps.print(s.dim(` the service could not say more just now (${unsure}): asking again`));
|
|
554
|
+
unsureSaid = unsure;
|
|
555
|
+
}
|
|
556
|
+
else
|
|
557
|
+
show(`${stateLine(state)}; the service could not say more just now (${unsure}): asking again`);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (a.retryAfter !== null)
|
|
561
|
+
waitMs = a.retryAfter * 1000;
|
|
562
|
+
// a refusal of the question (the token revoked meanwhile, the replica gone) is the service's, as it worded it
|
|
563
|
+
if (a.status !== 200)
|
|
564
|
+
return ended(serviceFailure(a, secrets) ?? unexpected(service, a, "the deploy's state was asked for"));
|
|
565
|
+
const now = deployStateOf(a.json, id, secrets, again);
|
|
566
|
+
if (now === null)
|
|
567
|
+
return ended(unexpected(service, a, "the deploy's state was asked for"));
|
|
568
|
+
state = now;
|
|
569
|
+
unsure = null;
|
|
570
|
+
unsureSaid = null;
|
|
571
|
+
if (!FINAL.has(state.status))
|
|
572
|
+
show(stateLine(state));
|
|
573
|
+
}
|
|
574
|
+
}
|