@wpelevator/tug 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +172 -75
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +39 -3
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +33 -25
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.d.ts +8 -1
- package/dist/backup-restore.js +135 -71
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +7 -2
- package/dist/cli-config.js +25 -46
- package/dist/cli-install.d.ts +4 -2
- package/dist/cli-install.js +12 -7
- package/dist/cli-live.js +4 -6
- package/dist/cli-onboarding.d.ts +31 -0
- package/dist/cli-onboarding.js +66 -0
- package/dist/cli-prompt.d.ts +9 -0
- package/dist/cli-prompt.js +60 -0
- package/dist/cli-secrets.d.ts +12 -0
- package/dist/cli-secrets.js +71 -0
- package/dist/cli-status.d.ts +11 -0
- package/dist/cli-status.js +37 -7
- package/dist/cli.js +66 -981
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +58 -0
- package/dist/commands/context.js +111 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +111 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +6 -0
- package/dist/commands/deploy.js +159 -0
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +13 -0
- package/dist/commands/host.d.ts +24 -0
- package/dist/commands/host.js +122 -0
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +40 -0
- package/dist/commands/node.d.ts +28 -0
- package/dist/commands/node.js +354 -0
- package/dist/commands/observe.d.ts +2 -0
- package/dist/commands/observe.js +71 -0
- package/dist/commands/restore.d.ts +2 -0
- package/dist/commands/restore.js +85 -0
- package/dist/commands/secret.d.ts +2 -0
- package/dist/commands/secret.js +26 -0
- package/dist/commands/service.d.ts +2 -0
- package/dist/commands/service.js +62 -0
- package/dist/commands/volume.d.ts +2 -0
- package/dist/commands/volume.js +13 -0
- package/dist/control.js +145 -14
- package/dist/credentials.js +2 -0
- package/dist/diagnostics.js +1 -1
- package/dist/docker-api.d.ts +1 -0
- package/dist/live.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,97 +1,194 @@
|
|
|
1
1
|
# Tug
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Deploy Docker Compose apps to your own Linux VPS, from your laptop or CI, without Kubernetes or a hosted platform.
|
|
4
4
|
|
|
5
|
-
|
|
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
6
|
|
|
7
|
-
|
|
7
|
+
Tug runs a small control plane and an agent, which can share the same VPS with your applications. It builds application images on the control server or your machine and delivers them to your nodes, so you do not need to run or configure an image registry for those builds. Published images are still pulled from their upstream registries.
|
|
8
8
|
|
|
9
|
-
|
|
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
|
+
## What you can do with it
|
|
22
10
|
|
|
23
|
-
|
|
11
|
+
- **Deploy a Compose file to your servers.** `tug deploy` builds or pulls your images, starts them on the right nodes, waits for health checks and tells you why if something fails. `tug rollback` returns to the previous version, and `x-tug: { rollback: auto }` does it for you.
|
|
12
|
+
- **Choose where things run.** Pin a database and its volume to one node, spread web containers over several, and let Tug's managed Caddy ingress serve them over HTTPS with certificates it obtains for you.
|
|
13
|
+
- **Update without downtime.** Opt-in rolling rollouts start the new version, wait until it is healthy and only then switch traffic.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
- **Give CI a limited key.** `tug credential create` makes read-only, deploy-only or admin credentials, and every change records who made it.
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
| mTLS: deploy, status, logs, exec
|
|
29
|
-
v
|
|
30
|
-
+-------------------------------------+
|
|
31
|
-
| Control plane (one mTLS port) |
|
|
32
|
-
| Control API (role-checked) |
|
|
33
|
-
| SQLite: desired states, secrets |
|
|
34
|
-
| private CA, image builds |
|
|
35
|
-
+-------------------------------------+
|
|
36
|
-
^ ^
|
|
37
|
-
| agents dial out: work, status,
|
|
38
|
-
| WebSocket wake and live sessions
|
|
39
|
-
| |
|
|
40
|
-
+------+------------+ +------+------------------+
|
|
41
|
-
| Node app1 | | Node app2 (ingress node)|
|
|
42
|
-
| Agent | | Agent |
|
|
43
|
-
| Docker Engine | | Docker Engine |
|
|
44
|
-
| services | | services |
|
|
45
|
-
| | | managed Caddy ingress |
|
|
46
|
-
+-------------------+ +-------------------------+
|
|
47
|
-
\_________________________/
|
|
48
|
-
workload traffic between node addresses
|
|
49
|
-
over the operator's private network
|
|
50
|
-
|
|
51
|
-
SSH is used only by the CLI to install the control plane and nodes.
|
|
52
|
-
```
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
## Try it
|
|
53
22
|
|
|
54
|
-
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install -g @wpelevator/tug # or run it with npx @wpelevator/tug
|
|
55
27
|
|
|
56
|
-
|
|
28
|
+
tug setup root@203.0.113.5 # install the control plane
|
|
29
|
+
tug node add root@203.0.113.5 --name app1 # install the agent on the same VPS
|
|
30
|
+
tug deploy # deploy a Compose file in this directory
|
|
31
|
+
```
|
|
57
32
|
|
|
58
|
-
|
|
33
|
+
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).
|
|
34
|
+
|
|
35
|
+
In a terminal Tug asks before it changes anything: whether to install Docker, which private address the node should use and whether it should handle ingress. It shows the plan and waits for you to confirm. `tug deploy` asks for any Compose variables, such as `${DB_PASSWORD}`, that no secret or `.env` provides. In CI or any other script nothing is ever asked, so pass the choices as flags (`--address`, `--ingress`, `--install-docker`, `-y`).
|
|
36
|
+
|
|
37
|
+
## Your Compose file, plus a little `x-tug`
|
|
38
|
+
|
|
39
|
+
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:
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
services:
|
|
43
|
+
db:
|
|
44
|
+
image: mariadb:11.4.8
|
|
45
|
+
volumes:
|
|
46
|
+
- db-data:/var/lib/mysql
|
|
47
|
+
environment:
|
|
48
|
+
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
49
|
+
x-tug:
|
|
50
|
+
placement:
|
|
51
|
+
nodes:
|
|
52
|
+
- app1
|
|
53
|
+
health:
|
|
54
|
+
type: tcp
|
|
55
|
+
port: 3306
|
|
56
|
+
|
|
57
|
+
wordpress:
|
|
58
|
+
image: wordpress:6.8.3-apache
|
|
59
|
+
depends_on:
|
|
60
|
+
- db
|
|
61
|
+
environment:
|
|
62
|
+
WORDPRESS_DB_HOST: db
|
|
63
|
+
WORDPRESS_DB_USER: root
|
|
64
|
+
WORDPRESS_DB_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
65
|
+
ports:
|
|
66
|
+
- "8080:80"
|
|
67
|
+
x-tug:
|
|
68
|
+
placement:
|
|
69
|
+
nodes:
|
|
70
|
+
- app1
|
|
71
|
+
health:
|
|
72
|
+
type: http
|
|
73
|
+
port: 80
|
|
74
|
+
path: /wp-includes/version.php
|
|
75
|
+
ingress:
|
|
76
|
+
host: blog.example.com
|
|
77
|
+
port: 80
|
|
78
|
+
tls: auto
|
|
79
|
+
|
|
80
|
+
volumes:
|
|
81
|
+
db-data:
|
|
82
|
+
x-tug:
|
|
83
|
+
placement:
|
|
84
|
+
nodes:
|
|
85
|
+
- app1
|
|
86
|
+
```
|
|
59
87
|
|
|
60
88
|
```bash
|
|
61
|
-
|
|
62
|
-
tug
|
|
63
|
-
tug
|
|
64
|
-
tug
|
|
65
|
-
tug deploy -f docker-compose.yml
|
|
89
|
+
tug secret set DB_ROOT_PASSWORD
|
|
90
|
+
tug deploy
|
|
91
|
+
tug status # what is running where, and anything failing
|
|
92
|
+
tug logs wordpress --follow
|
|
66
93
|
```
|
|
67
94
|
|
|
68
|
-
The
|
|
95
|
+
The named volume holds data on `app1`; it is not copied to another node automatically. Back it up and restore or move its data deliberately if you replace the server.
|
|
96
|
+
|
|
97
|
+
See the [examples directory](examples/) for stack-specific applications. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
|
|
98
|
+
|
|
99
|
+
## Everyday commands
|
|
100
|
+
|
|
101
|
+
Project commands sit at the top level; everything else reads `tug <subject> <verb>`.
|
|
102
|
+
|
|
103
|
+
| I want to... | Run |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| Deploy, or undo a deploy | `tug deploy`, `tug rollback` |
|
|
106
|
+
| See what is running and what failed | `tug status`, `tug events`, `tug ps` |
|
|
107
|
+
| Read logs or open a shell | `tug logs <service>`, `tug exec <service> sh` |
|
|
108
|
+
| Run a one-off command | `tug run <service> -- wp cache flush` |
|
|
109
|
+
| Change the number of replicas | `tug scale <service> 3` |
|
|
110
|
+
| Add, list or replace servers | `tug node add`, `tug node list`, `tug node replace <name>` |
|
|
111
|
+
| Add a server I cannot SSH to | `tug node enroll`, then run the printed `docker run` on it |
|
|
112
|
+
| Manage secrets | `tug secret set`, `tug secret list`, `tug secret remove` |
|
|
113
|
+
| Back up now, or restore | `tug backup run`, `tug restore run <target>` |
|
|
114
|
+
| Give CI a deploy key | `tug credential create ci --role deploy` |
|
|
115
|
+
| Check everything is healthy | `tug doctor` |
|
|
116
|
+
|
|
117
|
+
Every command and option is in the [CLI reference](docs/cli.md). `tug status --json` prints raw JSON for scripts.
|
|
118
|
+
|
|
119
|
+
## Is Tug right for you?
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
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).
|
|
124
|
+
|
|
125
|
+
## How it works
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
tug CLI / CI
|
|
129
|
+
|
|
|
130
|
+
| mTLS: deploy, status, logs, exec
|
|
131
|
+
v
|
|
132
|
+
+--------------------------------+ +--------------------------------+
|
|
133
|
+
| VPS app1 | | VPS app2 (optional) |
|
|
134
|
+
| | | |
|
|
135
|
+
| Control plane | | |
|
|
136
|
+
| API, SQLite, image builds | | |
|
|
137
|
+
| | | |
|
|
138
|
+
| Agent --> Docker Engine | | Agent --> Docker Engine |
|
|
139
|
+
| | | | | |
|
|
140
|
+
| +-- service A | | +-- service C |
|
|
141
|
+
| +-- database | | +-- service D |
|
|
142
|
+
| +-- more... | | +-- more... |
|
|
143
|
+
| (zero or more services) | | (zero or more services) |
|
|
144
|
+
| | | |
|
|
145
|
+
| Local volumes, optional Caddy | | Local volumes, optional Caddy |
|
|
146
|
+
+--------------------------------+ +--------------------------------+
|
|
147
|
+
| |
|
|
148
|
+
+------- private workload network --------+
|
|
149
|
+
|
|
150
|
+
Each agent connects to the control plane over mTLS. Services are
|
|
151
|
+
placed on either node; local volumes stay on their node. app1 can
|
|
152
|
+
run alone. SSH installs control and agents; normal operations use
|
|
153
|
+
the control API.
|
|
154
|
+
```
|
|
69
155
|
|
|
70
|
-
|
|
156
|
+
`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.
|
|
71
157
|
|
|
72
|
-
|
|
158
|
+
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.
|
|
73
159
|
|
|
74
160
|
## Documentation
|
|
75
161
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
- [
|
|
80
|
-
- [
|
|
81
|
-
- [
|
|
82
|
-
-
|
|
83
|
-
- [
|
|
84
|
-
- [
|
|
85
|
-
- [
|
|
86
|
-
-
|
|
87
|
-
- [
|
|
88
|
-
- [
|
|
89
|
-
- [
|
|
90
|
-
- [
|
|
91
|
-
-
|
|
92
|
-
- [
|
|
93
|
-
- [
|
|
94
|
-
- [
|
|
162
|
+
Start with the [single-VPS tutorial](docs/getting-started.md), then choose the topic that matches your next task:
|
|
163
|
+
|
|
164
|
+
- **Deploy and operate**
|
|
165
|
+
- [Application examples](examples/README.md)
|
|
166
|
+
- [Health checks and rollouts](docs/healthchecks.md)
|
|
167
|
+
- [Troubleshooting](docs/troubleshooting.md)
|
|
168
|
+
- **Networking and access**
|
|
169
|
+
- [Hostnames, HTTPS and service networking](docs/networking.md)
|
|
170
|
+
- [Credentials and CI](docs/access-control.md)
|
|
171
|
+
- [Security trust boundaries](docs/security.md)
|
|
172
|
+
- **Servers and data**
|
|
173
|
+
- [Service placement and scaling](docs/service-placement.md)
|
|
174
|
+
- [Node management](docs/nodes.md)
|
|
175
|
+
- [Backups and recovery](docs/backups.md)
|
|
176
|
+
- [Moving to new servers](docs/migration.md)
|
|
177
|
+
- **Reference**
|
|
178
|
+
- [CLI](docs/cli.md)
|
|
179
|
+
- [Compose extensions](docs/compose.md)
|
|
180
|
+
- [Configuration](docs/configuration.md)
|
|
181
|
+
- [Control plane](docs/control-plane.md)
|
|
182
|
+
- **Contribute and design**
|
|
183
|
+
- [Development](docs/development.md)
|
|
184
|
+
- [Testing](docs/testing.md)
|
|
185
|
+
- [Releases](docs/releases.md)
|
|
186
|
+
- [Architecture](docs/architecture.md)
|
|
187
|
+
- [Roadmap](docs/roadmap.md)
|
|
188
|
+
- [Observability status and proposal](docs/observability.md)
|
|
189
|
+
- [Terminology](docs/terminology.md)
|
|
190
|
+
- [Standalone binary proposal](docs/standalone-binary.md)
|
|
191
|
+
- [Docker Swarm comparison](docs/swarm-comparison.md)
|
|
95
192
|
|
|
96
193
|
## Development
|
|
97
194
|
|
package/dist/agent-credential.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
@@ -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
|
|
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
|
}
|
package/dist/backup-check.d.ts
CHANGED
package/dist/backup-config.js
CHANGED
|
@@ -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;
|
|
@@ -184,18 +184,25 @@ export function resolveBackups(compose, project = '') {
|
|
|
184
184
|
return null;
|
|
185
185
|
return { schedule, retention, jitterSeconds, timeoutSec, memory };
|
|
186
186
|
};
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
const volumeTarget = (name, def) => {
|
|
235
242
|
const config = isObject(def) ? def['x-tug']?.backup : undefined;
|
|
236
243
|
if (config === undefined)
|
|
237
|
-
|
|
244
|
+
return null;
|
|
238
245
|
const label = `volumes.${name}: x-tug.backup`;
|
|
239
|
-
const spec =
|
|
240
|
-
if (spec
|
|
241
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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
|
|
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');
|
package/dist/backup-restore.d.ts
CHANGED
|
@@ -31,7 +31,14 @@ export interface RestoreDeps {
|
|
|
31
31
|
clock?: () => Date;
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
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
|
*/
|