@wpelevator/tug 0.11.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 (80) hide show
  1. package/README.md +110 -68
  2. package/dist/agent.js +38 -2
  3. package/dist/backup-config.js +1 -1
  4. package/dist/backup-restore.d.ts +9 -2
  5. package/dist/backup-restore.js +75 -9
  6. package/dist/backup-scheduler.js +3 -1
  7. package/dist/cli-build.d.ts +1 -1
  8. package/dist/cli-build.js +4 -1
  9. package/dist/cli-config.d.ts +1 -1
  10. package/dist/cli-config.js +7 -2
  11. package/dist/cli-process.d.ts +13 -0
  12. package/dist/cli-process.js +46 -0
  13. package/dist/cli-progress.d.ts +11 -0
  14. package/dist/cli-progress.js +37 -0
  15. package/dist/cli-prompt.js +5 -4
  16. package/dist/cli-selection.d.ts +13 -0
  17. package/dist/cli-selection.js +39 -0
  18. package/dist/cli.js +4 -0
  19. package/dist/commands/backup.js +48 -31
  20. package/dist/commands/context.d.ts +6 -5
  21. package/dist/commands/context.js +65 -28
  22. package/dist/commands/control.js +56 -42
  23. package/dist/commands/credential.js +21 -2
  24. package/dist/commands/deploy.d.ts +4 -0
  25. package/dist/commands/deploy.js +116 -69
  26. package/dist/commands/hold.d.ts +2 -0
  27. package/dist/commands/hold.js +21 -0
  28. package/dist/commands/host.d.ts +8 -2
  29. package/dist/commands/host.js +36 -8
  30. package/dist/commands/image.d.ts +2 -0
  31. package/dist/commands/image.js +13 -0
  32. package/dist/commands/init.js +1 -1
  33. package/dist/commands/node.d.ts +1 -0
  34. package/dist/commands/node.js +139 -73
  35. package/dist/commands/observe.js +7 -2
  36. package/dist/commands/restore.js +71 -50
  37. package/dist/commands/secret.js +8 -3
  38. package/dist/commands/selection.d.ts +22 -0
  39. package/dist/commands/selection.js +90 -0
  40. package/dist/commands/service.js +17 -6
  41. package/dist/commands/volume.js +3 -2
  42. package/dist/control.js +234 -55
  43. package/dist/credentials.js +7 -0
  44. package/dist/db-backup-destination.d.ts +14 -0
  45. package/dist/db-backup-destination.js +28 -0
  46. package/dist/db-locks.d.ts +10 -0
  47. package/dist/db-locks.js +23 -0
  48. package/dist/db-transaction.d.ts +3 -0
  49. package/dist/db-transaction.js +24 -0
  50. package/dist/docker-api.d.ts +1 -0
  51. package/dist/image.d.ts +9 -0
  52. package/dist/image.js +19 -0
  53. package/dist/live.js +1 -0
  54. package/dist/util.d.ts +2 -0
  55. package/dist/util.js +6 -0
  56. package/docs/access-control.md +166 -0
  57. package/docs/architecture.md +254 -0
  58. package/docs/backups.md +160 -0
  59. package/docs/cli.md +182 -0
  60. package/docs/compose.md +245 -0
  61. package/docs/configuration.md +52 -0
  62. package/docs/control-plane.md +112 -0
  63. package/docs/development.md +140 -0
  64. package/docs/getting-started.md +75 -0
  65. package/docs/healthchecks.md +51 -0
  66. package/docs/migration.md +113 -0
  67. package/docs/networking.md +150 -0
  68. package/docs/nodes.md +143 -0
  69. package/docs/observability.md +213 -0
  70. package/docs/real-host-evaluation.md +224 -0
  71. package/docs/releases.md +39 -0
  72. package/docs/roadmap.md +234 -0
  73. package/docs/security.md +198 -0
  74. package/docs/service-placement.md +72 -0
  75. package/docs/standalone-binary.md +79 -0
  76. package/docs/swarm-comparison.md +197 -0
  77. package/docs/terminology.md +74 -0
  78. package/docs/testing.md +254 -0
  79. package/docs/troubleshooting.md +33 -0
  80. package/package.json +6 -3
package/README.md CHANGED
@@ -1,12 +1,16 @@
1
1
  # Tug
2
2
 
