@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.
Files changed (68) hide show
  1. package/README.md +7 -3
  2. package/dist/backup-restore.d.ts +1 -1
  3. package/dist/backup-restore.js +3 -1
  4. package/dist/backup-scheduler.js +3 -1
  5. package/dist/cli-build.d.ts +1 -1
  6. package/dist/cli-build.js +4 -1
  7. package/dist/cli-process.d.ts +13 -0
  8. package/dist/cli-process.js +46 -0
  9. package/dist/cli-progress.d.ts +11 -0
  10. package/dist/cli-progress.js +37 -0
  11. package/dist/cli-prompt.js +5 -4
  12. package/dist/cli-selection.d.ts +13 -0
  13. package/dist/cli-selection.js +39 -0
  14. package/dist/commands/backup.js +48 -31
  15. package/dist/commands/context.d.ts +2 -5
  16. package/dist/commands/context.js +50 -28
  17. package/dist/commands/control.js +55 -42
  18. package/dist/commands/credential.js +21 -2
  19. package/dist/commands/deploy.js +97 -70
  20. package/dist/commands/hold.js +12 -4
  21. package/dist/commands/host.d.ts +2 -2
  22. package/dist/commands/host.js +13 -8
  23. package/dist/commands/node.d.ts +1 -0
  24. package/dist/commands/node.js +103 -71
  25. package/dist/commands/observe.js +7 -2
  26. package/dist/commands/restore.js +71 -50
  27. package/dist/commands/secret.js +8 -3
  28. package/dist/commands/selection.d.ts +22 -0
  29. package/dist/commands/selection.js +90 -0
  30. package/dist/commands/service.js +17 -6
  31. package/dist/commands/volume.js +3 -2
  32. package/dist/control.js +95 -47
  33. package/dist/credentials.js +5 -0
  34. package/dist/db-backup-destination.d.ts +14 -0
  35. package/dist/db-backup-destination.js +28 -0
  36. package/dist/db-locks.d.ts +10 -0
  37. package/dist/db-locks.js +23 -0
  38. package/dist/db-transaction.d.ts +3 -0
  39. package/dist/db-transaction.js +24 -0
  40. package/dist/image.d.ts +9 -0
  41. package/dist/image.js +19 -0
  42. package/dist/util.d.ts +2 -0
  43. package/dist/util.js +6 -0
  44. package/docs/access-control.md +166 -0
  45. package/docs/architecture.md +254 -0
  46. package/docs/backups.md +160 -0
  47. package/docs/cli.md +182 -0
  48. package/docs/compose.md +245 -0
  49. package/docs/configuration.md +52 -0
  50. package/docs/control-plane.md +112 -0
  51. package/docs/development.md +140 -0
  52. package/docs/getting-started.md +75 -0
  53. package/docs/healthchecks.md +51 -0
  54. package/docs/migration.md +113 -0
  55. package/docs/networking.md +150 -0
  56. package/docs/nodes.md +143 -0
  57. package/docs/observability.md +213 -0
  58. package/docs/real-host-evaluation.md +224 -0
  59. package/docs/releases.md +39 -0
  60. package/docs/roadmap.md +234 -0
  61. package/docs/security.md +198 -0
  62. package/docs/service-placement.md +72 -0
  63. package/docs/standalone-binary.md +79 -0
  64. package/docs/swarm-comparison.md +197 -0
  65. package/docs/terminology.md +74 -0
  66. package/docs/testing.md +254 -0
  67. package/docs/troubleshooting.md +33 -0
  68. package/package.json +6 -3
@@ -1,7 +1,8 @@
1
- import { spawnSync } from 'node:child_process';
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
- if (o.installDocker)
51
- await bootstrapDocker(sshTarget, true);
52
- preflightRemote(sshTarget, { ports: [Number(o.port)], minDiskMb: 2048, compose: true });
53
- if (o.buildLocalImage)
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
- if (o.import) {
84
- const { controlUrl: connectUrl, project } = command.optsWithGlobals();
85
- await importCredential(cfg, text, { controlUrl: connectUrl, project });
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
- print({ ok: true, sshTarget, url: controlUrl });
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 <name>')
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
  }
@@ -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 cfg = await loadCfg();
50
- const project = o.project || cfg.project || 'site';
51
- // Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
52
- const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
53
- const secretNames = secrets.map((secret) => secret.name);
54
- const missing = await findMissingSecrets(o.file, secretNames);
55
- if (missing.length) {
56
- if (!canPrompt())
57
- 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)`);
58
- console.error(`Missing secrets for project ${project}: ${missing.join(', ')}`);
59
- // Only names that do not exist yet are stored, so an existing secret is never overwritten.
60
- for (const [name, value] of Object.entries(await promptForSecrets(missing))) {
61
- await request('POST', '/api/v1/secrets', { project, name, value });
62
- secretNames.push(name);
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
- const movePayload = o.move.length ? { moves: o.move } : {};
66
- const declared = await composeName(o.file);
67
- if (declared && declared !== project)
68
- 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)`);
69
- const compose = await normalizeCompose(o.file, secretNames, { project });
70
- const version = o.version || `git-${composeHash(compose)}`;
71
- let response;
72
- if (o.buildLocal) {
73
- const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
74
- try {
75
- const form = new FormData();
76
- const source = Object.values(compose.services).some((service) => service.build)
77
- ? await createLocalBuildSnapshot(process.cwd(), directory, [
78
- cfg.file,
79
- cfg.localFile,
80
- ...(cfg.credentialFile ? [cfg.credentialFile] : []),
81
- ])
82
- : process.cwd();
83
- const localImages = await prepareLocalImages(compose, source, directory);
84
- for (const { field } of localImages)
85
- form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
86
- form.append('payload', JSON.stringify({ project, version, compose, localImages, ...movePayload }));
87
- response = await request('POST', '/api/v1/deployments', form, true);
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
- finally {
90
- await fs.rm(directory, { recursive: true, force: true });
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
- else if (Object.values(compose.services).some((service) => service.build)) {
94
- const tarball = await makeContext();
95
- try {
96
- const form = new FormData();
97
- form.append('payload', JSON.stringify({ project, version, compose, ...movePayload }));
98
- form.append('context', await openAsBlob(tarball), 'context.tgz');
99
- response = await request('POST', '/api/v1/deployments', form, true);
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
- finally {
102
- await fs.rm(tarball, { force: true });
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 <id>')
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
- const response = await request('POST', '/api/v1/rollback', {
129
- project: await projectOf(o),
130
- force: Boolean(o.force),
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 <service> <replicas>')
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 <service>')
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')
@@ -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
- .action(async (services, o) => print(await request('POST', '/api/v1/holds/release', {
10
- project: await projectOf(o),
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
  }
@@ -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>;
@@ -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
- runLocal('docker', localImageBuildArgs(imageTag, uname));
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
- runLocal('scp', ['--', archive, `${target}:${remote}`]);
64
- runLocal('ssh', [
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())}`,
@@ -9,6 +9,7 @@ export interface NodeIdentityOpts extends InstallOpts {
9
9
  label: string[];
10
10
  agentControlUrl?: string;
11
11
  all?: boolean;
12
+ select?: boolean;
12
13
  wait: boolean;
13
14
  ingress?: boolean;
14
15
  yes?: boolean;