@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.
- package/README.md +93 -34
- package/dist/agent-credential.js +3 -3
- package/dist/agent.js +1 -1
- package/dist/backup-check.d.ts +1 -1
- package/dist/backup-config.js +32 -24
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.js +65 -65
- package/dist/backup-scheduler.js +1 -1
- package/dist/cli-build.js +1 -1
- package/dist/cli-config.d.ts +6 -1
- package/dist/cli-config.js +18 -44
- 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 +62 -981
- package/dist/commands/backup.d.ts +2 -0
- package/dist/commands/backup.js +109 -0
- package/dist/commands/context.d.ts +54 -0
- package/dist/commands/context.js +96 -0
- package/dist/commands/control.d.ts +2 -0
- package/dist/commands/control.js +110 -0
- package/dist/commands/credential.d.ts +2 -0
- package/dist/commands/credential.js +36 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +139 -0
- package/dist/commands/host.d.ts +18 -0
- package/dist/commands/host.js +99 -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 +320 -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 +6 -6
- package/dist/diagnostics.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,26 +1,99 @@
|
|
|
1
1
|
# Tug
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
7
|
+
## What you can do with it
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
- `
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
package/dist/backup-check.d.ts
CHANGED
package/dist/backup-config.js
CHANGED
|
@@ -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.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 } : {});
|
package/dist/backup-scheduler.js
CHANGED
|
@@ -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
|
|
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
|
|
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. */
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/cli-config.js
CHANGED
|
@@ -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
|
|
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
|
|
131
|
+
if (SETTINGS[key]?.commands.includes(commandPath(command)) &&
|
|
158
132
|
attributes.has(key) &&
|
|
159
133
|
command.getOptionValueSource(key) !== 'cli')
|
|
160
134
|
values[key] = value;
|
package/dist/cli-install.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
*/
|