3
- Deploy Docker Compose apps to a few ordinary Linux servers, from your laptop or CI, without Kubernetes or a hosted platform.
3
+ Deploy Docker Compose apps to your own Linux VPS, from your laptop or CI, without Kubernetes or a hosted platform.
4
4
 
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.
5
+ A VPS is a durable computer with enough resources to host multiple sites or a medium-size application and its database. Tug helps you use that capacity: keep your Compose file, deploy with one command, and manage rollouts, secrets, backups and restores. Start with one server and add more when you need them. Tug is built for a small number of servers and Compose applications, including Node.js, Laravel, Ruby and WordPress apps.
6
+
7
+ Tug runs a small control plane and an agent, which can share the same VPS with your applications. It builds application images on the control server or your machine and delivers them to your nodes, so you do not need to run or configure an image registry for those builds. Published images are still pulled from their upstream registries.
8
+
9
+ Existing service and resource targets can be chosen directly in an interactive terminal: try `tug logs`, `tug deploy cancel` or `tug restore run`. Use `--select` for backup and move-hold subsets; see [interactive CLI selection](docs/cli.md#interactive-selection) for all supported commands and scripting behavior.
6
10
 
7
11
  ## What you can do with it
8
12
 
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.
13
+ - **Deploy a Compose file to your servers.** `tug deploy` builds or pulls your images, starts them on the right nodes, waits for health checks and tells you why if something fails. It reports deployment stages and elapsed time while work is pending. `tug rollback` returns to the previous version, and `x-tug: { rollback: auto }` does it for you.
10
14
  - **Choose where things run.** Pin a database and its volume to one node, spread web containers over several, and let Tug's managed Caddy ingress serve them over HTTPS with certificates it obtains for you.
11
15
  - **Update without downtime.** Opt-in rolling rollouts start the new version, wait until it is healthy and only then switch traffic.
12
16
  - **Keep secrets out of your repository.** `tug secret set DB_PASSWORD` stores them encrypted and sends each one only to the nodes whose services use it.
@@ -14,48 +18,75 @@ You keep your `docker-compose.yml`. Tug installs a small control plane on one se
14
18
  - **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
19
  - **Give CI a limited key.** `tug credential create` makes read-only, deploy-only or admin credentials, and every change records who made it.
16
20
 
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.
21
+ Agents only dial out to the control plane, so nodes can work behind NAT or a firewall that allows nothing inbound. Tug does not need a cloud provider's API or an overlay network.
18
22
 
19
23
  ## Try it
20
24
 
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.
25
+ Follow [Deploy your first service](docs/getting-started.md) to install Tug, deploy nginx to one VPS and verify that it works. You need Node.js 22.18 or newer and Docker Compose v2 on your computer, plus SSH access to a Linux server. Tug can install Docker on supported servers.
22
26
 
23
27
  ```bash
24
28
  npm install -g @wpelevator/tug # or run it with npx @wpelevator/tug
25
29
 
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
30
+ tug setup root@203.0.113.5 # install the control plane
31
+ tug node add root@203.0.113.5 --name app1 # install the agent on the same VPS
32
+ tug deploy # deploy a Compose file in this directory
29
33
  ```
30
34
 
35
+ The control plane and agent share the VPS with your application. Once your service is running, you can [add HTTPS](docs/networking.md#layer-b-ingress-and-tls), [back up your data](docs/backups.md) or [add another server](docs/service-placement.md).
36
+
31
37
  In a terminal Tug asks before it changes anything: whether to install Docker, which private address the node should use and whether it should handle ingress. It shows the plan and waits for you to confirm. `tug deploy` asks for any Compose variables, such as `${DB_PASSWORD}`, that no secret or `.env` provides. In CI or any other script nothing is ever asked, so pass the choices as flags (`--address`, `--ingress`, `--install-docker`, `-y`).
32
38
 
39
+ Long-running deployments, rollbacks, setup, image transfers, backups, restores and agent command waits report their current stage and elapsed time to stderr every ten seconds. Progress also works in CI and keeps JSON results separate. See [CLI progress reporting](docs/cli.md).
40
+
33
41
  ## Your Compose file, plus a little `x-tug`
34
42
 
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:
43
+ This example builds on the [quickstart](docs/getting-started.md). It pins the app, database and database volume to `app1`. Placement can be omitted with one node, but explicit placement keeps the database on its chosen server when you add more nodes. Enable ingress with `tug node ingress app1`, point the hostname's DNS at the VPS, and allow public ports 80 and 443. Save this as `compose.yaml` and replace the hostname with yours:
36
44
 
37
45
  ```yaml
38
46
  services:
39
47
  db:
40
48
  image: mariadb:11.4.8
41
- volumes: [db-data:/var/lib/mysql]
49
+ volumes:
50
+ - db-data:/var/lib/mysql
42
51
  environment:
43
52
  MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
44
53
  x-tug:
45
- placement: { nodes: [app1] }
46
- health: { type: tcp, port: 3306 }
54
+ placement:
55
+ nodes:
56
+ - app1
57
+ health:
58
+ type: tcp
59
+ port: 3306
47
60
 
48
61
  wordpress:
49
62
  image: wordpress:6.8.3-apache
50
- depends_on: [db]
63
+ depends_on:
64
+ - db
65
+ environment:
66
+ WORDPRESS_DB_HOST: db
67
+ WORDPRESS_DB_USER: root
68
+ WORDPRESS_DB_PASSWORD: ${DB_ROOT_PASSWORD}
69
+ ports:
70
+ - "8080:80"
51
71
  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 }
72
+ placement:
73
+ nodes:
74
+ - app1
75
+ health:
76
+ type: http
77
+ port: 80
78
+ path: /wp-includes/version.php
79
+ ingress:
80
+ host: blog.example.com
81
+ port: 80
82
+ tls: auto
55
83
 
56
84
  volumes:
57
85
  db-data:
58
- x-tug: { placement: { nodes: [app1] } }
86
+ x-tug:
87
+ placement:
88
+ nodes:
89
+ - app1
59
90
  ```
60
91
 
61
92
  ```bash
@@ -65,7 +96,9 @@ tug status # what is running where, and anything failing
65
96
  tug logs wordpress --follow
66
97
  ```
67
98
 
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).
99
+ The named volume holds data on `app1`; it is not copied to another node automatically. Back it up and restore or move its data deliberately if you replace the server.
100
+
101
+ See the [examples directory](examples/) for stack-specific applications and [local stack benchmarks](docs/testing.md#example-stack-benchmarks) for measured behavior of those examples. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
69
102
 
70
103
  ## Everyday commands
71
104
 
@@ -89,68 +122,77 @@ Every command and option is in the [CLI reference](docs/cli.md). `tug status --j
89
122
 
90
123
  ## Is Tug right for you?
91
124
 
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.
125
+ Tug fits if you run Compose apps on one or a few VPSes, want deploys, rollbacks, secrets and backups handled in one tool, and are happy with a single control server that you back up. A server can host multiple apps as long as it has enough capacity; Tug does not automatically balance resource usage or replace a failed server.
93
126
 
94
127
  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
128
 
96
129
  ## How it works
97
130
 
98
131
  ```text
99
- tug CLI / CI (operator credential)
100
- |
101
- | mTLS: deploy, status, logs, exec
102
- v
103
- +-------------------------------------+
104
- | Control plane (one mTLS port) |
105
- | Control API (role-checked) |
106
- | SQLite: desired states, secrets |
107
- | private CA, image builds |
108
- +-------------------------------------+
109
- ^ ^
110
- | agents dial out: work, status,
111
- | WebSocket wake and live sessions
112
- | |
113
- +------+------------+ +------+------------------+
114
- | Node app1 | | Node app2 (ingress node)|
115
- | Agent | | Agent |
116
- | Docker Engine | | Docker Engine |
117
- | services | | services |
118
- | | | managed Caddy ingress |
119
- +-------------------+ +-------------------------+
120
- \_________________________/
121
- workload traffic between node addresses
122
- over the operator's private network
123
-
124
- SSH is used only by the CLI to install the control plane and nodes.
132
+ tug CLI / CI
133
+ |
134
+ | mTLS: deploy, status, logs, exec
135
+ v
136
+ +--------------------------------+ +--------------------------------+
137
+ | VPS app1 | | VPS app2 (optional) |
138
+ | | | |
139
+ | Control plane | | |
140
+ | API, SQLite, image builds | | |
141
+ | | | |
142
+ | Agent --> Docker Engine | | Agent --> Docker Engine |
143
+ | | | | | |
144
+ | +-- service A | | +-- service C |
145
+ | +-- database | | +-- service D |
146
+ | +-- more... | | +-- more... |
147
+ | (zero or more services) | | (zero or more services) |
148
+ | | | |
149
+ | Local volumes, optional Caddy | | Local volumes, optional Caddy |
150
+ +--------------------------------+ +--------------------------------+
151
+ | |
152
+ +------- private workload network --------+
153
+
154
+ Each agent connects to the control plane over mTLS. Services are
155
+ placed on either node; local volumes stay on their node. app1 can
156
+ run alone. SSH installs control and agents; normal operations use
157
+ the control API.
125
158
  ```
126
159
 
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.
160
+ `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 application images when needed and pins published images to digests. Tug delivers its built images as archives; published images are pulled from their upstream registries. 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 for application normalization; nodes need Docker, and the control installation also uses Compose. Cross-node workload traffic uses your own private network.
128
161
 
129
162
  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.
130
163
 
131
- The full walkthrough, including CI credentials, moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
132
-
133
164
  ## Documentation
134
165
 
135
- - [Getting started](docs/getting-started.md): install control, add nodes, deploy, give CI a credential, move control, recover.
136
- - [Terminology](docs/terminology.md): core concepts, the naming review (which recommendations are implemented), industry comparisons and the remaining proposed interface improvements.
137
- - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
138
- - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
139
- - [Control plane](docs/control-plane.md): install, mutual TLS and the private CA, image delivery, control-plane backup and recovery on another host.
140
- - [Backups](docs/backups.md): scheduled workload backups with `x-tug.backup` (restic), restore, recipes for common databases, and a restore drill. See also the [WordPress example](examples/wordpress/README.md).
141
- - [Access control](docs/access-control.md): certificate identity classes, scoped operator credentials, roles, audit trail and a GitHub Actions deploy example.
142
- - [Security](docs/security.md): deployment trust boundaries, security audit findings and validation limits.
143
- - [Networking](docs/networking.md): bring-your-own private networking, cross-node service names, ingress and TLS, firewall notes.
144
- - [Nodes](docs/nodes.md): node names and IDs, SSH install, self-service enrollment, certificate renewal and recovery, registration and status.
145
- - [CLI reference](docs/cli.md): every `tug` command and option.
146
- - [Configuration](docs/configuration.md): CLI, control and agent environment variables.
147
- - [Development](docs/development.md): local setup, CI, Compose stack, host mode and the integration test.
148
- - [Testing](docs/testing.md): test layers, rollout metrics, mocked test backlog, and the [Caddy/PHP and static-file benchmark](docs/testing.md#caddy-proxy-overhead-benchmark).
149
- - [Observability](docs/observability.md): what can be observed today, and the proposed logs, metrics and alerting.
150
- - [Releases](docs/releases.md): changesets, npm and Docker image publishing.
151
- - [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution, with the mTLS dependency findings.
152
- - [Roadmap](docs/roadmap.md): goals, principles, the reference WordPress topology and planned milestones.
153
- - [Docker Swarm comparison](docs/swarm-comparison.md): overlapping capabilities, workflow advantages, scheduling and networking limits, failure behavior and workload fit.
166
+ Start with the [single-VPS tutorial](docs/getting-started.md), then choose the topic that matches your next task:
167
+
168
+ - **Deploy and operate**
169
+ - [Application examples](examples/README.md)
170
+ - [Health checks and rollouts](docs/healthchecks.md)
171
+ - [Troubleshooting](docs/troubleshooting.md)
172
+ - **Networking and access**
173
+ - [Hostnames, HTTPS and service networking](docs/networking.md)
174
+ - [Credentials and CI](docs/access-control.md)
175
+ - [Security trust boundaries](docs/security.md)
176
+ - **Servers and data**
177
+ - [Service placement and scaling](docs/service-placement.md)
178
+ - [Node management](docs/nodes.md)
179
+ - [Backups and recovery](docs/backups.md)
180
+ - [Moving to new servers](docs/migration.md)
181
+ - **Reference**
182
+ - [CLI](docs/cli.md)
183
+ - [Compose extensions](docs/compose.md)
184
+ - [Configuration](docs/configuration.md)
185
+ - [Control plane](docs/control-plane.md)
186
+ - **Contribute and design**
187
+ - [Development](docs/development.md)
188
+ - [Testing](docs/testing.md)
189
+ - [Releases](docs/releases.md)
190
+ - [Architecture](docs/architecture.md)
191
+ - [Roadmap](docs/roadmap.md)
192
+ - [Observability status and proposal](docs/observability.md)
193
+ - [Terminology](docs/terminology.md)
194
+ - [Standalone binary proposal](docs/standalone-binary.md)
195
+ - [Docker Swarm comparison](docs/swarm-comparison.md)
154
196
 
155
197
  ## Development
156
198
 
@@ -161,4 +203,4 @@ npm run lint
161
203
  npm run test:integration
162
204
  ```
163
205
 
164
- See [Development](docs/development.md) for details.
206
+ See [Development](docs/development.md) for details. Database code uses direct SQLite queries; [SQLite storage](docs/architecture.md#sqlite-storage) describes query boundaries and synchronous transactions.
package/dist/agent.js CHANGED
@@ -518,9 +518,11 @@ export async function waitForHealth(ctx, project, container, service, health) {
518
518
  throw new Error(`${service}: docker health ${state.Health.Status}`);
519
519
  }
520
520
  else if (health.type === 'tcp') {
521
+ await assertRunning(ctx, container, service);
521
522
  await tcpCheck(await containerIp(ctx, project, container), health.port, parseDuration(health.timeout || '2s'));
522
523
  }
523
524
  else if (health.type === 'http') {
525
+ await assertRunning(ctx, container, service);
524
526
  const ip = await containerIp(ctx, project, container);
525
527
  const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, {
526
528
  signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')),
@@ -537,6 +539,33 @@ export async function waitForHealth(ctx, project, container, service, health) {
537
539
  }
538
540
  throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}${outsideContainerHint(health)}`);
539
541
  }
542
+ /** Fails fast with the exit code and last log lines when the container is gone, instead of dialing a dead address. */
543
+ async function assertRunning(ctx, container, service) {
544
+ const state = (await ctx.docker.inspectContainer(container)).State;
545
+ if (!state || state.Running)
546
+ return;
547
+ const tail = await lastLogLines(ctx, container);
548
+ throw new Error(`${service}: container exited with code ${state.ExitCode ?? '?'}${tail ? `; last log: ${tail}` : ''}`);
549
+ }
550
+ async function lastLogLines(ctx, container, lines = 3) {
551
+ try {
552
+ const proc = await ctx.docker.logs(container, { follow: false, tail: lines });
553
+ let out = '';
554
+ const collect = (chunk) => void (out += chunk.toString());
555
+ proc.stdout.on('data', collect);
556
+ proc.stderr.on('data', collect);
557
+ await new Promise((resolve) => {
558
+ const timer = setTimeout(resolve, 2000);
559
+ proc.stdout.once('close', () => (clearTimeout(timer), resolve()));
560
+ proc.stdout.once('end', () => (clearTimeout(timer), resolve()));
561
+ });
562
+ proc.kill();
563
+ return out.trim().split('\n').slice(-lines).join(' | ').slice(0, 400);
564
+ }
565
+ catch {
566
+ return '';
567
+ }
568
+ }
540
569
  /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
541
570
  function describeError(error) {
542
571
  const e = error;
@@ -987,8 +1016,10 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
987
1016
  markEvents('failed');
988
1017
  }
989
1018
  // Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
990
- if (!rolling.length)
991
- throw e;
1019
+ if (!rolling.length) {
1020
+ // Callers turn the throw into a failed status; carry the per-service states so a held writer is still seen.
1021
+ throw Object.assign(e instanceof Error ? e : new Error(errMessage(e)), { serviceStates });
1022
+ }
992
1023
  Object.assign(serviceStates, await activeRollingStates(dir, rolling));
993
1024
  return failedStatus(errMessage(e));
994
1025
  }
@@ -1099,6 +1130,11 @@ export async function pollOnce(ctx = defaultContext()) {
1099
1130
  ...(desired.revision ? { observedRevision: desired.revision } : {}),
1100
1131
  health: 'failed',
1101
1132
  error: errMessage(e),
1133
+ ...(e.serviceStates
1134
+ ? {
1135
+ serviceStates: e.serviceStates,
1136
+ }
1137
+ : {}),
1102
1138
  });
