@wpelevator/tug 0.12.0 → 0.13.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 +7 -3
- package/dist/backup-restore.d.ts +1 -1
- package/dist/backup-restore.js +3 -1
- package/dist/backup-scheduler.js +3 -1
- package/dist/cli-build.d.ts +1 -1
- package/dist/cli-build.js +4 -1
- package/dist/cli-process.d.ts +13 -0
- package/dist/cli-process.js +46 -0
- package/dist/cli-progress.d.ts +11 -0
- package/dist/cli-progress.js +37 -0
- package/dist/cli-prompt.js +5 -4
- package/dist/cli-selection.d.ts +13 -0
- package/dist/cli-selection.js +39 -0
- package/dist/commands/backup.js +48 -31
- package/dist/commands/context.d.ts +2 -5
- package/dist/commands/context.js +50 -28
- package/dist/commands/control.js +55 -42
- package/dist/commands/credential.js +21 -2
- package/dist/commands/deploy.js +97 -70
- package/dist/commands/hold.js +12 -4
- package/dist/commands/host.d.ts +2 -2
- package/dist/commands/host.js +13 -8
- package/dist/commands/node.d.ts +1 -0
- package/dist/commands/node.js +103 -71
- package/dist/commands/observe.js +7 -2
- package/dist/commands/restore.js +71 -50
- package/dist/commands/secret.js +8 -3
- package/dist/commands/selection.d.ts +22 -0
- package/dist/commands/selection.js +90 -0
- package/dist/commands/service.js +17 -6
- package/dist/commands/volume.js +3 -2
- package/dist/control.js +95 -47
- package/dist/credentials.js +5 -0
- package/dist/db-backup-destination.d.ts +14 -0
- package/dist/db-backup-destination.js +28 -0
- package/dist/db-locks.d.ts +10 -0
- package/dist/db-locks.js +23 -0
- package/dist/db-transaction.d.ts +3 -0
- package/dist/db-transaction.js +24 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +19 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +6 -0
- package/docs/access-control.md +166 -0
- package/docs/architecture.md +254 -0
- package/docs/backups.md +160 -0
- package/docs/cli.md +182 -0
- package/docs/compose.md +245 -0
- package/docs/configuration.md +52 -0
- package/docs/control-plane.md +112 -0
- package/docs/development.md +140 -0
- package/docs/getting-started.md +75 -0
- package/docs/healthchecks.md +51 -0
- package/docs/migration.md +113 -0
- package/docs/networking.md +150 -0
- package/docs/nodes.md +143 -0
- package/docs/observability.md +213 -0
- package/docs/real-host-evaluation.md +224 -0
- package/docs/releases.md +39 -0
- package/docs/roadmap.md +234 -0
- package/docs/security.md +198 -0
- package/docs/service-placement.md +72 -0
- package/docs/standalone-binary.md +79 -0
- package/docs/swarm-comparison.md +197 -0
- package/docs/terminology.md +74 -0
- package/docs/testing.md +254 -0
- package/docs/troubleshooting.md +33 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -6,9 +6,11 @@ A VPS is a durable computer with enough resources to host multiple sites or a me
|
|
|
6
6
|
|
|
7
7
|
Tug runs a small control plane and an agent, which can share the same VPS with your applications. It builds application images on the control server or your machine and delivers them to your nodes, so you do not need to run or configure an image registry for those builds. Published images are still pulled from their upstream registries.
|
|
8
8
|
|
|
9
|
+
Existing service and resource targets can be chosen directly in an interactive terminal: try `tug logs`, `tug deploy cancel` or `tug restore run`. Use `--select` for backup and move-hold subsets; see [interactive CLI selection](docs/cli.md#interactive-selection) for all supported commands and scripting behavior.
|
|
10
|
+
|
|
9
11
|
## What you can do with it
|
|
10
12
|
|
|
11
|
-
- **Deploy a Compose file to your servers.** `tug deploy` builds or pulls your images, starts them on the right nodes, waits for health checks and tells you why if something fails. `tug rollback` returns to the previous version, and `x-tug: { rollback: auto }` does it for you.
|
|
13
|
+
- **Deploy a Compose file to your servers.** `tug deploy` builds or pulls your images, starts them on the right nodes, waits for health checks and tells you why if something fails. It reports deployment stages and elapsed time while work is pending. `tug rollback` returns to the previous version, and `x-tug: { rollback: auto }` does it for you.
|
|
12
14
|
- **Choose where things run.** Pin a database and its volume to one node, spread web containers over several, and let Tug's managed Caddy ingress serve them over HTTPS with certificates it obtains for you.
|
|
13
15
|
- **Update without downtime.** Opt-in rolling rollouts start the new version, wait until it is healthy and only then switch traffic.
|
|
14
16
|
- **Keep secrets out of your repository.** `tug secret set DB_PASSWORD` stores them encrypted and sends each one only to the nodes whose services use it.
|
|
@@ -34,6 +36,8 @@ The control plane and agent share the VPS with your application. Once your servi
|
|
|
34
36
|
|
|
35
37
|
In a terminal Tug asks before it changes anything: whether to install Docker, which private address the node should use and whether it should handle ingress. It shows the plan and waits for you to confirm. `tug deploy` asks for any Compose variables, such as `${DB_PASSWORD}`, that no secret or `.env` provides. In CI or any other script nothing is ever asked, so pass the choices as flags (`--address`, `--ingress`, `--install-docker`, `-y`).
|
|
36
38
|
|
|
39
|
+
Long-running deployments, rollbacks, setup, image transfers, backups, restores and agent command waits report their current stage and elapsed time to stderr every ten seconds. Progress also works in CI and keeps JSON results separate. See [CLI progress reporting](docs/cli.md).
|
|
40
|
+
|
|
37
41
|
## Your Compose file, plus a little `x-tug`
|
|
38
42
|
|
|
39
43
|
This example builds on the [quickstart](docs/getting-started.md). It pins the app, database and database volume to `app1`. Placement can be omitted with one node, but explicit placement keeps the database on its chosen server when you add more nodes. Enable ingress with `tug node ingress app1`, point the hostname's DNS at the VPS, and allow public ports 80 and 443. Save this as `compose.yaml` and replace the hostname with yours:
|
|
@@ -94,7 +98,7 @@ tug logs wordpress --follow
|
|
|
94
98
|
|
|
95
99
|
The named volume holds data on `app1`; it is not copied to another node automatically. Back it up and restore or move its data deliberately if you replace the server.
|
|
96
100
|
|
|
97
|
-
See the [examples directory](examples/) for stack-specific applications. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
|
|
101
|
+
See the [examples directory](examples/) for stack-specific applications and [local stack benchmarks](docs/testing.md#example-stack-benchmarks) for measured behavior of those examples. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
|
|
98
102
|
|
|
99
103
|
## Everyday commands
|
|
100
104
|
|
|
@@ -199,4 +203,4 @@ npm run lint
|
|
|
199
203
|
npm run test:integration
|
|
200
204
|
```
|
|
201
205
|
|
|
202
|
-
See [Development](docs/development.md) for details.
|
|
206
|
+
See [Development](docs/development.md) for details. Database code uses direct SQLite queries; [SQLite storage](docs/architecture.md#sqlite-storage) describes query boundaries and synchronous transactions.
|
package/dist/backup-restore.d.ts
CHANGED
|
@@ -69,6 +69,6 @@ export declare function createRestoreService(deps: RestoreDeps): {
|
|
|
69
69
|
status: number;
|
|
70
70
|
};
|
|
71
71
|
holds: () => Record<string, string[]>;
|
|
72
|
-
list: (project: string, limit?: number) => RestoreRunRow[];
|
|
72
|
+
list: (project: string, limit?: number, interruptedOnly?: boolean) => RestoreRunRow[];
|
|
73
73
|
};
|
|
74
74
|
export type RestoreService = ReturnType<typeof createRestoreService>;
|
package/dist/backup-restore.js
CHANGED
|
@@ -374,7 +374,9 @@ export function createRestoreService(deps) {
|
|
|
374
374
|
(result[row.project_id] ??= []).push(row.service);
|
|
375
375
|
return result;
|
|
376
376
|
}
|
|
377
|
-
function list(project, limit = 20) {
|
|
377
|
+
function list(project, limit = 20, interruptedOnly = false) {
|
|
378
|
+
if (interruptedOnly)
|
|
379
|
+
return db.all("SELECT * FROM restore_runs WHERE project_id=? AND state='interrupted' ORDER BY started_at DESC,id", project);
|
|
378
380
|
return db.all('SELECT * FROM restore_runs WHERE project_id=? ORDER BY started_at DESC LIMIT ?', project, limit);
|
|
379
381
|
}
|
|
380
382
|
return { plan, start, advance, releaseInterrupted, resume, holds, list };
|
package/dist/backup-scheduler.js
CHANGED
|
@@ -4,6 +4,7 @@ import { projectRepository } from "./backup-destination.js";
|
|
|
4
4
|
import { volumeName } from "./container-spec.js";
|
|
5
5
|
import { id } from "./util.js";
|
|
6
6
|
import { isUniqueViolation } from "./db-driver.js";
|
|
7
|
+
import { backupDestinationQueries } from "./db-backup-destination.js";
|
|
7
8
|
/** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
|
|
8
9
|
export const DEFAULT_RESTIC_IMAGE = 'restic/restic:0.17.3';
|
|
9
10
|
const MINUTE = 60_000;
|
|
@@ -63,7 +64,8 @@ export function createBackupService(deps) {
|
|
|
63
64
|
const started = clock().getTime();
|
|
64
65
|
const stamp = () => clock().toISOString();
|
|
65
66
|
const heartbeats = new Map();
|
|
66
|
-
const
|
|
67
|
+
const destinationQueries = backupDestinationQueries(db);
|
|
68
|
+
const destination = () => destinationQueries.find();
|
|
67
69
|
const nodeId = (name) => db.get('SELECT id FROM nodes WHERE name=?', name)?.id;
|
|
68
70
|
const nodeName = (nodeIdValue) => nodeIdValue
|
|
69
71
|
? (db.get('SELECT name FROM nodes WHERE id=?', nodeIdValue)?.name ?? null)
|
package/dist/cli-build.d.ts
CHANGED
|
@@ -14,4 +14,4 @@ export declare function createSourceArchive(contextDir: string, archive: string,
|
|
|
14
14
|
/** Build from the same filtered, validated source snapshot used for remote builds. */
|
|
15
15
|
export declare function createLocalBuildSnapshot(contextDir: string, directory: string, configFiles: string[]): Promise<string>;
|
|
16
16
|
/** Build only application build services; callers own temporary files and upload transport. */
|
|
17
|
-
export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies): Promise<LocalImageUpload[]>;
|
|
17
|
+
export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies, onProgress?: (message: string) => void): Promise<LocalImageUpload[]>;
|
package/dist/cli-build.js
CHANGED
|
@@ -143,7 +143,7 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
|
|
|
143
143
|
remove: async (imageRef) => {
|
|
144
144
|
await run('docker', ['image', 'rm', imageRef]);
|
|
145
145
|
},
|
|
146
|
-
}) {
|
|
146
|
+
}, onProgress) {
|
|
147
147
|
const images = [];
|
|
148
148
|
const nonce = dependencies.nonce();
|
|
149
149
|
for (const [service, spec] of Object.entries(compose.services)) {
|
|
@@ -152,16 +152,19 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
|
|
|
152
152
|
const field = `image-${images.length}`;
|
|
153
153
|
const imageRef = `tug-build/${nonce}/${field}:local`;
|
|
154
154
|
try {
|
|
155
|
+
onProgress?.(`Building image for ${service} locally`);
|
|
155
156
|
await dependencies.build({
|
|
156
157
|
contextDir,
|
|
157
158
|
service,
|
|
158
159
|
buildSpec: spec.build,
|
|
159
160
|
imageRef,
|
|
160
161
|
});
|
|
162
|
+
onProgress?.(`Exporting image for ${service}`);
|
|
161
163
|
const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
|
|
162
164
|
images.push({ service, imageRef, ...archive, field });
|
|
163
165
|
}
|
|
164
166
|
finally {
|
|
167
|
+
onProgress?.(`Cleaning up temporary image for ${service}`);
|
|
165
168
|
try {
|
|
166
169
|
await dependencies.remove(imageRef);
|
|
167
170
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Keep the event loop free for progress timers during SSH, Docker and file transfers. */
|
|
2
|
+
export declare function runProcess(cmd: string, args: string[], { capture, input }?: {
|
|
3
|
+
capture?: boolean;
|
|
4
|
+
input?: string;
|
|
5
|
+
}): Promise<{
|
|
6
|
+
status: number | null;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function runLocal(cmd: string, args: string[]): Promise<void>;
|
|
11
|
+
export declare function runCapture(cmd: string, args: string[], opts?: {
|
|
12
|
+
input?: string;
|
|
13
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { RUN_OUTPUT_LIMIT } from "./build.js";
|
|
3
|
+
/** Keep the event loop free for progress timers during SSH, Docker and file transfers. */
|
|
4
|
+
export function runProcess(cmd, args, { capture = false, input } = {}) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(cmd, args, {
|
|
7
|
+
stdio: [
|
|
8
|
+
input === undefined ? 'inherit' : 'pipe',
|
|
9
|
+
capture ? 'pipe' : 'inherit',
|
|
10
|
+
capture ? 'pipe' : 'inherit',
|
|
11
|
+
],
|
|
12
|
+
});
|
|
13
|
+
let stdout = '';
|
|
14
|
+
let stderr = '';
|
|
15
|
+
child.stdout?.on('data', (chunk) => {
|
|
16
|
+
stdout = (stdout + String(chunk)).slice(-RUN_OUTPUT_LIMIT);
|
|
17
|
+
});
|
|
18
|
+
child.stderr?.on('data', (chunk) => {
|
|
19
|
+
stderr = (stderr + String(chunk)).slice(-RUN_OUTPUT_LIMIT);
|
|
20
|
+
});
|
|
21
|
+
child.on('error', reject);
|
|
22
|
+
child.stdin?.on('error', (error) => {
|
|
23
|
+
if (error.code !== 'EPIPE')
|
|
24
|
+
reject(error);
|
|
25
|
+
});
|
|
26
|
+
if (input !== undefined)
|
|
27
|
+
child.stdin?.end(input);
|
|
28
|
+
child.on('close', (status, signal) => {
|
|
29
|
+
if (signal)
|
|
30
|
+
reject(new Error(`${cmd} terminated by ${signal}`));
|
|
31
|
+
else
|
|
32
|
+
resolve({ status, stdout, stderr });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function runLocal(cmd, args) {
|
|
37
|
+
const result = await runProcess(cmd, args);
|
|
38
|
+
if (result.status !== 0)
|
|
39
|
+
throw new Error(`${cmd} failed (exit ${result.status})`);
|
|
40
|
+
}
|
|
41
|
+
export async function runCapture(cmd, args, opts = {}) {
|
|
42
|
+
const result = await runProcess(cmd, args, { ...opts, capture: true });
|
|
43
|
+
if (result.status !== 0)
|
|
44
|
+
throw new Error(`${cmd} failed (exit ${result.status})\n${result.stderr}`);
|
|
45
|
+
return result.stdout;
|
|
46
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Line-based stderr progress works in terminals and CI without changing stdout results. */
|
|
2
|
+
export declare function createProgress({ write, now, intervalMs, }?: {
|
|
3
|
+
write?: ((line: string) => void) | undefined;
|
|
4
|
+
now?: (() => number) | undefined;
|
|
5
|
+
intervalMs?: number | undefined;
|
|
6
|
+
}): {
|
|
7
|
+
stage(next: string): void;
|
|
8
|
+
stop(): void;
|
|
9
|
+
};
|
|
10
|
+
/** Own the timer for one operation, including failures and early returns. */
|
|
11
|
+
export declare function withProgress<T>(label: string, operation: (progress: ReturnType<typeof createProgress>) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Line-based stderr progress works in terminals and CI without changing stdout results. */
|
|
2
|
+
export function createProgress({ write = (line) => {
|
|
3
|
+
process.stderr.write(`${line}\n`);
|
|
4
|
+
}, now = Date.now, intervalMs = 10_000, } = {}) {
|
|
5
|
+
let label = '';
|
|
6
|
+
let started = now();
|
|
7
|
+
let timer;
|
|
8
|
+
const report = () => write(`[${Math.floor((now() - started) / 1000)}s] ${label}`);
|
|
9
|
+
return {
|
|
10
|
+
stage(next) {
|
|
11
|
+
if (next === label)
|
|
12
|
+
return;
|
|
13
|
+
label = next;
|
|
14
|
+
started = now();
|
|
15
|
+
report();
|
|
16
|
+
if (!timer) {
|
|
17
|
+
timer = setInterval(report, intervalMs);
|
|
18
|
+
timer.unref();
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
stop() {
|
|
22
|
+
clearInterval(timer);
|
|
23
|
+
timer = undefined;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** Own the timer for one operation, including failures and early returns. */
|
|
28
|
+
export async function withProgress(label, operation) {
|
|
29
|
+
const progress = createProgress();
|
|
30
|
+
try {
|
|
31
|
+
progress.stage(label);
|
|
32
|
+
return await operation(progress);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
progress.stop();
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/cli-prompt.js
CHANGED
|
@@ -10,24 +10,25 @@ export const ask = async (question, hidden = false) => {
|
|
|
10
10
|
const output = new Writable({
|
|
11
11
|
write(chunk, _encoding, callback) {
|
|
12
12
|
if (!muted.on)
|
|
13
|
-
process.
|
|
13
|
+
process.stderr.write(chunk);
|
|
14
14
|
callback();
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
17
|
const rl = readline.createInterface({ input: process.stdin, output, terminal: true });
|
|
18
18
|
try {
|
|
19
19
|
return await new Promise((resolve, reject) => {
|
|
20
|
-
|
|
20
|
+
const onClose = () => reject(new Error('input closed before an answer was given'));
|
|
21
|
+
rl.once('close', onClose);
|
|
21
22
|
rl.question(question, (answer) => {
|
|
23
|
+
rl.off('close', onClose);
|
|
22
24
|
if (hidden)
|
|
23
|
-
process.
|
|
25
|
+
process.stderr.write('\n');
|
|
24
26
|
resolve(answer);
|
|
25
27
|
});
|
|
26
28
|
muted.on = hidden;
|
|
27
29
|
});
|
|
28
30
|
}
|
|
29
31
|
finally {
|
|
30
|
-
rl.removeAllListeners('close');
|
|
31
32
|
rl.close();
|
|
32
33
|
}
|
|
33
34
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Prompter } from './cli-prompt.ts';
|
|
2
|
+
export interface Choice {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
export interface SelectionIO {
|
|
7
|
+
interactive: boolean;
|
|
8
|
+
prompt: Prompter;
|
|
9
|
+
write: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
/** Resolve lazily so explicit arguments never need a discovery request. */
|
|
12
|
+
export declare function selectOne(value: string | undefined, question: string, load: () => Promise<Choice[]>, io?: SelectionIO): Promise<string>;
|
|
13
|
+
export declare function selectMany(question: string, load: () => Promise<Choice[]>, io?: SelectionIO): Promise<string[]>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ask, canPrompt } from "./cli-prompt.js";
|
|
2
|
+
const terminal = () => ({
|
|
3
|
+
interactive: canPrompt(),
|
|
4
|
+
prompt: ask,
|
|
5
|
+
write: console.error,
|
|
6
|
+
});
|
|
7
|
+
/** Resolve lazily so explicit arguments never need a discovery request. */
|
|
8
|
+
export async function selectOne(value, question, load, io = terminal()) {
|
|
9
|
+
if (value)
|
|
10
|
+
return value;
|
|
11
|
+
const selected = await selectChoices(question, load, false, io);
|
|
12
|
+
return selected[0];
|
|
13
|
+
}
|
|
14
|
+
export async function selectMany(question, load, io = terminal()) {
|
|
15
|
+
return selectChoices(question, load, true, io);
|
|
16
|
+
}
|
|
17
|
+
async function selectChoices(question, load, multiple, io) {
|
|
18
|
+
if (!io.interactive)
|
|
19
|
+
throw new Error(`${question}: pass an explicit target; selection requires a terminal outside CI`);
|
|
20
|
+
const options = await load();
|
|
21
|
+
if (!options.length)
|
|
22
|
+
throw new Error(`${question}: no eligible options`);
|
|
23
|
+
io.write(question);
|
|
24
|
+
options.forEach((option, index) => io.write(` ${index + 1}) ${option.label}`));
|
|
25
|
+
for (;;) {
|
|
26
|
+
const answer = (await io.prompt(multiple
|
|
27
|
+
? 'Choose numbers separated by commas (or q to cancel): '
|
|
28
|
+
: 'Choose a number (or q to cancel): ')).trim();
|
|
29
|
+
if (/^(q|quit|cancel)$/i.test(answer))
|
|
30
|
+
throw new Error('cancelled; nothing was changed');
|
|
31
|
+
const parts = answer.split(',').map((part) => part.trim());
|
|
32
|
+
if ((!multiple && parts.length !== 1) || parts.some((part) => !/^[1-9]\d*$/.test(part)))
|
|
33
|
+
continue;
|
|
34
|
+
const indexes = parts.map((part) => Number(part) - 1);
|
|
35
|
+
if (indexes.some((index) => index >= options.length))
|
|
36
|
+
continue;
|
|
37
|
+
return [...new Set(indexes)].map((index) => options[index].value);
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/commands/backup.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selectBackups } from "./selection.js";
|
|
2
|
+
import { withProgress } from "../cli-progress.js";
|
|
1
3
|
import { backupCheckProblems } from "../backup-check.js";
|
|
2
4
|
import { collect, loadCfg, print, readStdin, request } from "./context.js";
|
|
3
5
|
const formatBytes = (bytes) => bytes === null
|
|
@@ -13,42 +15,57 @@ export function registerBackup(program) {
|
|
|
13
15
|
backup
|
|
14
16
|
.command('run [targets...]')
|
|
15
17
|
.description('back up the named services or volumes now, or every backup target of the project; waits and exits nonzero if any backup fails')
|
|
18
|
+
.option('--select', 'choose one or more backup targets instead of backing up all')
|
|
16
19
|
.option('--no-wait', 'return once the backups are queued')
|
|
17
20
|
.option('--timeout <seconds>', 'give up waiting after this many seconds', '3600')
|
|
18
21
|
.action(async (targets, o) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
targets
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
22
|
+
if (o.select && targets.length)
|
|
23
|
+
throw new Error('--select cannot be combined with explicit targets');
|
|
24
|
+
if (o.select)
|
|
25
|
+
targets = await selectBackups();
|
|
26
|
+
return withProgress('Queueing backups', async (progress) => {
|
|
27
|
+
const project = (await loadCfg()).project || 'site';
|
|
28
|
+
const queued = await request('POST', '/api/v1/backups/run', {
|
|
29
|
+
project,
|
|
30
|
+
targets: targets.length ? targets : undefined,
|
|
31
|
+
});
|
|
32
|
+
for (const message of queued.errors)
|
|
33
|
+
console.error(message);
|
|
34
|
+
for (const run of queued.runs)
|
|
35
|
+
console.log(`queued ${run.target} as ${run.runId}`);
|
|
36
|
+
let failed = queued.errors.length > 0;
|
|
37
|
+
if (o.wait) {
|
|
38
|
+
const deadline = Date.now() + Number(o.timeout) * 1000;
|
|
39
|
+
const pending = new Set(queued.runs.map((run) => run.runId));
|
|
40
|
+
if (pending.size)
|
|
41
|
+
progress.stage(`Waiting for backups: ${queued.runs.map((run) => run.target).join(', ')}`);
|
|
42
|
+
const states = new Map();
|
|
43
|
+
while (pending.size && Date.now() < deadline) {
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
45
|
+
const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=200`);
|
|
46
|
+
for (const run of listing.runs) {
|
|
47
|
+
if (pending.has(run.id))
|
|
48
|
+
states.set(run.id, `${run.target}: ${run.state}`);
|
|
49
|
+
if (pending.has(run.id) && BACKUP_TERMINAL.includes(run.state)) {
|
|
50
|
+
pending.delete(run.id);
|
|
51
|
+
const ok = run.state === 'succeeded';
|
|
52
|
+
failed ||= !ok;
|
|
53
|
+
console.log(ok
|
|
54
|
+
? `${run.target}: snapshot ${run.snapshot_id?.slice(0, 8)}, ${formatBytes(run.added_bytes)} added`
|
|
55
|
+
: `${run.target}: ${run.state}${run.error ? ` (${run.error})` : ''}`);
|
|
56
|
+
}
|
|
43
57
|
}
|
|
58
|
+
if (pending.size)
|
|
59
|
+
progress.stage(`Backups: ${[...pending].map((id) => states.get(id) ?? id).join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
if (pending.size) {
|
|
62
|
+
console.error(`timed out waiting for ${pending.size} backup(s); they keep running on the node`);
|
|
63
|
+
failed = true;
|
|
64
|
+
}
|
|
44
65
|
}
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (failed)
|
|
51
|
-
process.exitCode = 1;
|
|
66
|
+
if (failed)
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
52
69
|
});
|
|
53
70
|
backup
|
|
54
71
|
.command('list')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
export { runCapture, runLocal } from '../cli-process.ts';
|
|
2
2
|
import type { ResolvedCliConfig } from '../cli-config.ts';
|
|
3
3
|
import type { LiveInput, LiveRequest } from '../cli-live.ts';
|
|
4
4
|
import type { DeploymentState } from '../protocol.ts';
|
|
@@ -50,9 +50,6 @@ export declare function request<T = any>(method: string, route: string, body?: u
|
|
|
50
50
|
/** The project a project-scoped command works on: `--project`, then the resolved config, then `site`. */
|
|
51
51
|
export declare function projectOf(o: CommonOpts): Promise<string>;
|
|
52
52
|
export declare const print: (value: unknown) => void;
|
|
53
|
-
export declare function
|
|
54
|
-
export declare function runCapture(cmd: string, args: string[], opts?: SpawnSyncOptions): string;
|
|
55
|
-
export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number): Promise<DeploymentRecord>;
|
|
53
|
+
export declare function waitForDeployment(deploymentId: string, timeoutSeconds?: number, onProgress?: (deployment: DeploymentRecord) => void): Promise<DeploymentRecord>;
|
|
56
54
|
export declare function waitForCommand(commandId: string, timeoutSeconds?: number): Promise<CommandRecord>;
|
|
57
55
|
export declare function runLive(operation: LiveRequest['operation'], input: Omit<LiveInput, 'project'>): Promise<void>;
|
|
58
|
-
export {};
|
package/dist/commands/context.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { canPrompt } from "../cli-prompt.js";
|
|
2
|
+
import { selectOne } from "../cli-selection.js";
|
|
3
|
+
import { withProgress } from "../cli-progress.js";
|
|
4
|
+
export { runCapture, runLocal } from "../cli-process.js";
|
|
2
5
|
import path from 'node:path';
|
|
3
6
|
import { fileURLToPath } from 'node:url';
|
|
4
7
|
import { createTransport } from "../agent-credential.js";
|
|
5
8
|
import { createCliLiveTransport } from "../cli-live-transport.js";
|
|
6
9
|
import { parseLiveRequest, runCliLive } from "../cli-live.js";
|
|
7
10
|
import { TERMINAL_DEPLOYMENT_STATES } from "../protocol.js";
|
|
11
|
+
import { describeFetchError, errMessage } from "../util.js";
|
|
8
12
|
// Templates live next to the commands' parent: src/templates in dev (`node src/cli.ts`), dist/templates in the
|
|
9
13
|
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
10
14
|
export const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
@@ -53,11 +57,17 @@ export async function request(method, route, body, multipart = false) {
|
|
|
53
57
|
if (!multipart)
|
|
54
58
|
headers['content-type'] = 'application/json';
|
|
55
59
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
let res;
|
|
61
|
+
try {
|
|
62
|
+
res = await transport.http(`${transport.base}${route}`, {
|
|
63
|
+
method,
|
|
64
|
+
headers,
|
|
65
|
+
body: multipart ? body : body ? JSON.stringify(body) : undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new Error(`${method} ${route}: could not reach control at ${transport.base}: ${describeFetchError(error)}`, { cause: error });
|
|
70
|
+
}
|
|
61
71
|
if (!res.ok)
|
|
62
72
|
throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`);
|
|
63
73
|
return (await res.json());
|
|
@@ -71,21 +81,17 @@ export async function projectOf(o) {
|
|
|
71
81
|
return o.project || (await loadCfg()).project || 'site';
|
|
72
82
|
}
|
|
73
83
|
export const print = (value) => console.log(JSON.stringify(value, null, 2));
|
|
74
|
-
export function
|
|
75
|
-
const result = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
76
|
-
if (result.status !== 0)
|
|
77
|
-
throw new Error(`${cmd} failed${result.error ? `: ${result.error.message}` : ` (exit ${result.status ?? result.signal})`}`);
|
|
78
|
-
}
|
|
79
|
-
export function runCapture(cmd, args, opts = {}) {
|
|
80
|
-
const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
|
|
81
|
-
if (result.status !== 0)
|
|
82
|
-
throw new Error(`${cmd} ${args.join(' ')} failed\n${result.stderr || ''}`);
|
|
83
|
-
return String(result.stdout);
|
|
84
|
-
}
|
|
85
|
-
export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
84
|
+
export async function waitForDeployment(deploymentId, timeoutSeconds = 300, onProgress) {
|
|
86
85
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
87
86
|
while (Date.now() < deadline) {
|
|
88
|
-
|
|
87
|
+
let deployment;
|
|
88
|
+
try {
|
|
89
|
+
deployment = await request('GET', `/api/v1/deployments/${encodeURIComponent(deploymentId)}`);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw new Error(`lost contact with control while waiting for deployment ${deploymentId} (${errMessage(error)}); it may still be rolling out: check tug status, or stop it with: tug deploy cancel ${deploymentId}`, { cause: error });
|
|
93
|
+
}
|
|
94
|
+
onProgress?.(deployment);
|
|
89
95
|
if (TERMINAL_DEPLOYMENT_STATES.includes(deployment.state)) {
|
|
90
96
|
if (deployment.state !== 'succeeded')
|
|
91
97
|
throw new Error(`deployment ${deploymentId} ${deployment.state}${deployment.error_message ? `: ${deployment.error_message}` : ''}`);
|
|
@@ -93,19 +99,35 @@ export async function waitForDeployment(deploymentId, timeoutSeconds = 300) {
|
|
|
93
99
|
}
|
|
94
100
|
await new Promise((r) => setTimeout(r, 2000));
|
|
95
101
|
}
|
|
96
|
-
throw new Error(`timed out waiting for deployment ${deploymentId}`);
|
|
102
|
+
throw new Error(`timed out waiting for deployment ${deploymentId}; it is still rolling out: check tug status, or stop it with: tug deploy cancel ${deploymentId}`);
|
|
97
103
|
}
|
|
98
104
|
export async function waitForCommand(commandId, timeoutSeconds = 60) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
return withProgress(`Waiting for command ${commandId}`, async (progress) => {
|
|
106
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
const cmd = await request('GET', `/api/v1/commands/${commandId}`);
|
|
109
|
+
if (cmd)
|
|
110
|
+
progress.stage(`Command ${commandId}: ${cmd.state}`);
|
|
111
|
+
if (cmd?.state === 'succeeded' || cmd?.state === 'failed')
|
|
112
|
+
return cmd;
|
|
113
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`timed out waiting for command ${commandId}`);
|
|
116
|
+
});
|
|
107
117
|
}
|
|
108
118
|
export async function runLive(operation, input) {
|
|
109
119
|
const cfg = await loadCfg();
|
|
120
|
+
if (!input.node && canPrompt()) {
|
|
121
|
+
const { services: listing } = await request('GET', `/api/v1/services?project=${encodeURIComponent(cfg.project || 'site')}`);
|
|
122
|
+
const candidates = new Map(listing
|
|
123
|
+
.filter((service) => operation === 'ps' || service.name === input.service)
|
|
124
|
+
.flatMap((service) => service.nodes)
|
|
125
|
+
.map((node) => [node.name, node]));
|
|
126
|
+
if (candidates.size > 1)
|
|
127
|
+
input.node = await selectOne(undefined, 'Select a node for this operation', async () => [...candidates.values()].map((node) => ({
|
|
128
|
+
value: node.name,
|
|
129
|
+
label: `${node.name} (${node.status})`,
|
|
130
|
+
})));
|
|
131
|
+
}
|
|
110
132
|
process.exitCode = await runCliLive(parseLiveRequest(operation, { ...input, project: cfg.project || 'site' }), createCliLiveTransport(cfg.credential, cfg.controlUrl));
|
|
111
133
|
}
|