@wpelevator/tug 0.10.0 → 0.11.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 (53) hide show
  1. package/README.md +93 -34
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.js +1 -1
  4. package/dist/backup-check.d.ts +1 -1
  5. package/dist/backup-config.js +32 -24
  6. package/dist/backup-destination.d.ts +1 -1
  7. package/dist/backup-destination.js +1 -1
  8. package/dist/backup-restore.js +65 -65
  9. package/dist/backup-scheduler.js +1 -1
  10. package/dist/cli-build.js +1 -1
  11. package/dist/cli-config.d.ts +6 -1
  12. package/dist/cli-config.js +18 -44
  13. package/dist/cli-install.d.ts +4 -2
  14. package/dist/cli-install.js +12 -7
  15. package/dist/cli-live.js +4 -6
  16. package/dist/cli-onboarding.d.ts +31 -0
  17. package/dist/cli-onboarding.js +66 -0
  18. package/dist/cli-prompt.d.ts +9 -0
  19. package/dist/cli-prompt.js +60 -0
  20. package/dist/cli-secrets.d.ts +12 -0
  21. package/dist/cli-secrets.js +71 -0
  22. package/dist/cli-status.d.ts +11 -0
  23. package/dist/cli-status.js +37 -7
  24. package/dist/cli.js +62 -981
  25. package/dist/commands/backup.d.ts +2 -0
  26. package/dist/commands/backup.js +109 -0
  27. package/dist/commands/context.d.ts +54 -0
  28. package/dist/commands/context.js +96 -0
  29. package/dist/commands/control.d.ts +2 -0
  30. package/dist/commands/control.js +110 -0
  31. package/dist/commands/credential.d.ts +2 -0
  32. package/dist/commands/credential.js +36 -0
  33. package/dist/commands/deploy.d.ts +2 -0
  34. package/dist/commands/deploy.js +139 -0
  35. package/dist/commands/host.d.ts +18 -0
  36. package/dist/commands/host.js +99 -0
  37. package/dist/commands/init.d.ts +11 -0
  38. package/dist/commands/init.js +40 -0
  39. package/dist/commands/node.d.ts +28 -0
  40. package/dist/commands/node.js +320 -0
  41. package/dist/commands/observe.d.ts +2 -0
  42. package/dist/commands/observe.js +71 -0
  43. package/dist/commands/restore.d.ts +2 -0
  44. package/dist/commands/restore.js +85 -0
  45. package/dist/commands/secret.d.ts +2 -0
  46. package/dist/commands/secret.js +26 -0
  47. package/dist/commands/service.d.ts +2 -0
  48. package/dist/commands/service.js +62 -0
  49. package/dist/commands/volume.d.ts +2 -0
  50. package/dist/commands/volume.js +13 -0
  51. package/dist/control.js +6 -6
  52. package/dist/diagnostics.js +1 -1
  53. package/package.json +1 -1
package/README.md CHANGED
@@ -1,26 +1,99 @@
1
1
  # Tug
2
2
 
3
- The name: tugboats pull ships into place, like the pull-based agents here; `tug` is the CLI.
3
+ Deploy Docker Compose apps to a few ordinary Linux servers, from your laptop or CI, without Kubernetes or a hosted platform.
4
4
 
5
- Deploy Docker Compose applications to one or more ordinary Linux VPSes through a small self-hosted control plane and a pull-based agent on each node, without Kubernetes or a hosted PaaS. Tug runs an agent on each node; the agents drive the node's Docker Engine API directly, and Compose is only used on your machine to read the file. What it is for, the deployment shapes it targets and the planned milestones are in the [Roadmap](docs/roadmap.md).
5
+ You keep your `docker-compose.yml`. Tug installs a small control plane on one server and an agent on each of the others, then `tug deploy` puts your services where you say, waits until they are healthy and rolls back if they are not. It is built for running one to a few dozen sites, WordPress first, on two to ten VPSes, with backups and restores included.
6
6
 
7
- ## Features
7
+ ## What you can do with it
8
8
 