1103
1139
  }
1104
1140
  }
@@ -145,7 +145,7 @@ export function resolveBackups(compose, project = '') {
145
145
  ? defaultSchedule.trim()
146
146
  : undefined;
147
147
  if (spec.schedule === undefined && schedule === undefined)
148
- errors.push(`${label} needs a schedule: set ${label}.schedule or x-tug.backup.schedule`);
148
+ errors.push(`${label} needs a schedule: set ${label}.schedule or a project-level schedule at the top-level x-tug.backup.schedule`);
149
149
  const retention = spec.retention !== undefined
150
150
  ? validateRetention(label, spec.retention, errors)
151
151
  : defaultRetention;
@@ -31,7 +31,14 @@ export interface RestoreDeps {
31
31
  clock?: () => Date;
32
32
  }
33
33
  /**
34
- * The services that write to a target. For a service they are the services that depend on it, transitively;
34
+ * True when an environment entry points at `service` over the network: a host-like variable that is exactly the
35
+ * service name (`WORDPRESS_DB_HOST=db`), or a value that names it as a host (`db:3306`, `mysql://u:p@db/app`,
36
+ * `mysql:host=db;dbname=app`). A service reached across nodes has no `depends_on` to find it by.
37
+ */
38
+ export declare function environmentReaches(service: string, key: string, value: string): boolean;
39
+ /**
40
+ * The services that write to a target. For a service they are the services that depend on it, transitively,
41
+ * plus the services whose environment names it as a host, which is how a service on another node reaches it (only for the target itself, not for what depends on the writers);
35
42
  * for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
36
43
  * because the per-node copies drop cross-node dependencies.
37
44
  */
@@ -62,6 +69,6 @@ export declare function createRestoreService(deps: RestoreDeps): {
62
69
  status: number;
63
70
  };
64
71
  holds: () => Record<string, string[]>;
65
- list: (project: string, limit?: number) => RestoreRunRow[];
72
+ list: (project: string, limit?: number, interruptedOnly?: boolean) => RestoreRunRow[];
66
73
  };
