@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/dist/commands/control.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { withProgress } from "../cli-progress.js";
|
|
2
|
+
import { runProcess } from "../cli-process.js";
|
|
2
3
|
import { canPrompt, confirm } from "../cli-prompt.js";
|
|
3
4
|
import { CONTROL_CONTAINER, defaultControlUrl, shellQuote as sh } from "../cli-install.js";
|
|
4
|
-
import { resolveImage } from "../image.js";
|
|
5
|
+
import { assertImagePublished, resolveImage } from "../image.js";
|
|
5
6
|
import { decodeCredential, writeFileDurable } from "../pki.js";
|
|
6
7
|
import { loadTemplate } from "../util.js";
|
|
7
8
|
import { collect, loadCfg, print, request, runLocal, templatesDir } from "./context.js";
|
|
@@ -36,6 +37,7 @@ function addSetup(parent, description) {
|
|
|
36
37
|
o.installDocker = await confirm(`Docker with the Compose plugin is not installed on ${sshTarget}. Install it now (needs root)?`);
|
|
37
38
|
}
|
|
38
39
|
const image = resolveImage(o.image, o.buildLocalImage);
|
|
40
|
+
assertImagePublished(image);
|
|
39
41
|
console.error([
|
|
40
42
|
'Control plan',
|
|
41
43
|
` host: ${sshTarget}`,
|
|
@@ -47,45 +49,56 @@ function addSetup(parent, description) {
|
|
|
47
49
|
].join('\n'));
|
|
48
50
|
if (interactive && !o.yes && !(await confirm('Install the control plane?')))
|
|
49
51
|
throw new Error('cancelled; nothing was changed');
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await transferTugImage(sshTarget, image);
|
|
55
|
-
const yamlValue = (value) => JSON.stringify(value);
|
|
56
|
-
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
57
|
-
IMAGE: yamlValue(image),
|
|
58
|
-
CONTROL_URL: yamlValue(new URL(controlUrl).origin),
|
|
59
|
-
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
60
|
-
PORT: o.port,
|
|
61
|
-
});
|
|
62
|
-
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
63
|
-
COMPOSE: compose,
|
|
64
|
-
CONTAINER_SH: sh(CONTROL_CONTAINER),
|
|
65
|
-
});
|
|
66
|
-
runLocal('ssh', ['--', sshTarget, remote]);
|
|
67
|
-
// Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to files.
|
|
68
|
-
const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
|
|
69
|
-
const out = spawnSync('ssh', ['--', sshTarget, issue], { encoding: 'utf8' });
|
|
70
|
-
if (out.status !== 0) {
|
|
71
|
-
if (!o.recoverRoot && /already used/.test(String(out.stderr)))
|
|
72
|
-
console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
|
|
73
|
-
else
|
|
74
|
-
throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
const text = `${String(out.stdout).trim()}\n`;
|
|
78
|
-
decodeCredential(text);
|
|
79
|
-
if (o.credentialOut) {
|
|
80
|
-
await writeFileDurable(o.credentialOut, text, 0o600);
|
|
81
|
-
console.error(`root credential written to ${o.credentialOut}`);
|
|
52
|
+
return withProgress(`Preparing control on ${sshTarget}`, async (progress) => {
|
|
53
|
+
if (o.installDocker) {
|
|
54
|
+
progress.stage(`Installing Docker on ${sshTarget}`);
|
|
55
|
+
await bootstrapDocker(sshTarget, true);
|
|
82
56
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
57
|
+
progress.stage(`Checking control host ${sshTarget}`);
|
|
58
|
+
await preflightRemote(sshTarget, {
|
|
59
|
+
ports: [Number(o.port)],
|
|
60
|
+
minDiskMb: 2048,
|
|
61
|
+
compose: true,
|
|
62
|
+
});
|
|
63
|
+
if (o.buildLocalImage)
|
|
64
|
+
await transferTugImage(sshTarget, image, progress.stage);
|
|
65
|
+
const yamlValue = (value) => JSON.stringify(value);
|
|
66
|
+
const compose = await loadTemplate(templatesDir, 'control-compose.yml', {
|
|
67
|
+
IMAGE: yamlValue(image),
|
|
68
|
+
CONTROL_URL: yamlValue(new URL(controlUrl).origin),
|
|
69
|
+
EXTRA_SANS: yamlValue(o.extraSan.join(',')),
|
|
70
|
+
PORT: o.port,
|
|
71
|
+
});
|
|
72
|
+
const remote = await loadTemplate(templatesDir, 'control-install.sh', {
|
|
73
|
+
COMPOSE: compose,
|
|
74
|
+
CONTAINER_SH: sh(CONTROL_CONTAINER),
|
|
75
|
+
});
|
|
76
|
+
progress.stage(`Installing control on ${sshTarget}`);
|
|
77
|
+
await runLocal('ssh', ['--', sshTarget, remote]);
|
|
78
|
+
// Upgrades never reissue root: fresh installs create it, `--recover-root` replaces its key. Output goes straight to files.
|
|
79
|
+
const issue = `docker exec ${sh(CONTROL_CONTAINER)} node dist/main.js control issue-credential --name root --role admin${o.recoverRoot ? ' --replace' : ''}`;
|
|
80
|
+
progress.stage(`Issuing root credential on ${sshTarget}`);
|
|
81
|
+
const out = await runProcess('ssh', ['--', sshTarget, issue], { capture: true });
|
|
82
|
+
if (out.status !== 0) {
|
|
83
|
+
if (!o.recoverRoot && /already used/.test(String(out.stderr)))
|
|
84
|
+
console.error('control already has a root credential; upgrade complete (pass --recover-root to replace it)');
|
|
85
|
+
else
|
|
86
|
+
throw new Error(`issuing the root credential failed: ${String(out.stderr).trim()}`);
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
else {
|
|
89
|
+
const text = `${String(out.stdout).trim()}\n`;
|
|
90
|
+
decodeCredential(text);
|
|
91
|
+
if (o.credentialOut) {
|
|
92
|
+
await writeFileDurable(o.credentialOut, text, 0o600);
|
|
93
|
+
console.error(`root credential written to ${o.credentialOut}`);
|
|
94
|
+
}
|
|
95
|
+
if (o.import) {
|
|
96
|
+
const { controlUrl: connectUrl, project } = command.optsWithGlobals();
|
|
97
|
+
await importCredential(cfg, text, { controlUrl: connectUrl, project });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
print({ ok: true, sshTarget, url: controlUrl });
|
|
101
|
+
});
|
|
89
102
|
});
|
|
90
103
|
}
|
|
91
104
|
export function registerControl(program) {
|
|
@@ -99,13 +112,13 @@ export function registerControl(program) {
|
|
|
99
112
|
.command('backup')
|
|
100
113
|
.description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
|
|
101
114
|
.option('--no-images', 'leave the retained build archives out (deploy again to rebuild them)')
|
|
102
|
-
.action(async (o) => print(await request('POST', '/api/v1/control/backups/run', { images: o.images })));
|
|
115
|
+
.action(async (o) => withProgress('Creating control-plane backup', async () => print(await request('POST', '/api/v1/control/backups/run', { images: o.images }))));
|
|
103
116
|
control
|
|
104
117
|
.command('restore <artifactRef>')
|
|
105
118
|
.description('restore a control-plane backup on the control-plane host; requires a control restart')
|
|
106
119
|
.option('--confirm')
|
|
107
|
-
.action(async (artifactRef, o) => print(await request('POST', '/api/v1/control/restore', {
|
|
120
|
+
.action(async (artifactRef, o) => withProgress('Restoring control-plane backup', async () => print(await request('POST', '/api/v1/control/restore', {
|
|
108
121
|
artifactRef,
|
|
109
122
|
confirm: Boolean(o.confirm),
|
|
110
|
-
})));
|
|
123
|
+
}))));
|
|
111
124
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { selectMany } from "../cli-selection.js";
|
|
2
|
+
import { selectResource } from "./selection.js";
|
|
1
3
|
import { Option } from 'commander';
|
|
2
4
|
import { ROLES } from "../credentials.js";
|
|
3
5
|
import { print, request } from "./context.js";
|
|
@@ -14,8 +16,25 @@ export function registerCredential(program) {
|
|
|
14
16
|
.addOption(new Option('--project <name>', 'limit a read or deploy credential to this project; repeat for several')
|
|
15
17
|
.default([], 'all projects')
|
|
16
18
|
.argParser((value, previous) => [...previous, value]))
|
|
19
|
+
.option('--select-projects', 'choose existing projects for the credential scope')
|
|
17
20
|
.option('--expires-in-days <n>', 'expire the credential after this many days (default and maximum: 365)')
|
|
18
21
|
.action(async (name, o) => {
|
|
22
|
+
if (o.selectProjects) {
|
|
23
|
+
if (o.role === 'admin' || o.project.length)
|
|
24
|
+
throw new Error('--select-projects requires read/deploy role and no --project');
|
|
25
|
+
o.project = await selectMany('Select projects (choose all projects alone for unrestricted access)', async () => {
|
|
26
|
+
const { projects } = await request('GET', '/api/v1/projects');
|
|
27
|
+
return [
|
|
28
|
+
{ value: '*', label: 'All projects, including future projects' },
|
|
29
|
+
...projects.map((project) => ({ value: project.name, label: project.name })),
|
|
30
|
+
];
|
|
31
|
+
});
|
|
32
|
+
if (o.project.includes('*')) {
|
|
33
|
+
if (o.project.length !== 1)
|
|
34
|
+
throw new Error('all projects must be selected alone');
|
|
35
|
+
o.project = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
19
38
|
const created = await request('POST', '/api/v1/credentials', {
|
|
20
39
|
name,
|
|
21
40
|
role: o.role,
|
|
@@ -30,7 +49,7 @@ export function registerCredential(program) {
|
|
|
30
49
|
.description('list operator credentials with role, projects, expiry and last use; secrets are never shown')
|
|
31
50
|
.action(async () => print(await request('GET', '/api/v1/credentials')));
|
|
32
51
|
credential
|
|
33
|
-
.command('revoke
|
|
52
|
+
.command('revoke [name]')
|
|
34
53
|
.description('revoke an operator credential immediately; its name stays reserved for the audit trail')
|
|
35
|
-
.action(async (name) => print(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(name)}`)));
|
|
54
|
+
.action(async (name) => print(await request('DELETE', `/api/v1/credentials/${encodeURIComponent(await selectResource(name, 'credentials'))}`)));
|
|
36
55
|
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { selectDeployment, selectService } from "./selection.js";
|
|
1
2
|
import { existsSync, openAsBlob } from 'node:fs';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { createLocalBuildSnapshot, createSourceArchive, prepareLocalImages } from "../cli-build.js";
|
|
7
|
+
import { createProgress, withProgress } from "../cli-progress.js";
|
|
6
8
|
import { canPrompt } from "../cli-prompt.js";
|
|
7
9
|
import { findMissingSecrets, promptForSecrets } from "../cli-secrets.js";
|
|
8
10
|
import { composeHash } from "../compose.js";
|
|
@@ -46,77 +48,92 @@ export function registerDeploy(program) {
|
|
|
46
48
|
.option('--json', 'print the full result, including the desired state, as JSON')
|
|
47
49
|
.option('--move <service>', 'acknowledge that a service placed on another node starts empty (its data does not move); the services that write to it are held until you restore it or run `tug hold release` (repeatable)', collect, [])
|
|
48
50
|
.action(async (o) => {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
const progress = createProgress();
|
|
52
|
+
try {
|
|
53
|
+
progress.stage('Preparing deployment');
|
|
54
|
+
const cfg = await loadCfg();
|
|
55
|
+
const project = o.project || cfg.project || 'site';
|
|
56
|
+
// Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
|
|
57
|
+
const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
|
|
58
|
+
const secretNames = secrets.map((secret) => secret.name);
|
|
59
|
+
const missing = await findMissingSecrets(o.file, secretNames);
|
|
60
|
+
if (missing.length) {
|
|
61
|
+
if (!canPrompt())
|
|
62
|
+
throw new Error(`${o.file} needs values that are not set: ${missing.join(', ')}; create each with tug secret set <name> (or run tug deploy in a terminal to be prompted)`);
|
|
63
|
+
console.error(`Missing secrets for project ${project}: ${missing.join(', ')}`);
|
|
64
|
+
// Only names that do not exist yet are stored, so an existing secret is never overwritten.
|
|
65
|
+
for (const [name, value] of Object.entries(await promptForSecrets(missing))) {
|
|
66
|
+
await request('POST', '/api/v1/secrets', { project, name, value });
|
|
67
|
+
secretNames.push(name);
|
|
68
|
+
}
|
|
63
69
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
70
|
+
const movePayload = o.move.length ? { moves: o.move } : {};
|
|
71
|
+
const declared = await composeName(o.file);
|
|
72
|
+
if (declared && declared !== project)
|
|
73
|
+
console.error(`warning: ${o.file} is named ${declared} but the Tug project is ${project}; secrets, backups and restores are scoped to the Tug project (set project: ${declared} in tug.yml, or pass --project)`);
|
|
74
|
+
const compose = await normalizeCompose(o.file, secretNames, { project });
|
|
75
|
+
const version = o.version || `git-${composeHash(compose)}`;
|
|
76
|
+
let response;
|
|
77
|
+
if (o.buildLocal) {
|
|
78
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
|
|
79
|
+
try {
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
progress.stage('Preparing local build snapshot');
|
|
82
|
+
const source = Object.values(compose.services).some((service) => service.build)
|
|
83
|
+
? await createLocalBuildSnapshot(process.cwd(), directory, [
|
|
84
|
+
cfg.file,
|
|
85
|
+
cfg.localFile,
|
|
86
|
+
...(cfg.credentialFile ? [cfg.credentialFile] : []),
|
|
87
|
+
])
|
|
88
|
+
: process.cwd();
|
|
89
|
+
const localImages = await prepareLocalImages(compose, source, directory, undefined, progress.stage);
|
|
90
|
+
for (const { field } of localImages)
|
|
91
|
+
form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
|
|
92
|
+
form.append('payload', JSON.stringify({ project, version, compose, localImages, ...movePayload }));
|
|
93
|
+
progress.stage('Uploading images and preparing deployment on control');
|
|
94
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
98
|
+
}
|
|
88
99
|
}
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
else if (Object.values(compose.services).some((service) => service.build)) {
|
|
101
|
+
progress.stage('Archiving source for remote build');
|
|
102
|
+
const tarball = await makeContext();
|
|
103
|
+
try {
|
|
104
|
+
const form = new FormData();
|
|
105
|
+
form.append('payload', JSON.stringify({ project, version, compose, ...movePayload }));
|
|
106
|
+
form.append('context', await openAsBlob(tarball), 'context.tgz');
|
|
107
|
+
progress.stage('Uploading source and building images on control');
|
|
108
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await fs.rm(tarball, { force: true });
|
|
112
|
+
}
|
|
91
113
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
else {
|
|
115
|
+
progress.stage('Resolving images and preparing deployment on control');
|
|
116
|
+
response = await request('POST', '/api/v1/deployments', {
|
|
117
|
+
project,
|
|
118
|
+
version,
|
|
119
|
+
compose,
|
|
120
|
+
...movePayload,
|
|
121
|
+
});
|
|
100
122
|
}
|
|
101
|
-
|
|
102
|
-
|
|
123
|
+
if (o.wait) {
|
|
124
|
+
progress.stage(`Deployment ${response.deployment.id}: ${response.deployment.state}`);
|
|
125
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout), (deployment) => progress.stage(`Deployment ${deployment.id}: ${deployment.state}`));
|
|
103
126
|
}
|
|
127
|
+
printResult(o.json, response, formatDeployment);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
progress.stop();
|
|
104
131
|
}
|
|
105
|
-
else
|
|
106
|
-
response = await request('POST', '/api/v1/deployments', {
|
|
107
|
-
project,
|
|
108
|
-
version,
|
|
109
|
-
compose,
|
|
110
|
-
...movePayload,
|
|
111
|
-
});
|
|
112
|
-
if (o.wait)
|
|
113
|
-
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
114
|
-
printResult(o.json, response, formatDeployment);
|
|
115
132
|
});
|
|
116
133
|
deploy
|
|
117
|
-
.command('cancel
|
|
134
|
+
.command('cancel [id]')
|
|
118
135
|
.description('cancel a deployment that has not finished')
|
|
119
|
-
.action(async (id) => print(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {})));
|
|
136
|
+
.action(async (id) => print(await request('POST', `/api/v1/deployments/${encodeURIComponent(await selectDeployment(id))}/cancel`, {})));
|
|
120
137
|
program
|
|
121
138
|
.command('rollback')
|
|
122
139
|
.description('activate the previous successful deployment')
|
|
@@ -125,31 +142,41 @@ export function registerDeploy(program) {
|
|
|
125
142
|
.option('--timeout <seconds>', 'wait timeout', '300')
|
|
126
143
|
.option('--json', 'print the full result, including the desired state, as JSON')
|
|
127
144
|
.action(async (o) => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
145
|
+
return withProgress('Preparing rollback on control', async (progress) => {
|
|
146
|
+
const response = await request('POST', '/api/v1/rollback', {
|
|
147
|
+
project: await projectOf(o),
|
|
148
|
+
force: Boolean(o.force),
|
|
149
|
+
});
|
|
150
|
+
if (o.wait) {
|
|
151
|
+
progress.stage(`Deployment ${response.deployment.id}: ${response.deployment.state}`);
|
|
152
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout), (deployment) => progress.stage(`Deployment ${deployment.id}: ${deployment.state}`));
|
|
153
|
+
}
|
|
154
|
+
printResult(o.json, response, formatDeployment);
|
|
131
155
|
});
|
|
132
|
-
if (o.wait)
|
|
133
|
-
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
134
|
-
printResult(o.json, response, formatDeployment);
|
|
135
156
|
});
|
|
136
157
|
program
|
|
137
|
-
.command('scale
|
|
158
|
+
.command('scale [service] [replicas]')
|
|
159
|
+
.option('--replicas <n>', 'replica count when choosing a service interactively')
|
|
138
160
|
.description('scale a service to N replicas across matching nodes')
|
|
139
161
|
.option('--json', 'print the full result as JSON')
|
|
140
162
|
.action(async (service, replicas, o) => {
|
|
163
|
+
if (o.replicas && replicas)
|
|
164
|
+
throw new Error('pass replicas positionally or with --replicas, not both');
|
|
165
|
+
replicas = replicas || o.replicas || '';
|
|
141
166
|
const project = await projectOf(o);
|
|
142
167
|
if (!/^[1-9]\d*$/.test(replicas))
|
|
143
168
|
throw new Error('replicas must be a positive integer');
|
|
169
|
+
service = await selectService(service);
|
|
144
170
|
const result = await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) });
|
|
145
171
|
printResult(o.json, result, (r) => `${r.service} scaled to ${r.replicas} replica${r.replicas === 1 ? '' : 's'} on ${r.nodes.join(', ')}`);
|
|
146
172
|
});
|
|
147
173
|
program
|
|
148
|
-
.command('restart
|
|
174
|
+
.command('restart [service]')
|
|
149
175
|
.description('restart a service on every node that runs it')
|
|
150
176
|
.option('--timeout <seconds>', 'wait timeout', '60')
|
|
151
177
|
.action(async (svc, o) => {
|
|
152
178
|
const project = await projectOf(o);
|
|
179
|
+
svc = await selectService(svc);
|
|
153
180
|
const created = await request('POST', '/api/v1/restart', { project, service: svc });
|
|
154
181
|
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
155
182
|
if (cmd.state === 'failed')
|
package/dist/commands/hold.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectHolds } from "./selection.js";
|
|
1
2
|
import { print, projectOf, request } from "./context.js";
|
|
2
3
|
export function registerHold(program) {
|
|
3
4
|
const hold = program
|
|
@@ -6,8 +7,15 @@ export function registerHold(program) {
|
|
|
6
7
|
hold
|
|
7
8
|
.command('release [services...]')
|
|
8
9
|
.description('start the services that `tug deploy --move` held, without restoring (all of them when none is named); a restore releases its own holds')
|
|
9
|
-
.
|
|
10
|
-
|
|
11
|
-
services
|
|
12
|
-
|
|
10
|
+
.option('--select', 'choose one or more move holds instead of releasing all')
|
|
11
|
+
.action(async (services, o) => {
|
|
12
|
+
if (o.select && services.length)
|
|
13
|
+
throw new Error('--select cannot be combined with explicit services');
|
|
14
|
+
if (o.select)
|
|
15
|
+
services = await selectHolds();
|
|
16
|
+
return print(await request('POST', '/api/v1/holds/release', {
|
|
17
|
+
project: await projectOf(o),
|
|
18
|
+
services,
|
|
19
|
+
}));
|
|
20
|
+
});
|
|
13
21
|
}
|
package/dist/commands/host.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export declare function preflightRemote(target: string, { controlUrl, ports, min
|
|
|
5
5
|
minDiskMb?: number;
|
|
6
6
|
/** Requires the Compose plugin; only the control host runs Compose, nodes need the Docker Engine alone. */
|
|
7
7
|
compose?: boolean;
|
|
8
|
-
}): void
|
|
9
|
-
export declare function transferTugImage(target: string, imageTag: string): Promise<void>;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
export declare function transferTugImage(target: string, imageTag: string, stage: (label: string) => void): Promise<void>;
|
|
10
10
|
export declare function dockerBootstrap(): Promise<string>;
|
|
11
11
|
/** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
|
|
12
12
|
export declare function bootstrapDocker(target: string, compose?: boolean): Promise<void>;
|
package/dist/commands/host.js
CHANGED
|
@@ -6,7 +6,7 @@ import { saveImageArchive } from "../artifacts.js";
|
|
|
6
6
|
import { controlPortCheck, localImageBuildArgs, shellQuote as sh } from "../cli-install.js";
|
|
7
7
|
import { LIST_ADDRESSES_COMMAND, parseAddressCandidates, sortCandidates, } from "../cli-onboarding.js";
|
|
8
8
|
import { runCapture, runLocal, templatesDir } from "./context.js";
|
|
9
|
-
export function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
|
|
9
|
+
export async function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
|
|
10
10
|
const checks = [
|
|
11
11
|
[
|
|
12
12
|
'docker.version',
|
|
@@ -50,18 +50,23 @@ export function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 10
|
|
|
50
50
|
const script = `set -e
|
|
51
51
|
${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
|
|
52
52
|
`;
|
|
53
|
-
runLocal('ssh', ['--', target, script]);
|
|
53
|
+
await runLocal('ssh', ['--', target, script]);
|
|
54
54
|
}
|
|
55
|
-
export async function transferTugImage(target, imageTag) {
|
|
55
|
+
export async function transferTugImage(target, imageTag, stage) {
|
|
56
|
+
stage(`Checking architecture on ${target}`);
|
|
56
57
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-runtime-'));
|
|
57
58
|
const archive = path.join(directory, 'image.tar.gz');
|
|
58
59
|
const remote = `/tmp/${path.basename(directory)}.tar.gz`;
|
|
59
60
|
try {
|
|
60
|
-
const uname = runCapture('ssh', ['--', target, 'uname -m']);
|
|
61
|
-
|
|
61
|
+
const uname = await runCapture('ssh', ['--', target, 'uname -m']);
|
|
62
|
+
stage(`Building Tug image ${imageTag}`);
|
|
63
|
+
await runLocal('docker', localImageBuildArgs(imageTag, uname));
|
|
64
|
+
stage(`Exporting Tug image ${imageTag}`);
|
|
62
65
|
await saveImageArchive(imageTag, archive);
|
|
63
|
-
|
|
64
|
-
runLocal('
|
|
66
|
+
stage(`Transferring Tug image to ${target}`);
|
|
67
|
+
await runLocal('scp', ['--', archive, `${target}:${remote}`]);
|
|
68
|
+
stage(`Loading Tug image on ${target}`);
|
|
69
|
+
await runLocal('ssh', [
|
|
65
70
|
'--',
|
|
66
71
|
target,
|
|
67
72
|
`set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`,
|
|
@@ -76,7 +81,7 @@ export async function dockerBootstrap() {
|
|
|
76
81
|
}
|
|
77
82
|
/** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
|
|
78
83
|
export async function bootstrapDocker(target, compose = false) {
|
|
79
|
-
runLocal('ssh', [
|
|
84
|
+
await runLocal('ssh', [
|
|
80
85
|
'--',
|
|
81
86
|
target,
|
|
82
87
|
`${compose ? 'TUG_INSTALL_COMPOSE=1 ' : ''}sh -eu -c ${sh(await dockerBootstrap())}`,
|