9
- - One mutual-TLS port: every operator, CI job and node authenticates with a certificate from the control plane's private CA, and control authorizes it by public key. Scoped `read`/`deploy`/`admin` operator credentials for CI and teammates, an audit trail naming who acted, and SQLite metadata through Node's built-in driver.
10
- - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` after `node-prepare-enrollment`, each with its own certificate and an immutable node ID. Agents only dial out, so nodes behind NAT work.
11
- - Agents pull desired state from control, reconcile containers through the Docker Engine API, run TCP/HTTP/Docker health checks and report status. An outbound WebSocket wakes them as soon as control queues work and carries live `exec`, followed logs, `ps` and `inspect` sessions, with long-poll and periodic-poll fallbacks for ordinary work.
12
- - `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
13
- - Build application images on control from uploaded source, or locally with `deploy --build-local`. Both use a filtered source snapshot that excludes known Tug credentials; operators must still exclude other secrets. Agents download saved images directly from control. Published images are pinned to registry digests at deployment creation.
14
- - Secrets encrypted at rest, released only to the nodes whose services reference them, with targeted service recreation when only a secret changes.
15
- - Deployment convergence, per-project operation locks, cancellation, rollback, per-service rollout events and log fetching through the agents.
16
- - Opt-in zero-downtime rolling rollouts for ingress-fronted services (`x-tug.rollout`) and automatic rollback of failed deployments (`x-tug.rollback: auto`). See [Compose extensions](docs/compose.md#rolling-rollouts).
17
- - Private published images use encrypted project credentials for digest resolution and Engine API pulls.
18
- - Health-aware cross-node service names (hosts-file entries injected into containers), on by default with a `TUG_SERVICE_NAMES=false` opt-out, and an optional managed Caddy ingress with automatic HTTPS. Both use each node's private `address` on your existing network. See [Networking](docs/networking.md).
19
- - Private-by-default published ports: agent-generated container definitions bind them to the node address or loopback, never every interface unless you write `0.0.0.0`. Tug does not encrypt node-to-node workload traffic; your network owns reachability, firewalling and encryption. See [Networking](docs/networking.md#published-ports-private-by-default).
20
- - Workload backups of service commands and volumes with restic, a separate control-plane backup and restore, and `tug doctor` diagnostics for certificate expiry, node connectivity and clock skew.
21
- - `tug status --human` summarizes deployments, node reports and failures; the default JSON output remains available for automation.
9
+ - **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.
10
+ - **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.
11
+ - **Update without downtime.** Opt-in rolling rollouts start the new version, wait until it is healthy and only then switch traffic.
12
+ - **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.
13
+ - **Back up and restore.** Scheduled backups of databases and volumes to S3-compatible storage with restic, and `tug restore run db` to bring one back.
14
+ - **Look inside running services.** `tug logs`, `tug exec`, `tug run` (for example `wp` CLI), `tug ps` and `tug status` work through the control plane, so you never SSH to a node to see what is going on.
15
+ - **Give CI a limited key.** `tug credential create` makes read-only, deploy-only or admin credentials, and every change records who made it.
22
16
 
23
- ## How it fits together
17
+ Nodes only dial out to the control plane, so they work behind NAT or a firewall that allows nothing inbound. Tug does not need a cloud provider's API, a registry or an overlay network.
18
+
19
+ ## Try it
20
+
21
+ You need Node.js 22.18 or newer and Docker Compose v2 on your machine, and SSH access to one or more Linux servers (Debian, Ubuntu, Fedora or a RHEL-compatible distribution). Tug can install Docker on them for you.
22
+
23
+ ```bash
24
+ npm install -g @wpelevator/tug # or run it with npx @wpelevator/tug
25
+
26
+ tug setup root@203.0.113.5 # install the control plane on a server
27
+ tug node add root@198.51.100.7 # add a server that will run your apps
28
+ tug deploy # deploy ./docker-compose.yml
29
+ ```
30
+
31
+ 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`).
32
+
33
+ ## Your Compose file, plus a little `x-tug`
34
+
35
+ A normal Compose file already works. Add `x-tug` to say where services run, how to tell they are healthy and where traffic comes in:
36
+
37
+ ```yaml
38
+ services:
39
+ db:
40
+ image: mariadb:11.4.8
41
+ volumes: [db-data:/var/lib/mysql]
42
+ environment:
43
+ MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
44
+ x-tug:
45
+ placement: { nodes: [app1] }
46
+ health: { type: tcp, port: 3306 }
47
+
48
+ wordpress:
49
+ image: wordpress:6.8.3-apache
50
+ depends_on: [db]
51
+ x-tug:
52
+ placement: { nodes: [app1] }
53
+ health: { type: http, port: 80, path: /wp-includes/version.php }
54
+ ingress: { host: blog.example.com, port: 80, tls: auto }
55
+
56
+ volumes:
57
+ db-data:
58
+ x-tug: { placement: { nodes: [app1] } }
59
+ ```
60
+
61
+ ```bash
62
+ tug secret set DB_ROOT_PASSWORD
63
+ tug deploy
64
+ tug status # what is running where, and anything failing
65
+ tug logs wordpress --follow
66
+ ```
67
+
68
+ A complete WordPress site with scheduled database and uploads backups is in the [WordPress example](examples/wordpress/README.md). Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
69
+
70
+ ## Everyday commands
71
+
72
+ Project commands sit at the top level; everything else reads `tug <subject> <verb>`.
73
+
74
+ | I want to... | Run |
75
+ | --- | --- |
76
+ | Deploy, or undo a deploy | `tug deploy`, `tug rollback` |
77
+ | See what is running and what failed | `tug status`, `tug events`, `tug ps` |
78
+ | Read logs or open a shell | `tug logs <service>`, `tug exec <service> sh` |
79
+ | Run a one-off command | `tug run <service> -- wp cache flush` |
80
+ | Change the number of replicas | `tug scale <service> 3` |
81
+ | Add, list or replace servers | `tug node add`, `tug node list`, `tug node replace <name>` |
82
+ | Add a server I cannot SSH to | `tug node enroll`, then run the printed `docker run` on it |
83
+ | Manage secrets | `tug secret set`, `tug secret list`, `tug secret remove` |
84
+ | Back up now, or restore | `tug backup run`, `tug restore run <target>` |
85
+ | Give CI a deploy key | `tug credential create ci --role deploy` |
86
+ | Check everything is healthy | `tug doctor` |
87
+
88
+ Every command and option is in the [CLI reference](docs/cli.md). `tug status --json` prints raw JSON for scripts.
89
+
90
+ ## Is Tug right for you?
91
+
92
+ Tug fits if you run a handful of Compose apps on VPSes you already pay for, want deploys, rollbacks, secrets and backups handled in one tool, and are happy with a single control server that you back up.
93
+
94
+ It deliberately does not do Kubernetes-style scheduling, autoscaling, distributed storage or database failover, and it does not provision servers through a provider's API. Node-to-node traffic travels over your own private network (Tailscale, a VPC, a LAN), so you own its firewalling and encryption. If you want automatic rescheduling of containers across a cluster, see how it compares with [Docker Swarm](docs/swarm-comparison.md). The goals, principles and planned milestones are in the [Roadmap](docs/roadmap.md).
95
+
96
+ ## How it works
24
97
 
25
98
  ```text
26
99
  tug CLI / CI (operator credential)
@@ -51,25 +124,11 @@ Deploy Docker Compose applications to one or more ordinary Linux VPSes through a
51
124
  SSH is used only by the CLI to install the control plane and nodes.
52
125
  ```
53
126
 
54
- Operators and CI talk to the control API. The control plane stores desired state and builds images; each agent dials out to fetch the work for its node, applies it through the Docker Engine API and reports health back, so the control channel works through NAT without inbound ports. SSH is used only to install the control plane and its nodes. Cross-node service names and Caddy ingress use each node's `address` over the operator's private network; Tug provides no overlay or encryption for that workload traffic. See [Networking](docs/networking.md), and [Architecture](docs/architecture.md) for every flow in detail.
55
-
56
- ## Quick start
57
-
58
- Install the CLI (Node >= 22.18), or run it without installing through `npx @wpelevator/tug`:
59
-
60
- ```bash
61
- npm install -g @wpelevator/tug
62
- tug control-install root@203.0.113.5 --install-docker
63
- tug init tug-root.credential.json
64
- tug node-install root@198.51.100.7 --name app1 --address 10.0.0.7 --install-docker
65
- tug deploy -f docker-compose.yml
66
- ```
67
-
68
- The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your hosts. Control serves mutual TLS directly on its port (`8443`): every operator, CI job and node authenticates with a certificate from a private CA created at install, and clients verify a name generated from that CA, so the address can be a bare IP and can change later. Do not terminate TLS in front of it.
127
+ `tug deploy` reads your Compose file on your machine and sends it to the control plane, which stores it as an immutable desired state, builds any images and pins published images to digests. Each agent fetches the work for its node, applies it through the Docker Engine API, runs the health checks and reports back. The control plane marks the deployment done when every node reports it serving. Compose runs only on your machine; nodes need just Docker.
69
128
 
70
- A `controlUrl` in `tug.yml` is the one name for where control is: `control-install` advertises it, and every other command connects to it. Pass `--control-url` (or set `TUG_CONTROL_URL`) to override it for one invocation. See [CLI configuration](docs/cli.md#config-file) for precedence and per-node defaults.
129
+ Control serves mutual TLS directly on one port (`8443`). Every operator, CI job and node authenticates with a certificate from a private CA created at install, so the control address can be a bare IP and can change later. Do not terminate TLS in front of it. A `controlUrl` in `tug.yml` tells the CLI where control is (see [CLI configuration](docs/cli.md#config-file)). [Architecture](docs/architecture.md) covers every flow in detail, and [Security](docs/security.md) the trust boundaries.
71
130
 
72
- The full walkthrough, including nodes you cannot SSH to (`tug node-prepare-enrollment`), CI credentials (`tug credential-create`), moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
131
+ The full walkthrough, including CI credentials, moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
73
132
 
74
133
  ## Documentation
75
134
 
@@ -5,7 +5,7 @@ import { Agent, fetch as undiciFetch } from 'undici';
5
5
  import { clientTlsOptions, createCsr, generateKeyPair, identityUri, parseCredential, validateInstalledCertificate, writeFileDurable, } from "./pki.js";
6
6
  export const agentFile = (stateDir) => path.join(stateDir, 'agent.json');
7
7
  export const pendingFile = (stateDir) => path.join(stateDir, 'agent-pending.json');
8
- const NO_AGENT_IDENTITY = 'this node has no identity: run `tug node-install`, `tug node-prepare-enrollment` or `tug node-repair-identity` from an operator machine';
8
+ const NO_AGENT_IDENTITY = 'this node has no identity: run `tug node add`, `tug node enroll` or `tug node repair` from an operator machine';
9
9
  async function readJson(file) {
10
10
  let text;
11
11
  try {
@@ -20,7 +20,7 @@ async function readJson(file) {
20
20
  return JSON.parse(text);
21
21
  }
22
22
  catch (e) {
23
- throw new Error(`${file} is corrupt; run \`tug node-repair-identity\` to replace this node's identity`, {
23
+ throw new Error(`${file} is corrupt; run \`tug node repair\` to replace this node's identity`, {
24
24
  cause: e,
25
25
  });
26
26
  }
@@ -37,7 +37,7 @@ export async function loadAgentDocument(stateDir) {
37
37
  return { ...credential, nodeId, nodeName };
38
38
  }
39
39
  catch (e) {
40
- throw new Error(`${agentFile(stateDir)} is invalid (${e instanceof Error ? e.message : String(e)}); run \`tug node-repair-identity\``, { cause: e });
40
+ throw new Error(`${agentFile(stateDir)} is invalid (${e instanceof Error ? e.message : String(e)}); run \`tug node repair\``, { cause: e });
41
41
  }
42
42
  }
43
43
  async function pendingKey(stateDir) {
package/dist/agent.js CHANGED
@@ -500,7 +500,7 @@ export function runsInContainer() {
500
500
  export function outsideContainerHint(health, inContainer = runsInContainer()) {
501
501
  if (inContainer || health.type === 'docker')
502
502
  return '';
503
- return '; this agent is not running in a container, so it may be unable to reach container addresses (a host process on macOS cannot); run it with `docker run` as `tug node-install` does';
503
+ return '; this agent is not running in a container, so it may be unable to reach container addresses (a host process on macOS cannot); run it with `docker run` as `tug node add` does';
504
504
  }
505
505
  export async function waitForHealth(ctx, project, container, service, health) {
506
506
  if (!ctx.apply || !health)
@@ -1,4 +1,4 @@
1
- /** The part of `GET /api/v1/backups` that `tug backup-list --check` judges. */
1
+ /** The part of `GET /api/v1/backups` that `tug backup list --check` judges. */
2
2
  export interface BackupCheckInput {
3
3
  destinationConfigured: boolean;
4
4
  targets: {
@@ -184,18 +184,25 @@ export function resolveBackups(compose, project = '') {
184
184
  return null;
185
185
  return { schedule, retention, jitterSeconds, timeoutSec, memory };
186
186
  };
187
- for (const [name, svc] of Object.entries(compose.services)) {
188
- const config = svc['x-tug']?.backup;
189
- if (config === undefined)
190
- continue;
191
- const label = `${name}: x-tug.backup`;
187
+ /** The object form of a backup config; null when it is off, or invalid (the error is reported). */
188
+ const specOf = (label, config) => {
192
189
  const spec = normalizeConfig(config);
193
190
  if (spec === null)
194
- continue;
191
+ return null;
195
192
  if (typeof spec !== 'object' || Array.isArray(spec)) {
196
193
  errors.push(`${label} must be true, false, a cron string or an object`);
197
- continue;
194
+ return null;
198
195
  }
196
+ return spec;
197
+ };
198
+ const serviceTarget = (name, svc) => {
199
+ const config = svc['x-tug']?.backup;
200
+ if (config === undefined)
201
+ return null;
202
+ const label = `${name}: x-tug.backup`;
203
+ const spec = specOf(label, config);
204
+ if (!spec)
205
+ return null;
199
206
  const before = errors.length;
200
207
  const base = common(label, 'service', name, spec);
201
208
  const argv = spec.command === undefined ? null : commandArgv(spec.command);
@@ -216,8 +223,8 @@ export function resolveBackups(compose, project = '') {
216
223
  if (global || replicas > 1 || nodes.length > 1)
217
224
  errors.push(`${label} needs a single instance; ${name} is global, replicated or placed on several nodes`);
218
225
  if (errors.length > before || !base || !argv)
219
- continue;
220
- targets.push({
226
+ return null;
227
+ return {
221
228
  target: `service:${name}`,
222
229
  kind: 'service',
223
230
  name,
@@ -229,20 +236,16 @@ export function resolveBackups(compose, project = '') {
229
236
  verify: spec.verify,
230
237
  writers: spec.writers,
231
238
  node: !dynamic && nodes.length === 1 ? nodes[0] : undefined,
232
- });
233
- }
234
- for (const [name, def] of Object.entries(compose.volumes || {})) {
239
+ };
240
+ };
241
+ const volumeTarget = (name, def) => {
235
242
  const config = isObject(def) ? def['x-tug']?.backup : undefined;
236
243
  if (config === undefined)
237
- continue;
244
+ return null;
238
245
  const label = `volumes.${name}: x-tug.backup`;
239
- const spec = normalizeConfig(config);
240
- if (spec === null)
241
- continue;
242
- if (typeof spec !== 'object' || Array.isArray(spec)) {
243
- errors.push(`${label} must be true, false, a cron string or an object`);
244
- continue;
245
- }
246
+ const spec = specOf(label, config);
247
+ if (!spec)
248
+ return null;
246
249
  const before = errors.length;
247
250
  const base = common(label, 'volume', name, spec);
248
251
  if (spec.exclude !== undefined &&
@@ -260,8 +263,8 @@ export function resolveBackups(compose, project = '') {
260
263
  else if (!placed.length && dynamicPlacement)
261
264
  errors.push(`${label} needs x-tug.placement.nodes on volume ${name}, since its services are placed by label or global`);
262
265
  if (errors.length > before || !base)
263
- continue;
264
- targets.push({
266
+ return null;
267
+ return {
265
268
  target: `volume:${name}`,
266
269
  kind: 'volume',
267
270
  name,
@@ -270,7 +273,12 @@ export function resolveBackups(compose, project = '') {
270
273
  exclude: spec.exclude,
271
274
  writers: spec.writers,
272
275
  node: candidates.length === 1 ? candidates[0] : undefined,
273
- });
274
- }
276
+ };
277
+ };
278
+ const found = [
279
+ ...Object.entries(compose.services).map(([name, svc]) => serviceTarget(name, svc)),
280
+ ...Object.entries(compose.volumes || {}).map(([name, def]) => volumeTarget(name, def)),
281
+ ];
282
+ targets.push(...found.filter((target) => target !== null));
275
283
  return { targets, errors };
276
284
  }
@@ -15,7 +15,7 @@ export declare function projectRepository(destinationUrl: string, project: strin
15
15
  /**
16
16
  * The repository password of a project, derived from the control master key so nothing extra is stored and
17
17
  * a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
18
- * repository, which is why `tug backup-repository-password` exists for offline escrow.
18
+ * repository, which is why `tug backup password` exists for offline escrow.
19
19
  */
20
20
  export declare function repositoryPassword(masterKey: string, project: string): string;
21
21
  /** Removes every known secret value from text that is about to be stored, shown or put in an event. */
@@ -54,7 +54,7 @@ export function projectRepository(destinationUrl, project) {
54
54
  /**
55
55
  * The repository password of a project, derived from the control master key so nothing extra is stored and
56
56
  * a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
57
- * repository, which is why `tug backup-repository-password` exists for offline escrow.
57
+ * repository, which is why `tug backup password` exists for offline escrow.
58
58
  */
59
59
  export function repositoryPassword(masterKey, project) {
60
60
  return createHmac('sha256', masterKey).update(`tug-restic:${project}`).digest('hex');
@@ -197,7 +197,7 @@ export function createRestoreService(deps) {
197
197
  release(restore);
198
198
  move(restore, 'failed', { error });
199
199
  }
200
- event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore-release' : ''}`, { error, held: dataChanged });
200
+ event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore release' : ''}`, { error, held: dataChanged });
201
201
  }
202
202
  function queueRestoreJob(restore) {
203
203
  const queued = backups.queueRestore(restore.project_id, restore.target, {
@@ -210,72 +210,72 @@ export function createRestoreService(deps) {
210
210
  move(restore, 'restoring', { commandId: queued.commandId });
211
211
  db.run('UPDATE restore_runs SET node_id=? WHERE id=?', queued.nodeId, restore.id);
212
212
  }
213
+ function advanceHolding(restore, writers, age) {
214
+ if (!writersReport(restore, writers, 'held')) {
215
+ if (age > HOLD_TIMEOUT_MS)
216
+ fail(restore, 'the writers did not stop in time', false);
217
+ return;
218
+ }
219
+ if (!restore.safety_backup)
220
+ return queueRestoreJob(restore);
221
+ const safety = backups.runNow(restore.project_id, [restore.target], restore.actor ?? 'restore', 'pre-restore');
222
+ if (!safety.runs.length)
223
+ return fail(restore, `the safety backup could not start: ${safety.errors.join('; ')}`, false);
224
+ move(restore, 'safety_backup', { safetyRunId: safety.runs[0].runId });
225
+ }
226
+ function advanceSafetyBackup(restore) {
227
+ const run = db.get('SELECT * FROM backup_runs WHERE id=?', restore.safety_run_id);
228
+ if (!run || run.state === 'queued' || run.state === 'running')
229
+ return;
230
+ if (run.state === 'succeeded')
231
+ return queueRestoreJob(restore);
232
+ fail(restore, `the safety backup ${run.state}${run.error ? ` (${run.error})` : ''}; nothing was changed`, false);
233
+ }
234
+ function advanceRestoring(restore, writers) {
235
+ const command = db.get('SELECT * FROM commands WHERE id=?', restore.command_id);
236
+ if (!command || ['pending', 'running'].includes(command.state))
237
+ return;
238
+ if (command.state !== 'succeeded') {
239
+ const error = JSON.parse(command.result_json ?? '{}').error ??
240
+ `the restore command ${command.state}`;
241
+ return fail(restore, error, restore.mode === 'in-place');
242
+ }
243
+ if (restore.mode === 'in-place')
244
+ release(restore);
245
+ // The data is back; the restore is done when the services that were stopped are serving again.
246
+ if (restore.mode === 'in-place' && writers.length)
247
+ move(restore, 'verifying');
248
+ else
249
+ complete(restore);
250
+ }
251
+ function advanceVerifying(restore, writers, age) {
252
+ if (writersReport(restore, writers, 'serving'))
253
+ return complete(restore);
254
+ if (age > VERIFY_TIMEOUT_MS)
255
+ complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
256
+ }
257
+ function advanceRestore(restore) {
258
+ const age = clock().getTime() -
259
+ new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
260
+ // Keep the project locked while a restore is unfinished, across a control restart.
261
+ if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
262
+ lock(restore);
263
+ const writers = JSON.parse(restore.writers_json);
264
+ switch (restore.state) {
265
+ case 'holding':
266
+ return advanceHolding(restore, writers, age);
267
+ case 'safety_backup':
268
+ return advanceSafetyBackup(restore);
269
+ case 'restoring':
270
+ return advanceRestoring(restore, writers);
271
+ case 'verifying':
272
+ return advanceVerifying(restore, writers, age);
273
+ }
274
+ }
213
275
  /** Advances every restore that is waiting on something; safe to call often. */
214
276
  function advance() {
215
- for (const restore of db.all(`SELECT * FROM restore_runs WHERE state NOT IN ('succeeded','failed','released')`)) {
216
- const age = clock().getTime() -
217
- new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
218
- // Keep the project locked while a restore is unfinished, across a control restart.
219
- if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
220
- lock(restore);
221
- const writers = JSON.parse(restore.writers_json);
222
- switch (restore.state) {
223
- case 'holding': {
224
- if (!writersReport(restore, writers, 'held')) {
225
- if (age > HOLD_TIMEOUT_MS)
226
- fail(restore, 'the writers did not stop in time', false);
227
- break;
228
- }
229
- if (!restore.safety_backup) {
230
- queueRestoreJob(restore);
231
- break;
232
- }
233
- const safety = backups.runNow(restore.project_id, [restore.target], restore.actor ?? 'restore', 'pre-restore');
234
- if (!safety.runs.length)
235
- return fail(restore, `the safety backup could not start: ${safety.errors.join('; ')}`, false);
236
- move(restore, 'safety_backup', { safetyRunId: safety.runs[0].runId });
237
- break;
238
- }
239
- case 'safety_backup': {
240
- const run = db.get('SELECT * FROM backup_runs WHERE id=?', restore.safety_run_id);
241
- if (!run || run.state === 'queued' || run.state === 'running')
242
- break;
243
- if (run.state === 'succeeded')
244
- queueRestoreJob(restore);
245
- else
246
- fail(restore, `the safety backup ${run.state}${run.error ? ` (${run.error})` : ''}; nothing was changed`, false);
247
- break;
248
- }
249
- case 'restoring': {
250
- const command = db.get('SELECT * FROM commands WHERE id=?', restore.command_id);
251
- if (!command || ['pending', 'running'].includes(command.state))
252
- break;
253
- if (command.state === 'succeeded') {
254
- if (restore.mode === 'in-place')
255
- release(restore);
256
- // The data is back; the restore is done when the services that were stopped are serving again.
257
- if (restore.mode === 'in-place' && writers.length)
258
- move(restore, 'verifying');
259
- else
260
- complete(restore);
261
- }
262
- else {
263
- const error = JSON.parse(command.result_json ?? '{}').error ??
264
- `the restore command ${command.state}`;
265
- fail(restore, error, restore.mode === 'in-place');
266
- }
267
- break;
268
- }
269
- case 'verifying': {
270
- if (writersReport(restore, writers, 'serving'))
271
- complete(restore);
272
- else if (age > VERIFY_TIMEOUT_MS)
273
- complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
274
- break;
275
- }
276
- default:
277
- }
278
- }
277
+ for (const restore of db.all(`SELECT * FROM restore_runs WHERE state NOT IN ('succeeded','failed','released')`))
278
+ advanceRestore(restore);
279
279
  }
280
280
  function complete(restore, warning) {
281
281
  move(restore, 'succeeded', warning ? { error: warning } : {});
@@ -189,7 +189,7 @@ export function createBackupService(deps) {
189
189
  actor,
190
190
  });
191
191
  if (runId)
192
- event('warn', 'backup.no_destination', schedule.project_id, `backup of ${schedule.target} skipped: no backup destination is configured; run tug backup-destination-set`, {
192
+ event('warn', 'backup.no_destination', schedule.project_id, `backup of ${schedule.target} skipped: no backup destination is configured; run tug backup destination set`, {
193
193
  runId,
194
194
  target: schedule.target,
195
195
  }, null, actor);
package/dist/cli-build.js CHANGED
@@ -29,7 +29,7 @@ export function sourceArchiveFilter(entry, configPaths, ignores) {
29
29
  return false;
30
30
  return !compileIgnore(ignores)(relative);
31
31
  }
32
- /** Credential documents written by `control-install --credential-out` (default `tug-root.credential.json`). */
32
+ /** Credential documents, such as the copy `control setup --credential-out` writes (conventionally `*.credential.json`). */
33
33
  const CREDENTIAL_FILE = /\.credential\.json$/;
34
34
  const ignoreCache = new WeakMap();
35
35
  /** Translates one `.dockerignore` pattern: `*`, `?` and `[...]` classes stay within a path segment, `**` crosses them. */
@@ -44,13 +44,18 @@ export declare function localConfigPath(file: string): string;
44
44
  export declare function resolveCliConfig(options: ConnectionOptions, env: NodeJS.ProcessEnv, cwd: string, creating?: boolean): Promise<ResolvedCliConfig>;
45
45
  /** `nodeDefaults` under the node's own keys; a list in the node replaces the default list. */
46
46
  export declare function nodeSettings(config: ResolvedCliConfig, name?: string): SettingValues;
47
- interface OptionSource {
47
+ interface Named {
48
48
  name(): string;
49
+ parent?: Named | null;
50
+ }
51
+ interface OptionSource extends Named {
49
52
  options: readonly {
50
53
  attributeName(): string;
51
54
  }[];
52
55
  getOptionValueSource(key: string): string | undefined;
53
56
  }
57
+ /** The space-joined command names from the first subject down, such as `node add`; `setup` is `control setup`. */
58
+ export declare function commandPath(command: Named): string;
54
59
  /**
55
60
  * Use config values as defaults for the options of a command: a key applies to a command that lists it
56
61
  * and has an option of that name, and never over a value given on the command line.
@@ -11,14 +11,7 @@ export const COMMAND_SETTINGS = {
11
11
  wait: {
12
12
  type: 'boolean',
13
13
  flag: '--no-wait',
14
- commands: [
15
- 'deploy',
16
- 'rollback',
17
- 'backup-run',
18
- 'node-install',
19
- 'node-replace-identity',
20
- 'node-repair-identity',
21
- ],
14
+ commands: ['deploy', 'rollback', 'backup run', 'node add', 'node replace', 'node repair'],
22
15
  },
23
16
  };
24
17
  /** Per-node connection data (`nodes.<name>`); `nodeDefaults` takes the subset that is not node-specific. */
@@ -26,61 +19,32 @@ export const NODE_SETTINGS = {
26
19
  ssh: {
27
20
  type: 'string',
28
21
  flag: '<ssh-target>',
29
- commands: ['node-install', 'node-replace-identity', 'node-repair-identity'],
22
+ commands: ['node add', 'node replace', 'node repair'],
30
23
  },
31
24
  address: {
32
25
  type: 'string',
33
26
  flag: '--address',
34
- commands: [
35
- 'node-install',
36
- 'node-replace-identity',
37
- 'node-repair-identity',
38
- 'node-register',
39
- 'node-prepare-enrollment',
40
- ],
27
+ commands: ['node add', 'node replace', 'node repair', 'node register', 'node enroll'],
41
28
  },
42
29
  label: {
43
30
  type: 'list',
44
31
  flag: '--label',
45
- commands: [
46
- 'node-install',
47
- 'node-replace-identity',
48
- 'node-repair-identity',
49
- 'node-register',
50
- 'node-prepare-enrollment',
51
- ],
32
+ commands: ['node add', 'node replace', 'node repair', 'node register', 'node enroll'],
52
33
  },
53
34
  installDocker: {
54
35
  type: 'boolean',
55
36
  flag: '--install-docker',
56
- commands: [
57
- 'node-install',
58
- 'node-replace-identity',
59
- 'node-repair-identity',
60
- 'node-prepare-enrollment',
61
- 'control-install',
62
- ],
37
+ commands: ['node add', 'node replace', 'node repair', 'node enroll', 'control setup'],
63
38
  },
64
39
  image: {
65
40
  type: 'string',
66
41
  flag: '--image',
67
- commands: [
68
- 'node-install',
69
- 'node-replace-identity',
70
- 'node-repair-identity',
71
- 'node-prepare-enrollment',
72
- 'control-install',
73
- ],
42
+ commands: ['node add', 'node replace', 'node repair', 'node enroll', 'control setup'],
74
43
  },
75
44
  agentControlUrl: {
76
45
  type: 'string',
77
46
  flag: '--agent-control-url',
78
- commands: [
79
- 'node-install',
80
- 'node-replace-identity',
81
- 'node-repair-identity',
82
- 'node-prepare-enrollment',
83
- ],
47
+ commands: ['node add', 'node replace', 'node repair', 'node enroll'],
84
48
  },
85
49
  };
86
50
  const NODE_SPECIFIC_KEYS = ['ssh', 'address'];
@@ -147,6 +111,16 @@ export async function resolveCliConfig(options, env, cwd, creating = false) {
147
111
  export function nodeSettings(config, name) {
148
112
  return { ...config.nodeDefaults, ...(name ? config.nodes[name] : undefined) };
149
113
  }
114
+ /** Top-level shortcuts that run a grouped command, so config keys and validation treat them alike. */
115
+ const SHORTCUTS = { setup: 'control setup' };
116
+ /** The space-joined command names from the first subject down, such as `node add`; `setup` is `control setup`. */
117
+ export function commandPath(command) {
118
+ const names = [command.name()];
119
+ for (let parent = command.parent; parent?.parent; parent = parent.parent)
120
+ names.unshift(parent.name());
121
+ const path = names.join(' ');
122
+ return SHORTCUTS[path] ?? path;
123
+ }
150
124
  /**
151
125
  * Use config values as defaults for the options of a command: a key applies to a command that lists it
152
126
  * and has an option of that name, and never over a value given on the command line.
@@ -154,7 +128,7 @@ export function nodeSettings(config, name) {
154
128
  export function applyConfigDefaults(command, values, settings) {
155
129
  const attributes = new Set(command.options.map((option) => option.attributeName()));
156
130
  for (const [key, value] of Object.entries(settings)) {
157
- if (SETTINGS[key]?.commands.includes(command.name()) &&
131
+ if (SETTINGS[key]?.commands.includes(commandPath(command)) &&
158
132
  attributes.has(key) &&
159
133
  command.getOptionValueSource(key) !== 'cli')
160
134
  values[key] = value;
@@ -8,12 +8,14 @@ export declare function shellQuote(value: unknown): string;
8
8
  export declare function dockerRunSnippet(image: string, joinKey: string, address?: string): string;
9
9
  /** The bootstrap runs in its own shell so its idempotent early exit cannot skip enrollment. */
10
10
  export declare function enrollmentCloudInit(bootstrap: string, command: string): string;
11
- /** Name of the container `control-install` creates (Compose project `tug-control`, service `control`). */
11
+ /** Name of the container `control setup` creates (Compose project `tug-control`, service `control`). */
12
12
  export declare const CONTROL_CONTAINER = "tug-control-control-1";
13
13
  /** A TCP port is free, or is held by the control container this install would replace, so installing again upgrades in place. */
14
14
  export declare function controlPortCheck(port: number): string;
15
+ /** The host of an SSH target: `host`, `user@host`, `ssh://user@host:2222` or bracketed IPv6. */
16
+ export declare function sshHost(sshTarget: string): string;
15
17
  /**
16
- * The default control URL for `control-install`: the SSH target's host and the published port. Accepts
18
+ * The default control URL for `control setup`: the SSH target's host and the published port. Accepts
17
19
  * `host`, `user@host`, `ssh://user@host:2222` and bracketed IPv6. An SSH alias from ~/.ssh/config is used as written,
18
20
  * so pass `--control-url` when the alias is not a name or address clients can reach.
19
21
  */