67
74
  export type RestoreService = ReturnType<typeof createRestoreService>;
@@ -8,8 +8,36 @@ const VERIFY_TIMEOUT_MS = 5 * MINUTE;
8
8
  /** Restore states in which the project must stay locked. */
9
9
  const LOCKED_STATES = ['locking', 'holding', 'safety_backup', 'restoring', 'interrupted'];
10
10
  const TERMINAL = ['succeeded', 'failed', 'released'];
11
+ const HOST_KEY = /host|server|addr|endpoint|url|uri|dsn/i;
11
12
  /**
12
- * The services that write to a target. For a service they are the services that depend on it, transitively;
13
+ * True when an environment entry points at `service` over the network: a host-like variable that is exactly the
14
+ * service name (`WORDPRESS_DB_HOST=db`), or a value that names it as a host (`db:3306`, `mysql://u:p@db/app`,
15
+ * `mysql:host=db;dbname=app`). A service reached across nodes has no `depends_on` to find it by.
16
+ */
17
+ export function environmentReaches(service, key, value) {
18
+ const name = service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ if (value === service)
20
+ return HOST_KEY.test(key);
21
+ return [
22
+ new RegExp(`^${name}:\\d+$`),
23
+ new RegExp(`(?:://|@)(?:[^/@\\s]*@)?${name}(?::\\d+)?(?:[/?\\s]|$)`),
24
+ new RegExp(`(?:host|server)=${name}(?:[;:,\\s]|$)`, 'i'),
25
+ ].some((pattern) => pattern.test(value));
26
+ }
27
+ function environmentOf(service) {
28
+ const env = service.environment;
29
+ if (Array.isArray(env))
30
+ return env.map(String).map((entry) => {
31
+ const at = entry.indexOf('=');
32
+ return at < 0 ? [entry, ''] : [entry.slice(0, at), entry.slice(at + 1)];
33
+ });
34
+ return env && typeof env === 'object'
35
+ ? Object.entries(env).map(([k, v]) => [k, String(v ?? '')])
36
+ : [];
37
+ }
38
+ /**
39
+ * The services that write to a target. For a service they are the services that depend on it, transitively,
40
+ * plus the services whose environment names it as a host, which is how a service on another node reaches it (only for the target itself, not for what depends on the writers);
13
41
  * for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
14
42
  * because the per-node copies drop cross-node dependencies.
15
43
  */
