@magland/mochi 0.3.1
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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.IMAGE_REPO = void 0;
|
|
37
|
+
exports.fly = fly;
|
|
38
|
+
exports.flyStream = flyStream;
|
|
39
|
+
exports.die = die;
|
|
40
|
+
exports.parseVmSize = parseVmSize;
|
|
41
|
+
exports.normalizeMemory = normalizeMemory;
|
|
42
|
+
exports.ownVersion = ownVersion;
|
|
43
|
+
exports.sourceRoot = sourceRoot;
|
|
44
|
+
exports.requireFly = requireFly;
|
|
45
|
+
exports.appExists = appExists;
|
|
46
|
+
exports.namedVolume = namedVolume;
|
|
47
|
+
exports.machines = machines;
|
|
48
|
+
exports.secretNames = secretNames;
|
|
49
|
+
exports.appUrl = appUrl;
|
|
50
|
+
exports.deployFlyCmd = deployFlyCmd;
|
|
51
|
+
exports.deployShowCmd = deployShowCmd;
|
|
52
|
+
exports.promptLine = promptLine;
|
|
53
|
+
exports.deployDestroyCmd = deployDestroyCmd;
|
|
54
|
+
const child_process_1 = require("child_process");
|
|
55
|
+
const fs = __importStar(require("fs"));
|
|
56
|
+
const os = __importStar(require("os"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
const credentials_1 = require("./credentials");
|
|
59
|
+
const backup_cmd_1 = require("./cli/backup-cmd");
|
|
60
|
+
const vault_1 = require("./vault");
|
|
61
|
+
// `mochi deploy fly`: put a vault on Fly.io from one command, and deploy
|
|
62
|
+
// updates to it with the same one. This is a thin driver of the fly command
|
|
63
|
+
// rather than a client of Fly's API, so it inherits `fly auth login` and the
|
|
64
|
+
// user's existing organization; the only prerequisite is that flyctl is
|
|
65
|
+
// installed and logged in.
|
|
66
|
+
//
|
|
67
|
+
// Nothing about a deployment is remembered on this machine. Fly already knows
|
|
68
|
+
// the region, the volume size, and the machine's shape, so this reads them back
|
|
69
|
+
// from the live app and applies only what the flags change. A generated
|
|
70
|
+
// fly.toml goes to a temporary directory for the length of the deploy, which is
|
|
71
|
+
// why there is no fly.toml in this repository to keep in sync or to explain.
|
|
72
|
+
exports.IMAGE_REPO = 'ghcr.io/magland/mochi';
|
|
73
|
+
const VOLUME_NAME = 'vault';
|
|
74
|
+
const OWNER_TOKEN_SECRET = 'MOCHI_OWNER_TOKEN';
|
|
75
|
+
const INTERNAL_PORT = 3000;
|
|
76
|
+
const DEFAULTS = { region: 'ewr', volumeGb: 10, cpuKind: 'shared', cpus: 1, memory: '512mb' };
|
|
77
|
+
const FLY_NOT_FOUND = 'Neither fly nor flyctl is on PATH. Install it from https://fly.io/docs/flyctl/install/';
|
|
78
|
+
/**
|
|
79
|
+
* The name flyctl goes by here.
|
|
80
|
+
*
|
|
81
|
+
* A normal install provides both `fly` and `flyctl`, but not every install is
|
|
82
|
+
* normal: flyctl's own GitHub Action unpacks the release tarball, which carries
|
|
83
|
+
* `flyctl` alone. So preferring `fly` and falling back keeps a deploy from a CI
|
|
84
|
+
* runner working, which is the one place nobody is watching to fix the PATH.
|
|
85
|
+
* Either name is the same binary, so which one was found never matters again.
|
|
86
|
+
*/
|
|
87
|
+
function flyBin() {
|
|
88
|
+
if (cachedFlyBin === null)
|
|
89
|
+
cachedFlyBin = onPath('fly') ?? onPath('flyctl') ?? 'fly';
|
|
90
|
+
return cachedFlyBin;
|
|
91
|
+
}
|
|
92
|
+
let cachedFlyBin = null;
|
|
93
|
+
function onPath(name) {
|
|
94
|
+
const names = process.platform === 'win32' ? [`${name}.exe`, `${name}.cmd`, name] : [name];
|
|
95
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
|
|
96
|
+
if (!dir)
|
|
97
|
+
continue;
|
|
98
|
+
for (const n of names) {
|
|
99
|
+
try {
|
|
100
|
+
fs.accessSync(path.join(dir, n), fs.constants.X_OK);
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* not this one */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
// Quiet commands, whose output this code reads rather than the user. A non-zero
|
|
111
|
+
// exit is often the answer and not a failure (`fly status` on an app that does
|
|
112
|
+
// not exist), so the code is reported instead of thrown.
|
|
113
|
+
//
|
|
114
|
+
// The optional stdin is how a secret is handed over. An argument would be
|
|
115
|
+
// readable in `ps` by every other user on this machine for as long as the child
|
|
116
|
+
// runs, and a token is worth more than that.
|
|
117
|
+
function fly(args, stdin) {
|
|
118
|
+
return new Promise((resolve, reject) => {
|
|
119
|
+
const child = (0, child_process_1.execFile)(flyBin(), args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
120
|
+
const code = err?.code;
|
|
121
|
+
if (code === 'ENOENT') {
|
|
122
|
+
reject(new Error(FLY_NOT_FOUND));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
resolve({ code: typeof code === 'number' ? code : err ? 1 : 0, stdout: String(stdout), stderr: String(stderr) });
|
|
126
|
+
});
|
|
127
|
+
if (stdin !== undefined) {
|
|
128
|
+
// A child that exits before reading breaks the pipe; that failure is
|
|
129
|
+
// already reported by its exit code above, so it is not raised twice.
|
|
130
|
+
child.stdin?.on('error', () => undefined);
|
|
131
|
+
child.stdin?.end(stdin);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async function flyJson(args) {
|
|
136
|
+
const r = await fly([...args, '--json']);
|
|
137
|
+
if (r.code !== 0)
|
|
138
|
+
return null;
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(r.stdout);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// The commands whose progress the user should watch: deploying, creating a
|
|
147
|
+
// volume, provisioning a bucket. Their output is fly's to format, and hiding a
|
|
148
|
+
// three-minute deploy behind a spinner of our own would only lose detail.
|
|
149
|
+
function flyStream(args, cwd) {
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const child = (0, child_process_1.spawn)(flyBin(), args, { stdio: 'inherit', cwd });
|
|
152
|
+
child.on('error', (e) => {
|
|
153
|
+
reject(e.code === 'ENOENT' ? new Error(FLY_NOT_FOUND) : e);
|
|
154
|
+
});
|
|
155
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function die(message) {
|
|
159
|
+
console.error(message);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
function parseDeployArgs(args, usage) {
|
|
163
|
+
const out = {
|
|
164
|
+
app: null,
|
|
165
|
+
region: null,
|
|
166
|
+
volumeGb: null,
|
|
167
|
+
vmSize: null,
|
|
168
|
+
memory: null,
|
|
169
|
+
image: null,
|
|
170
|
+
fromSource: false,
|
|
171
|
+
localBuild: false,
|
|
172
|
+
org: null,
|
|
173
|
+
lfsBucket: false,
|
|
174
|
+
yes: false,
|
|
175
|
+
};
|
|
176
|
+
for (let i = 0; i < args.length; i++) {
|
|
177
|
+
const a = args[i];
|
|
178
|
+
if (a === '-h' || a === '--help')
|
|
179
|
+
usage();
|
|
180
|
+
else if (a === '--region')
|
|
181
|
+
out.region = args[++i];
|
|
182
|
+
else if (a === '--volume') {
|
|
183
|
+
const gb = parseInt(args[++i], 10);
|
|
184
|
+
if (!Number.isInteger(gb) || gb < 1)
|
|
185
|
+
die('--volume takes a size in whole gigabytes, e.g. --volume 10');
|
|
186
|
+
out.volumeGb = gb;
|
|
187
|
+
}
|
|
188
|
+
else if (a === '--vm-size')
|
|
189
|
+
out.vmSize = args[++i];
|
|
190
|
+
else if (a === '--vm-memory')
|
|
191
|
+
out.memory = args[++i];
|
|
192
|
+
else if (a === '--image')
|
|
193
|
+
out.image = args[++i];
|
|
194
|
+
else if (a === '--from-source')
|
|
195
|
+
out.fromSource = true;
|
|
196
|
+
else if (a === '--local-build')
|
|
197
|
+
out.localBuild = true;
|
|
198
|
+
else if (a === '--org')
|
|
199
|
+
out.org = args[++i];
|
|
200
|
+
else if (a === '--lfs-bucket')
|
|
201
|
+
out.lfsBucket = true;
|
|
202
|
+
else if (a === '-y' || a === '--yes')
|
|
203
|
+
out.yes = true;
|
|
204
|
+
else if (a.startsWith('-'))
|
|
205
|
+
die(`Unknown option: ${a}`);
|
|
206
|
+
else if (!out.app)
|
|
207
|
+
out.app = a;
|
|
208
|
+
else
|
|
209
|
+
die(`Unexpected argument: ${a}`);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
// `deploy fly show` and `deploy fly destroy` take an app name and nothing else,
|
|
214
|
+
// apart from --yes on destroy. The flags that shape a deployment are parsed by the
|
|
215
|
+
// same function they share, so accepting one here and then ignoring it would
|
|
216
|
+
// look like it had been applied. Only the first one found is named, since fixing
|
|
217
|
+
// it means dropping it and running the command again either way.
|
|
218
|
+
function rejectFlyFlags(a, usage, allowYes) {
|
|
219
|
+
const used = [
|
|
220
|
+
['--region', a.region !== null],
|
|
221
|
+
['--volume', a.volumeGb !== null],
|
|
222
|
+
['--vm-size', a.vmSize !== null],
|
|
223
|
+
['--vm-memory', a.memory !== null],
|
|
224
|
+
['--image', a.image !== null],
|
|
225
|
+
['--from-source', a.fromSource],
|
|
226
|
+
['--local-build', a.localBuild],
|
|
227
|
+
['--org', a.org !== null],
|
|
228
|
+
['--lfs-bucket', a.lfsBucket],
|
|
229
|
+
['--yes', a.yes && !allowYes],
|
|
230
|
+
];
|
|
231
|
+
const flag = used.find(([, given]) => given);
|
|
232
|
+
if (!flag)
|
|
233
|
+
return;
|
|
234
|
+
if (flag[0] === '--yes')
|
|
235
|
+
die(`--yes confirms a destroy, and there is nothing here to confirm.\nUsage: ${usage}`);
|
|
236
|
+
die(`${flag[0]} says how to deploy, and this command deploys nothing.\nUsage: ${usage}`);
|
|
237
|
+
}
|
|
238
|
+
// Fly's own machine sizes name a CPU kind and a count: shared-cpu-4x,
|
|
239
|
+
// performance-2x. The generated config sets cpu_kind and cpus separately, since
|
|
240
|
+
// spelling those two out avoids having to know which shorthands Fly accepts
|
|
241
|
+
// today, so the shorthand is taken apart here.
|
|
242
|
+
function parseVmSize(size) {
|
|
243
|
+
const m = /^(shared|performance)(?:-cpu)?-(\d+)x$/.exec(size.trim());
|
|
244
|
+
if (!m) {
|
|
245
|
+
die(`Not a Fly machine size: ${size}\n` +
|
|
246
|
+
'Expected something like shared-cpu-1x, shared-cpu-4x, or performance-2x.\n' +
|
|
247
|
+
'See: fly platform vm-sizes');
|
|
248
|
+
}
|
|
249
|
+
return { cpuKind: m[1], cpus: parseInt(m[2], 10) };
|
|
250
|
+
}
|
|
251
|
+
function normalizeMemory(memory) {
|
|
252
|
+
const m = /^(\d+)\s*(mb|gb)?$/i.exec(memory.trim());
|
|
253
|
+
if (!m)
|
|
254
|
+
die(`Not a memory size: ${memory}\nExpected something like 512mb, 1gb, or 2048.`);
|
|
255
|
+
const n = parseInt(m[1], 10);
|
|
256
|
+
const unit = (m[2] ?? 'mb').toLowerCase();
|
|
257
|
+
const mb = unit === 'gb' ? n * 1024 : n;
|
|
258
|
+
if (mb < 256)
|
|
259
|
+
die(`Memory of ${memory} is below Fly's 256mb minimum.`);
|
|
260
|
+
return `${mb}mb`;
|
|
261
|
+
}
|
|
262
|
+
/** The version of this CLI, which is the image tag deployed unless --image says otherwise. */
|
|
263
|
+
function ownVersion() {
|
|
264
|
+
try {
|
|
265
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
266
|
+
if (typeof pkg.version === 'string' && pkg.version)
|
|
267
|
+
return pkg.version;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
/* fall through to the message below */
|
|
271
|
+
}
|
|
272
|
+
die('Could not read this package\'s version, so there is no image tag to deploy. Pass --image <ref>.');
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The checkout this CLI is running out of, for --from-source.
|
|
276
|
+
*
|
|
277
|
+
* dist/deploy-cli.js and src/deploy-cli.ts are both one directory below the
|
|
278
|
+
* package root, so this is the same answer either way: a built checkout, or one
|
|
279
|
+
* being run through tsx. The published npm package ships only dist, so a globally
|
|
280
|
+
* installed mochi has no Dockerfile and no src to build from, and that is
|
|
281
|
+
* worth saying rather than letting docker fail on a missing file.
|
|
282
|
+
*/
|
|
283
|
+
function sourceRoot() {
|
|
284
|
+
const root = path.resolve(__dirname, '..');
|
|
285
|
+
if (!fs.existsSync(path.join(root, 'Dockerfile')) || !fs.existsSync(path.join(root, 'src'))) {
|
|
286
|
+
die('--from-source builds the image from a mochi checkout, and this is not one:\n' +
|
|
287
|
+
` ${root}\n\n` +
|
|
288
|
+
'The published package contains only the compiled output, so there is nothing to\n' +
|
|
289
|
+
'build. Clone the repository and run the deploy from there:\n\n' +
|
|
290
|
+
' git clone https://github.com/magland/mochi && cd mochi && npm install\n' +
|
|
291
|
+
' npm run build && node dist/index.js deploy fly <app> --from-source\n');
|
|
292
|
+
}
|
|
293
|
+
return root;
|
|
294
|
+
}
|
|
295
|
+
async function requireFly() {
|
|
296
|
+
const who = await fly(['auth', 'whoami']);
|
|
297
|
+
if (who.code !== 0) {
|
|
298
|
+
die('Not logged in to Fly. Run:\n\n fly auth login\n');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function appExists(app) {
|
|
302
|
+
const r = await fly(['status', '-a', app]);
|
|
303
|
+
return r.code === 0;
|
|
304
|
+
}
|
|
305
|
+
async function namedVolume(app, name) {
|
|
306
|
+
const vols = (await flyJson(['volumes', 'list', '-a', app])) ?? [];
|
|
307
|
+
return vols.find((v) => v.name === name) ?? null;
|
|
308
|
+
}
|
|
309
|
+
async function vaultVolume(app) {
|
|
310
|
+
return namedVolume(app, VOLUME_NAME);
|
|
311
|
+
}
|
|
312
|
+
async function machines(app) {
|
|
313
|
+
return (await flyJson(['machines', 'list', '-a', app])) ?? [];
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The hostnames Fly serves this app under besides <app>.fly.dev, from its
|
|
317
|
+
* certificates. A vault with a domain of its own is reached by that name, so
|
|
318
|
+
* anything asking "is this app the one at <url>" has to know both.
|
|
319
|
+
*/
|
|
320
|
+
async function certHostnames(app) {
|
|
321
|
+
const certs = (await flyJson(['certs', 'list', '-a', app])) ?? [];
|
|
322
|
+
// A wildcard covers each repository's site rather than the vault itself, so it
|
|
323
|
+
// is not a name the vault answers on.
|
|
324
|
+
return certs.map((c) => c.hostname ?? '').filter((h) => h !== '' && !h.startsWith('*.'));
|
|
325
|
+
}
|
|
326
|
+
async function secretNames(app) {
|
|
327
|
+
const secrets = (await flyJson(['secrets', 'list', '-a', app])) ?? [];
|
|
328
|
+
return secrets.map((s) => s.Name ?? s.name ?? '').filter(Boolean);
|
|
329
|
+
}
|
|
330
|
+
/** What Fly currently has, so that a flag-less redeploy changes nothing and one flag changes one thing. */
|
|
331
|
+
async function liveSettings(app) {
|
|
332
|
+
const out = {};
|
|
333
|
+
const vol = await vaultVolume(app);
|
|
334
|
+
if (vol) {
|
|
335
|
+
out.region = vol.region;
|
|
336
|
+
out.volumeGb = vol.size_gb;
|
|
337
|
+
}
|
|
338
|
+
const guest = (await machines(app)).find((m) => m.config?.guest)?.config?.guest;
|
|
339
|
+
if (guest) {
|
|
340
|
+
if (guest.cpu_kind)
|
|
341
|
+
out.cpuKind = guest.cpu_kind;
|
|
342
|
+
if (guest.cpus)
|
|
343
|
+
out.cpus = guest.cpus;
|
|
344
|
+
if (guest.memory_mb)
|
|
345
|
+
out.memory = `${guest.memory_mb}mb`;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
function resolveSettings(a, live) {
|
|
350
|
+
const base = { ...DEFAULTS, ...live };
|
|
351
|
+
const vm = a.vmSize ? parseVmSize(a.vmSize) : null;
|
|
352
|
+
return {
|
|
353
|
+
region: a.region ?? base.region,
|
|
354
|
+
volumeGb: a.volumeGb ?? base.volumeGb,
|
|
355
|
+
cpuKind: vm?.cpuKind ?? base.cpuKind,
|
|
356
|
+
cpus: vm?.cpus ?? base.cpus,
|
|
357
|
+
memory: a.memory ? normalizeMemory(a.memory) : base.memory,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// A vault is a directory on one volume, so this app runs as exactly one
|
|
361
|
+
// machine: --ha=false at deploy time, and min_machines_running = 0 with
|
|
362
|
+
// auto-start here. A second machine would mean a second volume and a second
|
|
363
|
+
// vault, diverging silently from the first. For the same reason, a busier vault
|
|
364
|
+
// wants a bigger machine rather than more of them.
|
|
365
|
+
function flyToml(app, s) {
|
|
366
|
+
return `# Generated by mochi deploy for '${app}'. Written to a temporary
|
|
367
|
+
# directory for the length of one deploy; edit the deploy command, not this.
|
|
368
|
+
app = "${app}"
|
|
369
|
+
primary_region = "${s.region}"
|
|
370
|
+
|
|
371
|
+
[mounts]
|
|
372
|
+
source = "${VOLUME_NAME}"
|
|
373
|
+
destination = "/vault"
|
|
374
|
+
|
|
375
|
+
# Fly always terminates TLS in front, so the forwarded headers are the only place
|
|
376
|
+
# the real scheme and address appear. The server records this in the vault's
|
|
377
|
+
# config.json on the next start, where it can be changed by hand afterwards.
|
|
378
|
+
[env]
|
|
379
|
+
MOCHI_TRUST_PROXY = "1"
|
|
380
|
+
|
|
381
|
+
[http_service]
|
|
382
|
+
internal_port = ${INTERNAL_PORT}
|
|
383
|
+
force_https = true
|
|
384
|
+
auto_stop_machines = "stop"
|
|
385
|
+
auto_start_machines = true
|
|
386
|
+
min_machines_running = 0
|
|
387
|
+
[http_service.concurrency]
|
|
388
|
+
type = "requests"
|
|
389
|
+
hard_limit = 250
|
|
390
|
+
soft_limit = 200
|
|
391
|
+
|
|
392
|
+
[[vm]]
|
|
393
|
+
cpu_kind = "${s.cpuKind}"
|
|
394
|
+
cpus = ${s.cpus}
|
|
395
|
+
memory = "${s.memory}"
|
|
396
|
+
`;
|
|
397
|
+
}
|
|
398
|
+
function writeTempConfig(app, s) {
|
|
399
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mochi-deploy-'));
|
|
400
|
+
const file = path.join(dir, 'fly.toml');
|
|
401
|
+
fs.writeFileSync(file, flyToml(app, s));
|
|
402
|
+
return file;
|
|
403
|
+
}
|
|
404
|
+
function appUrl(app) {
|
|
405
|
+
return `https://${app}.fly.dev`;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Wait for the deployed vault to answer as the token's owner. This is both the
|
|
409
|
+
* health check and the proof that the injected token was adopted: a machine
|
|
410
|
+
* that boots and then fails to read its volume answers nothing, and a vault
|
|
411
|
+
* that was already initialized answers 401.
|
|
412
|
+
*/
|
|
413
|
+
async function waitForVault(url, token, seconds = 120) {
|
|
414
|
+
const deadline = Date.now() + seconds * 1000;
|
|
415
|
+
let last = 'no answer yet';
|
|
416
|
+
while (Date.now() < deadline) {
|
|
417
|
+
try {
|
|
418
|
+
const resp = await fetch(`${url}/api/whoami`, { headers: { authorization: `Bearer ${token}` } });
|
|
419
|
+
if (resp.ok) {
|
|
420
|
+
const data = (await resp.json());
|
|
421
|
+
if (typeof data.username === 'string' && data.username)
|
|
422
|
+
return { ok: true, username: data.username };
|
|
423
|
+
last = 'the vault answered without saying who the token belongs to';
|
|
424
|
+
}
|
|
425
|
+
else if (resp.status === 401 || resp.status === 403) {
|
|
426
|
+
// Conclusive rather than worth retrying: the server is up and has
|
|
427
|
+
// rejected this token, which means the vault was initialized before.
|
|
428
|
+
return { ok: false, reason: `the vault did not accept the new owner token (HTTP ${resp.status})` };
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
last = `HTTP ${resp.status} from ${url}`;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (e) {
|
|
435
|
+
last = e instanceof Error ? e.message : String(e);
|
|
436
|
+
}
|
|
437
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
438
|
+
}
|
|
439
|
+
return { ok: false, reason: `timed out after ${seconds}s: ${last}` };
|
|
440
|
+
}
|
|
441
|
+
async function deployFlyCmd(args, usage) {
|
|
442
|
+
const a = parseDeployArgs(args, usage);
|
|
443
|
+
if (!a.app) {
|
|
444
|
+
die('Which app? Fly app names are globally unique, and the name becomes the URL:\n\n' +
|
|
445
|
+
' mochi deploy fly my-vault-name -> https://my-vault-name.fly.dev\n');
|
|
446
|
+
}
|
|
447
|
+
const app = a.app;
|
|
448
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(app)) {
|
|
449
|
+
die(`Not a valid Fly app name: ${app}\nUse lowercase letters, digits, and dashes.`);
|
|
450
|
+
}
|
|
451
|
+
// What gets deployed: a published image to pull, or this checkout to build.
|
|
452
|
+
// Settled from the flags before anything reaches the network, so a
|
|
453
|
+
// contradiction between them costs no round trip and creates no app.
|
|
454
|
+
if (a.fromSource && a.image !== null) {
|
|
455
|
+
die('--image names an image to pull and --from-source builds one instead. Pass one or the other.');
|
|
456
|
+
}
|
|
457
|
+
if (a.localBuild && !a.fromSource) {
|
|
458
|
+
die('--local-build says how to build, and without --from-source there is nothing to build.');
|
|
459
|
+
}
|
|
460
|
+
const buildRoot = a.fromSource ? sourceRoot() : null;
|
|
461
|
+
const image = buildRoot === null ? a.image ?? `${exports.IMAGE_REPO}:${ownVersion()}` : null;
|
|
462
|
+
await requireFly();
|
|
463
|
+
const existed = await appExists(app);
|
|
464
|
+
const live = existed ? await liveSettings(app) : {};
|
|
465
|
+
const settings = resolveSettings(a, live);
|
|
466
|
+
// A volume cannot move, so a region flag that disagrees with the volume that
|
|
467
|
+
// exists is a request this cannot carry out. Saying so beats deploying a
|
|
468
|
+
// machine in one region that can never attach the disk in another.
|
|
469
|
+
if (existed && live.region && a.region && a.region !== live.region) {
|
|
470
|
+
die(`This vault's volume is in ${live.region}, and a volume cannot be moved to ${a.region}.\n` +
|
|
471
|
+
'Deploying to another region means a new vault and copying the data across.');
|
|
472
|
+
}
|
|
473
|
+
if (existed) {
|
|
474
|
+
console.log(`==> Updating '${app}' (${appUrl(app)})`);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
console.log(`==> Creating '${app}' in ${settings.region}`);
|
|
478
|
+
const created = await flyStream(['apps', 'create', app, ...(a.org ? ['--org', a.org] : [])]);
|
|
479
|
+
if (created !== 0) {
|
|
480
|
+
die(`\nCould not create the Fly app '${app}'.\n` +
|
|
481
|
+
'App names are globally unique, so a name in use by anyone stops this. Try another.');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
const vol = await vaultVolume(app);
|
|
485
|
+
if (!vol) {
|
|
486
|
+
console.log(`==> Creating a ${settings.volumeGb}GB volume '${VOLUME_NAME}' in ${settings.region}`);
|
|
487
|
+
const code = await flyStream([
|
|
488
|
+
'volumes',
|
|
489
|
+
'create',
|
|
490
|
+
VOLUME_NAME,
|
|
491
|
+
'-a',
|
|
492
|
+
app,
|
|
493
|
+
'--region',
|
|
494
|
+
settings.region,
|
|
495
|
+
'--size',
|
|
496
|
+
String(settings.volumeGb),
|
|
497
|
+
'--yes',
|
|
498
|
+
]);
|
|
499
|
+
if (code !== 0)
|
|
500
|
+
die('\nCould not create the volume, so there is nowhere to keep the vault.');
|
|
501
|
+
}
|
|
502
|
+
else if (settings.volumeGb > vol.size_gb) {
|
|
503
|
+
console.log(`==> Extending volume '${VOLUME_NAME}' from ${vol.size_gb}GB to ${settings.volumeGb}GB`);
|
|
504
|
+
const code = await flyStream(['volumes', 'extend', vol.id, '-a', app, '--size', String(settings.volumeGb)]);
|
|
505
|
+
if (code !== 0)
|
|
506
|
+
die('\nCould not extend the volume.');
|
|
507
|
+
}
|
|
508
|
+
else if (a.volumeGb !== null && a.volumeGb < vol.size_gb) {
|
|
509
|
+
// Fly volumes only grow. Ignoring this quietly would leave the operator
|
|
510
|
+
// believing the vault had been shrunk, and paying for the old size.
|
|
511
|
+
die(`The volume is ${vol.size_gb}GB and Fly volumes cannot be shrunk, so --volume ${a.volumeGb} cannot be applied.\n` +
|
|
512
|
+
'Leave the flag off to keep the size it has.');
|
|
513
|
+
}
|
|
514
|
+
const secrets = existed ? await secretNames(app) : [];
|
|
515
|
+
if (a.lfsBucket && !secrets.includes('BUCKET_NAME')) {
|
|
516
|
+
// Tigris' own secret names are the ones the LFS store already reads, so
|
|
517
|
+
// provisioning a bucket is the whole configuration step. It is the only
|
|
518
|
+
// provider a deploy can set up unattended, which is why this flag uses it
|
|
519
|
+
// and not the one the documentation recommends.
|
|
520
|
+
console.log('==> Provisioning a Tigris bucket for Git LFS objects');
|
|
521
|
+
// Said here rather than only in the documentation, because this is the
|
|
522
|
+
// moment the choice is being made. LFS bytes leave the bucket rather than
|
|
523
|
+
// the app, so they are outside the vault's daily egress cap and are billed
|
|
524
|
+
// on the bucket's own terms; R2 charges nothing for them.
|
|
525
|
+
console.log(' Tigris is what a deploy can provision unattended, not what costs least to');
|
|
526
|
+
console.log(' serve from: LFS downloads leave the bucket, so they are billed by the bucket');
|
|
527
|
+
console.log(' and are not covered by the vault\'s daily egress limit. Cloudflare R2 charges');
|
|
528
|
+
console.log(' no egress fees; see docs/lfs.md#storage-providers to point this vault there.');
|
|
529
|
+
const code = await flyStream(['storage', 'create', '-a', app, '-n', `${app}-lfs`, '--yes']);
|
|
530
|
+
if (code !== 0) {
|
|
531
|
+
die('\nCould not provision the bucket. The app and volume are already there, so\n' +
|
|
532
|
+
'this command is safe to run again once the bucket problem is sorted out.');
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (a.lfsBucket) {
|
|
536
|
+
console.log('==> A bucket is already configured (BUCKET_NAME is set), leaving it alone');
|
|
537
|
+
}
|
|
538
|
+
// Set once the operator has been shown the token. It is what the exit hook
|
|
539
|
+
// below checks before printing it as a last resort.
|
|
540
|
+
let tokenDelivered = false;
|
|
541
|
+
// An owner token is minted only for a vault that has none. The question is
|
|
542
|
+
// whether the vault has been initialized rather than whether the app exists,
|
|
543
|
+
// because a first deploy that fails leaves the app behind: on the next
|
|
544
|
+
// attempt the app is not new but the vault still is, and that retry should
|
|
545
|
+
// end with a usable owner token like any other first deploy. Whether a
|
|
546
|
+
// machine has ever run is as close to that question as Fly can be asked.
|
|
547
|
+
//
|
|
548
|
+
// The secret already being set is deliberately not part of it. A Fly secret
|
|
549
|
+
// can be written and not read, so a token from an abandoned attempt is a
|
|
550
|
+
// token nobody has; overwriting it with one this run knows is the only way
|
|
551
|
+
// the retry can end with a token the operator holds.
|
|
552
|
+
//
|
|
553
|
+
// Minting here rather than on the server is what makes that possible: the
|
|
554
|
+
// server adopts this token when it initializes the vault, stores only its
|
|
555
|
+
// hash, and never prints it.
|
|
556
|
+
const ownerToken = existed && (await machines(app)).length > 0 ? null : (0, vault_1.mintToken)().token;
|
|
557
|
+
if (ownerToken) {
|
|
558
|
+
console.log('==> Setting the one-time owner token as a Fly secret');
|
|
559
|
+
// `secrets import` reads KEY=VALUE lines from stdin, which keeps the token
|
|
560
|
+
// off the child's argv and so out of `ps`.
|
|
561
|
+
const r = await fly(['secrets', 'import', '-a', app, '--stage'], `${OWNER_TOKEN_SECRET}=${ownerToken}\n`);
|
|
562
|
+
if (r.code !== 0)
|
|
563
|
+
die(`Could not set the owner token secret:\n${r.stderr.trim() || r.stdout.trim()}`);
|
|
564
|
+
// From here the token exists in two places: this process, and a Fly secret
|
|
565
|
+
// that can be written but never read back. So every way out of the rest of
|
|
566
|
+
// this command has to end with the operator looking at it, and an exit hook
|
|
567
|
+
// is the one place that covers them all, including a `die()` from deeper
|
|
568
|
+
// down and an unexpected throw.
|
|
569
|
+
process.on('exit', () => {
|
|
570
|
+
if (tokenDelivered)
|
|
571
|
+
return;
|
|
572
|
+
// Written with writeSync rather than console.error, which is the whole
|
|
573
|
+
// point of doing it here: on Linux a write to a pipe is asynchronous, so
|
|
574
|
+
// console.error from an exit handler is discarded when the output is
|
|
575
|
+
// piped into a file or a pager, which is a plausible thing to do with a
|
|
576
|
+
// deploy. The one message that must not be lost cannot go out that way.
|
|
577
|
+
fs.writeSync(2, '\nThe owner token this run staged as a Fly secret, shown here because nothing\n' +
|
|
578
|
+
'else has a copy of it. A Fly secret can be written but not read back, and this\n' +
|
|
579
|
+
'token is the owner of the vault if this app initialized one:\n' +
|
|
580
|
+
`\n ${ownerToken}\n` +
|
|
581
|
+
`\nKeep it, then: mochi login ${appUrl(app)} --token ${ownerToken}\n`);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
const config = writeTempConfig(app, settings);
|
|
585
|
+
// A source build runs fly in the checkout, so the build context is the checkout
|
|
586
|
+
// and fly finds its Dockerfile without being told where it is. --config still
|
|
587
|
+
// points at the generated fly.toml in a temporary directory, which is why there
|
|
588
|
+
// is no fly.toml in the repository for a build to pick up by accident.
|
|
589
|
+
//
|
|
590
|
+
// Without --local-only, flyctl builds on a Fly builder machine, which needs no
|
|
591
|
+
// Docker here and provisions a builder app on first use. --local-build asks for
|
|
592
|
+
// the local daemon instead and pushes the result to Fly's registry.
|
|
593
|
+
const source = buildRoot !== null;
|
|
594
|
+
if (source) {
|
|
595
|
+
console.log(`==> Building ${buildRoot} ${a.localBuild ? 'with the local Docker' : "on Fly's builder"}`);
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
console.log(`==> Deploying ${image}`);
|
|
599
|
+
}
|
|
600
|
+
const code = await flyStream([
|
|
601
|
+
'deploy',
|
|
602
|
+
'--app',
|
|
603
|
+
app,
|
|
604
|
+
'--config',
|
|
605
|
+
config,
|
|
606
|
+
...(source ? (a.localBuild ? ['--local-only'] : []) : ['--image', image]),
|
|
607
|
+
'--ha=false',
|
|
608
|
+
'--yes',
|
|
609
|
+
], buildRoot ?? undefined);
|
|
610
|
+
fs.rmSync(path.dirname(config), { recursive: true, force: true });
|
|
611
|
+
if (code !== 0) {
|
|
612
|
+
console.error('');
|
|
613
|
+
console.error('The deploy failed. The app and the volume survive, so fix the cause and run the');
|
|
614
|
+
console.error('same command again.');
|
|
615
|
+
if (ownerToken) {
|
|
616
|
+
// The retry will not mint a second token if a machine was left behind by
|
|
617
|
+
// this attempt, and it could not overwrite a secret it cannot read even
|
|
618
|
+
// if it did. So this run's token is the one the vault ends up with, and
|
|
619
|
+
// the exit hook is about to print it.
|
|
620
|
+
console.error('');
|
|
621
|
+
console.error('The retry may not mint a token of its own, because the one shown at the end of');
|
|
622
|
+
console.error('this output is already the token the vault will be initialized with. Keep it,');
|
|
623
|
+
console.error('and log in with it once a deploy succeeds.');
|
|
624
|
+
}
|
|
625
|
+
if (source) {
|
|
626
|
+
console.error('');
|
|
627
|
+
console.error('If the build is the problem, `npm run build` in the checkout reproduces it locally');
|
|
628
|
+
console.error(a.localBuild
|
|
629
|
+
? 'without Fly in the way; check that the Docker daemon here is running.'
|
|
630
|
+
: "without Fly in the way. --local-build uses this machine's Docker instead of Fly's builder.");
|
|
631
|
+
}
|
|
632
|
+
else if (a.image === null) {
|
|
633
|
+
console.error('');
|
|
634
|
+
console.error(`If the image is the problem, check that ${image} exists,`);
|
|
635
|
+
console.error('or deploy another tag with --image <ref>, or build this checkout with --from-source.');
|
|
636
|
+
}
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
const url = appUrl(app);
|
|
640
|
+
console.log('');
|
|
641
|
+
if (!ownerToken) {
|
|
642
|
+
console.log(`==> Deployed: ${url}`);
|
|
643
|
+
console.log('');
|
|
644
|
+
console.log('The vault it serves is whichever vault was already on the volume, users and all.');
|
|
645
|
+
// No token was minted because a machine had run before, which usually means
|
|
646
|
+
// a vault that has been in use and an operator who is already logged in.
|
|
647
|
+
// With nothing stored here, the other reading is possible: an earlier
|
|
648
|
+
// attempt staged a token and left a machine behind, and this deploy has
|
|
649
|
+
// just initialized the vault with it. Saying so costs a line only in the
|
|
650
|
+
// case where it might be the answer.
|
|
651
|
+
if (!(await (0, credentials_1.readCredential)((0, credentials_1.credentialTarget)(url)))) {
|
|
652
|
+
console.log('');
|
|
653
|
+
console.log('No token for it is stored on this machine, so log in with one the vault knows:');
|
|
654
|
+
console.log('');
|
|
655
|
+
console.log(` mochi login ${url}`);
|
|
656
|
+
console.log('');
|
|
657
|
+
console.log('If an earlier deploy of this app failed, the vault was initialized just now with');
|
|
658
|
+
console.log('the owner token that run printed, and that is the token to use.');
|
|
659
|
+
console.log('');
|
|
660
|
+
}
|
|
661
|
+
console.log(` fly logs -a ${app}`);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
console.log('==> Waiting for the vault to answer');
|
|
665
|
+
const ready = await waitForVault(url, ownerToken);
|
|
666
|
+
if (!ready.ok) {
|
|
667
|
+
console.error('');
|
|
668
|
+
console.error(`Deployed, but ${ready.reason}.`);
|
|
669
|
+
console.error(`Look at what the server said: fly logs -a ${app}`);
|
|
670
|
+
if (existed) {
|
|
671
|
+
// The likeliest cause when the app was already there: a volume carrying
|
|
672
|
+
// a vault that was initialized by an earlier machine. Its own tokens are
|
|
673
|
+
// still the way in, and no token minted here will ever work on it.
|
|
674
|
+
console.error('');
|
|
675
|
+
console.error('If this app has served a vault before, that vault keeps the users and tokens it');
|
|
676
|
+
console.error('already had, and a token minted now is not one of them. Log in with one of those:');
|
|
677
|
+
console.error('');
|
|
678
|
+
console.error(` mochi login ${url}`);
|
|
679
|
+
}
|
|
680
|
+
// The token this run minted is printed on the way out by the exit hook,
|
|
681
|
+
// since a vault that has not answered yet may still adopt it.
|
|
682
|
+
process.exit(1);
|
|
683
|
+
}
|
|
684
|
+
// The token is shown rather than stored. A deploy that logged you in quietly
|
|
685
|
+
// left the operator holding a vault whose token they had never seen, which is
|
|
686
|
+
// no way to sign in to the web UI and nothing to keep anywhere; and the token
|
|
687
|
+
// cannot be recovered later, since the server keeps only its hash and a Fly
|
|
688
|
+
// secret cannot be read back. So it is printed once, with the two ways to use
|
|
689
|
+
// it, and `mochi login` stays the one thing that stores a credential.
|
|
690
|
+
console.log('');
|
|
691
|
+
console.log(`==> Ready: ${url}`);
|
|
692
|
+
console.log('');
|
|
693
|
+
console.log(`The vault is initialized, and '${ready.username}' owns it. This is its token, shown`);
|
|
694
|
+
console.log('here once and nowhere else: the server keeps only its hash, and the Fly secret it');
|
|
695
|
+
console.log('was staged in cannot be read back. Keep it somewhere safe now.');
|
|
696
|
+
console.log('');
|
|
697
|
+
console.log(` ${ownerToken}`);
|
|
698
|
+
// Only now: the exit hook is the backstop for a token that never reached the
|
|
699
|
+
// operator, and stdout can fail (a closed pipe) between here and there.
|
|
700
|
+
tokenDelivered = true;
|
|
701
|
+
console.log('');
|
|
702
|
+
console.log(`To administer the vault in a browser, open its sign-in page and give that token as`);
|
|
703
|
+
console.log(`'${ready.username}':`);
|
|
704
|
+
console.log('');
|
|
705
|
+
console.log(` ${url}/login`);
|
|
706
|
+
console.log('');
|
|
707
|
+
console.log('The form asks for a username and a token, since a vault has no passwords. From');
|
|
708
|
+
console.log('there the Admin page creates the users and the repositories, which is the usual');
|
|
709
|
+
console.log('way to bootstrap a fresh vault.');
|
|
710
|
+
console.log('');
|
|
711
|
+
console.log('To use the CLI and git instead, hand the same token to git\'s credential store,');
|
|
712
|
+
console.log('which is what login is for:');
|
|
713
|
+
console.log('');
|
|
714
|
+
console.log(` mochi login ${url}`);
|
|
715
|
+
console.log('');
|
|
716
|
+
console.log('It asks for the token without echoing it, checks it, and remembers this vault, so');
|
|
717
|
+
console.log('these need no arguments afterwards and git stops asking on a push:');
|
|
718
|
+
console.log('');
|
|
719
|
+
console.log(' mochi whoami');
|
|
720
|
+
console.log(" mochi user add alice --scope 'alice/*'");
|
|
721
|
+
console.log(` mochi import https://github.com/someone/something.git mine`);
|
|
722
|
+
console.log('');
|
|
723
|
+
console.log('Deploy an update, or change a setting, with the same command:');
|
|
724
|
+
console.log('');
|
|
725
|
+
console.log(` mochi deploy fly ${app}`);
|
|
726
|
+
console.log(` mochi deploy fly ${app} --volume 50 --vm-memory 1gb`);
|
|
727
|
+
}
|
|
728
|
+
async function deployShowCmd(args, usage) {
|
|
729
|
+
const a = parseDeployArgs(args, usage);
|
|
730
|
+
if (!a.app)
|
|
731
|
+
die('Which app? Usage: mochi deploy fly show <app>');
|
|
732
|
+
rejectFlyFlags(a, 'mochi deploy fly show <app>', false);
|
|
733
|
+
const app = a.app;
|
|
734
|
+
await requireFly();
|
|
735
|
+
if (!(await appExists(app))) {
|
|
736
|
+
die(`No Fly app named '${app}' that you can see. Check the name, or: fly apps list`);
|
|
737
|
+
}
|
|
738
|
+
const url = appUrl(app);
|
|
739
|
+
const vol = await vaultVolume(app);
|
|
740
|
+
const ms = await machines(app);
|
|
741
|
+
const secrets = await secretNames(app);
|
|
742
|
+
console.log(`${app} ${url}`);
|
|
743
|
+
console.log('');
|
|
744
|
+
if (ms.length === 0) {
|
|
745
|
+
console.log(' machines none, so nothing is serving this vault');
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
// More than one machine is worth naming rather than summarizing: it means
|
|
749
|
+
// two volumes and two vaults, which is the failure --ha=false prevents.
|
|
750
|
+
if (ms.length > 1)
|
|
751
|
+
console.log(` machines ${ms.length}, which is one too many for a single-volume vault`);
|
|
752
|
+
for (const m of ms) {
|
|
753
|
+
const g = m.config?.guest;
|
|
754
|
+
const shape = g ? `${g.cpu_kind}-cpu-${g.cpus}x, ${g.memory_mb}mb` : 'unknown shape';
|
|
755
|
+
console.log(` machine ${m.id} ${m.state ?? '?'} ${m.region ?? '?'} ${shape}`);
|
|
756
|
+
console.log(` image ${m.config?.image ?? 'unknown'}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
console.log(vol ? ` volume ${vol.size_gb}GB in ${vol.region} (${vol.state ?? 'created'})` : ' volume none');
|
|
760
|
+
console.log(` lfs ${secrets.includes('BUCKET_NAME') ? 'objects in a bucket (BUCKET_NAME is set)' : 'objects on the volume'}`);
|
|
761
|
+
// Whether it works, which is the question `fly status` cannot answer. A
|
|
762
|
+
// stored credential turns this into a report of who you are on it.
|
|
763
|
+
const target = (0, credentials_1.credentialTarget)(url);
|
|
764
|
+
const stored = await (0, credentials_1.readCredential)(target);
|
|
765
|
+
let vault = 'not reachable';
|
|
766
|
+
try {
|
|
767
|
+
const resp = await fetch(`${url}/api/whoami`, {
|
|
768
|
+
headers: stored ? { authorization: `Bearer ${stored.password}` } : {},
|
|
769
|
+
});
|
|
770
|
+
if (resp.ok) {
|
|
771
|
+
const data = (await resp.json());
|
|
772
|
+
vault = `answering, and you are '${String(data.username)}' on it`;
|
|
773
|
+
}
|
|
774
|
+
else if (resp.status === 401) {
|
|
775
|
+
vault = stored ? 'answering, but your stored token is not valid on it' : 'answering (no token stored here)';
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
vault = `answering with HTTP ${resp.status}`;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
vault = `not reachable: ${e instanceof Error ? e.message : e}`;
|
|
783
|
+
}
|
|
784
|
+
console.log(` vault ${vault}`);
|
|
785
|
+
const saved = (0, credentials_1.loadLogin)();
|
|
786
|
+
if (saved && saved.host.replace(/\/+$/, '') === url)
|
|
787
|
+
console.log(' login this is the vault mochi commands use');
|
|
788
|
+
// Fly's own volume snapshots live at the same provider as the volume, so they
|
|
789
|
+
// are a complement to a backup on a disk of your own rather than a substitute
|
|
790
|
+
// for one. Whether this machine keeps such a copy is worth one line.
|
|
791
|
+
//
|
|
792
|
+
// Both names are offered, because a vault with a domain of its own was almost
|
|
793
|
+
// certainly backed up by that name rather than by <app>.fly.dev, and a report
|
|
794
|
+
// that said "none" in that case would be worse than no report at all.
|
|
795
|
+
const backup = (0, backup_cmd_1.backupLineFor)([url, ...(await certHostnames(app)).map((h) => `https://${h}`)]);
|
|
796
|
+
console.log(backup ? ` backup ${backup}` : ' backup none on this machine (mochi backup <dir>)');
|
|
797
|
+
console.log('');
|
|
798
|
+
console.log(` fly logs -a ${app}`);
|
|
799
|
+
}
|
|
800
|
+
function promptLine(prompt) {
|
|
801
|
+
return new Promise((resolve, reject) => {
|
|
802
|
+
if (!process.stdin.isTTY) {
|
|
803
|
+
reject(new Error('Nothing to ask on: not a terminal. Pass --yes to confirm.'));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
process.stdout.write(prompt);
|
|
807
|
+
process.stdin.resume();
|
|
808
|
+
process.stdin.setEncoding('utf8');
|
|
809
|
+
const onData = (chunk) => {
|
|
810
|
+
process.stdin.removeListener('data', onData);
|
|
811
|
+
process.stdin.pause();
|
|
812
|
+
resolve(chunk.trim());
|
|
813
|
+
};
|
|
814
|
+
process.stdin.on('data', onData);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
async function deployDestroyCmd(args, usage) {
|
|
818
|
+
const a = parseDeployArgs(args, usage);
|
|
819
|
+
if (!a.app)
|
|
820
|
+
die('Which app? Usage: mochi deploy fly destroy <app> [--yes]');
|
|
821
|
+
rejectFlyFlags(a, 'mochi deploy fly destroy <app> [--yes]', true);
|
|
822
|
+
const app = a.app;
|
|
823
|
+
await requireFly();
|
|
824
|
+
if (!(await appExists(app))) {
|
|
825
|
+
die(`No Fly app named '${app}' that you can see. Check the name, or: fly apps list`);
|
|
826
|
+
}
|
|
827
|
+
const vol = await vaultVolume(app);
|
|
828
|
+
const hadBucket = (await secretNames(app)).includes('BUCKET_NAME');
|
|
829
|
+
if (!a.yes) {
|
|
830
|
+
console.log(`This destroys the Fly app '${app}' and its ${vol ? `${vol.size_gb}GB ` : ''}volume.`);
|
|
831
|
+
console.log('Everything in the vault goes with it: repositories, issues, pull requests, users.');
|
|
832
|
+
console.log('There is no undo, and Fly keeps no backup of a destroyed volume.');
|
|
833
|
+
console.log('');
|
|
834
|
+
const answer = await promptLine(`Type the app name to confirm: `);
|
|
835
|
+
if (answer !== app)
|
|
836
|
+
die('Not destroyed.');
|
|
837
|
+
}
|
|
838
|
+
const code = await flyStream(['apps', 'destroy', app, '--yes']);
|
|
839
|
+
if (code !== 0)
|
|
840
|
+
die('\nCould not destroy the app.');
|
|
841
|
+
// A credential for a vault that no longer exists is litter, and a saved
|
|
842
|
+
// login pointing at it would send the next command nowhere.
|
|
843
|
+
const url = appUrl(app);
|
|
844
|
+
const target = (0, credentials_1.credentialTarget)(url);
|
|
845
|
+
const stored = await (0, credentials_1.readCredential)(target);
|
|
846
|
+
if (stored) {
|
|
847
|
+
await (0, credentials_1.rejectCredential)(target, stored.username);
|
|
848
|
+
console.log(`Removed the stored credential for ${url}.`);
|
|
849
|
+
}
|
|
850
|
+
(0, credentials_1.clearLogin)(url);
|
|
851
|
+
if (hadBucket) {
|
|
852
|
+
console.log('');
|
|
853
|
+
console.log('The Tigris bucket that held this vault\'s LFS objects is a separate resource and');
|
|
854
|
+
console.log('was not destroyed. Remove it, and its contents, with:');
|
|
855
|
+
console.log('');
|
|
856
|
+
console.log(' fly storage list');
|
|
857
|
+
console.log(' fly storage destroy <name>');
|
|
858
|
+
}
|
|
859
|
+
}
|