@@ -34,7 +62,12 @@ export function discoverWriters(compose, target) {
34
62
  while (queue.length) {
35
63
  const current = queue.pop();
36
64
  for (const service of Object.keys(compose.services))
37
- if (!writers.has(service) && service !== name && dependsOn(service).includes(current)) {
65
+ if (!writers.has(service) &&
66
+ service !== name &&
67
+ (dependsOn(service).includes(current) ||
68
+ (kind === 'service' &&
69
+ current === name &&
70
+ environmentOf(compose.services[service]).some(([k, v]) => environmentReaches(name, k, v))))) {
38
71
  writers.add(service);
39
72
  queue.push(service);
40
73
  }
@@ -106,7 +139,7 @@ export function createRestoreService(deps) {
106
139
  return { error: `writer ${writer} is not a service of ${request.project}`, status: 400 };
107
140
  if (!writers.length && !request.noWriters && !request.writers)
108
141
  return {
109
- error: `no services were found that write to ${request.target}; pass --writers to name them or --no-writers to restore without holding any`,
142
+ error: `no services were found that write to ${request.target} (looked at depends_on, volume mounts and host names in environment variables); pass --writers to name them or --no-writers to restore without holding any`,
110
143
  status: 409,
111
144
  };
112
145
  }
@@ -136,8 +169,16 @@ export function createRestoreService(deps) {
136
169
  function unlock(restore) {
137
170
  db.run('DELETE FROM locks WHERE name=? AND owner=?', `project:${restore.project_id}`, `restore:${restore.id}`);
138
171
  }
139
- function release(restore) {
172
+ /**
173
+ * Drops a restore's holds and lock. Holds a deploy placed for a move (no `restore_id`) are dropped with them,
174
+ * unless `keepMoveHolds`: a restore that failed before changing data must not start the writers against the
175
+ * empty database it was meant to fill.
176
+ */
177
+ function release(restore, keepMoveHolds = false) {
140
178
  db.run('DELETE FROM service_holds WHERE restore_id=?', restore.id);
179
+ if (!keepMoveHolds)
180
+ for (const service of JSON.parse(restore.writers_json))
181
+ db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', restore.project_id, service);
141
182
  unlock(restore);
142
183
  deps.wake(restore.project_id);
143
184
  }
@@ -166,8 +207,12 @@ export function createRestoreService(deps) {
166
207
  else
167
208
  move(restore, 'holding');
168
209
  if (restore.mode === 'in-place')
169
- for (const service of restorePlan.writers)
210
+ for (const service of restorePlan.writers) {
211
+ // A hold a deploy placed for a move stays as it is; this restore drops it when it succeeds.
212
+ if (db.get('SELECT 1 FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', restore.project_id, service))
213
+ continue;
170
214
  db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', restore.project_id, service, `restore ${restoreId}`, restoreId, ts);
215
+ }
171
216
  deps.wake(restore.project_id);
172
217
  advance();
173
218
  return {
@@ -189,12 +234,29 @@ export function createRestoreService(deps) {
189
234
  });
190
235
  });
191
236
  };
237
+ /** Says which writers never reported `held` and why: the node's own apply error, or that it has not reported. */
238
+ const holdDiagnosis = (restore, writers) => {
239
+ const desired = deployed(restore.project_id);
240
+ const problems = [];
241
+ for (const service of writers)
242
+ for (const node of desired?.services[service]?.placement.nodes ?? []) {
243
+ const row = db.get('SELECT s.* FROM node_statuses s JOIN nodes n ON n.id=s.node_id WHERE n.name=? AND s.project_id=?', node, restore.project_id);
244
+ const status = row ? JSON.parse(row.status_json) : undefined;
245
+ const state = status?.serviceStates?.[service]?.rolloutState;
246
+ if (state === 'held')
247
+ continue;
248
+ problems.push(!status
249
+ ? `${service} on ${node}: the node has not reported`
250
+ : `${service} on ${node}: ${state ?? 'no state'}${status.error ? `; the node's apply failed with: ${status.error}` : ''}`);
251
+ }
252
+ return problems.join('; ');
253
+ };
192
254
  /** Fails a restore. Before anything changed, holds and the lock are dropped; after, they stay until released. */
193
255
  function fail(restore, error, dataChanged) {
194
256
  if (dataChanged)
195
257
  move(restore, 'interrupted', { error });
196
258
  else {
197
- release(restore);
259
+ release(restore, true);
198
260
  move(restore, 'failed', { error });
199
261
  }
200
262
  event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore release' : ''}`, { error, held: dataChanged });
@@ -212,8 +274,10 @@ export function createRestoreService(deps) {
212
274
  }
213
275
  function advanceHolding(restore, writers, age) {
214
276
  if (!writersReport(restore, writers, 'held')) {
215
- if (age > HOLD_TIMEOUT_MS)
216
- fail(restore, 'the writers did not stop in time', false);
277
+ if (age > HOLD_TIMEOUT_MS) {
278
+ const why = holdDiagnosis(restore, writers);
279
+ fail(restore, `the writers did not stop in time${why ? ` (${why})` : ''}`, false);
280
+ }
217
281
  return;
218
282
  }
219
283
  if (!restore.safety_backup)
@@ -310,7 +374,9 @@ export function createRestoreService(deps) {
310
374
  (result[row.project_id] ??= []).push(row.service);
311
375
  return result;
312
376
  }
313
- function list(project, limit = 20) {
377
+ function list(project, limit = 20, interruptedOnly = false) {
378
+ if (interruptedOnly)
379
+ return db.all("SELECT * FROM restore_runs WHERE project_id=? AND state='interrupted' ORDER BY started_at DESC,id", project);
314
380
  return db.all('SELECT * FROM restore_runs WHERE project_id=? ORDER BY started_at DESC LIMIT ?', project, limit);
315
381
  }
316
382
  return { plan, start, advance, releaseInterrupted, resume, holds, list };
@@ -4,6 +4,7 @@ import { projectRepository } from "./backup-destination.js";
4
4
  import { volumeName } from "./container-spec.js";
5
5
  import { id } from "./util.js";
6
6
  import { isUniqueViolation } from "./db-driver.js";
7
+ import { backupDestinationQueries } from "./db-backup-destination.js";
7
8
  /** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
8
9
  export const DEFAULT_RESTIC_IMAGE = 'restic/restic:0.17.3';
9
10
  const MINUTE = 60_000;
@@ -63,7 +64,8 @@ export function createBackupService(deps) {
63
64
  const started = clock().getTime();
64
65
  const stamp = () => clock().toISOString();
65
66
  const heartbeats = new Map();
66
- const destination = () => db.get("SELECT * FROM backup_destination WHERE id='default'");
67
+ const destinationQueries = backupDestinationQueries(db);
68
+ const destination = () => destinationQueries.find();
67
69
  const nodeId = (name) => db.get('SELECT id FROM nodes WHERE name=?', name)?.id;
68
70
  const nodeName = (nodeIdValue) => nodeIdValue
69
71
  ? (db.get('SELECT name FROM nodes WHERE id=?', nodeIdValue)?.name ?? null)
@@ -14,4 +14,4 @@ export declare function createSourceArchive(contextDir: string, archive: string,
14
14
  /** Build from the same filtered, validated source snapshot used for remote builds. */
15
15
  export declare function createLocalBuildSnapshot(contextDir: string, directory: string, configFiles: string[]): Promise<string>;
16
16
  /** Build only application build services; callers own temporary files and upload transport. */
17
- export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies): Promise<LocalImageUpload[]>;
17
+ export declare function prepareLocalImages(compose: ComposeFile, contextDir: string, directory: string, dependencies?: LocalBuildDependencies, onProgress?: (message: string) => void): Promise<LocalImageUpload[]>;
package/dist/cli-build.js CHANGED
@@ -143,7 +143,7 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
143
143
  remove: async (imageRef) => {
144
144
  await run('docker', ['image', 'rm', imageRef]);
145
145
  },
146
- }) {
146
+ }, onProgress) {
147
147
  const images = [];
148
148
  const nonce = dependencies.nonce();
149
149
  for (const [service, spec] of Object.entries(compose.services)) {
@@ -152,16 +152,19 @@ export async function prepareLocalImages(compose, contextDir, directory, depende
152
152
  const field = `image-${images.length}`;
153
153
  const imageRef = `tug-build/${nonce}/${field}:local`;
154
154
  try {
155
+ onProgress?.(`Building image for ${service} locally`);
155
156
  await dependencies.build({
156
157
  contextDir,
157
158
  service,
158
159
  buildSpec: spec.build,
159
160
  imageRef,
160
161
  });
162
+ onProgress?.(`Exporting image for ${service}`);
161
163
  const archive = await dependencies.save(imageRef, path.join(directory, `${field}.tar.gz`));
162
164
  images.push({ service, imageRef, ...archive, field });
163
165
  }
164
166
  finally {
167
+ onProgress?.(`Cleaning up temporary image for ${service}`);
165
168
  try {
166
169
  await dependencies.remove(imageRef);
167
170
  }
@@ -66,7 +66,7 @@ export declare function applyConfigDefaults(command: OptionSource, values: objec
66
66
  * overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
67
67
  * connection flags passed to `init` are saved to the overlay instead.
68
68
  */
69
- export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions): Promise<string[]>;
69
+ export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions, credentialUrl?: string): Promise<string[]>;
70
70
  export declare function normalizeControlUrl(value: string): string;
71
71
  export declare function integerOption(value: string, name: string, minimum?: number, maximum?: number): number;
72
72
  export {};
@@ -272,11 +272,16 @@ function serialize(file, data, notice) {
272
272
  * overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
273
273
  * connection flags passed to `init` are saved to the overlay instead.
274
274
  */
275
- export async function writeInitConfig(config, credentialFile, explicit) {
275
+ export async function writeInitConfig(config, credentialFile, explicit, credentialUrl) {
276
276
  const written = [];
277
277
  if (!config.fileExists) {
278
278
  await fs.mkdir(path.dirname(config.file), { recursive: true });
279
- await fs.writeFile(config.file, serialize(config.file, { controlUrl: config.controlUrl, project: config.project }, `Safe to commit: keep credentials out of this file (use TUG_CREDENTIAL_FILE or ${path.basename(config.localFile)}).`));
279
+ await fs.writeFile(config.file, serialize(config.file, {
280
+ controlUrl: explicit.controlUrl
281
+ ? config.controlUrl
282
+ : (credentialUrl ?? config.controlUrl),
283
+ project: config.project,
284
+ }, `Safe to commit: keep credentials out of this file (use TUG_CREDENTIAL_FILE or ${path.basename(config.localFile)}).`));
280
285
  written.push(config.file);
281
286
  }
282
287
  let local = {};
@@ -0,0 +1,13 @@
1
+ /** Keep the event loop free for progress timers during SSH, Docker and file transfers. */
2
+ export declare function runProcess(cmd: string, args: string[], { capture, input }?: {
3
+ capture?: boolean;
4
+ input?: string;
5
+ }): Promise<{
6
+ status: number | null;
7
+ stdout: string;
8
+ stderr: string;
9
+ }>;
10
+ export declare function runLocal(cmd: string, args: string[]): Promise<void>;
11
+ export declare function runCapture(cmd: string, args: string[], opts?: {
12
+ input?: string;
13
+ }): Promise<string>;