@wpelevator/tug 0.9.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 +100 -35
- package/dist/agent-credential.js +3 -3
- package/dist/agent.d.ts +5 -7
- package/dist/agent.js +45 -67
- 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-job.js +17 -5
- package/dist/backup-restore.js +69 -69
- package/dist/backup-scheduler.js +3 -1
- package/dist/cli-build.d.ts +2 -0
- package/dist/cli-build.js +15 -2
- package/dist/cli-config.d.ts +10 -2
- package/dist/cli-config.js +28 -49
- package/dist/cli-install.d.ts +10 -5
- package/dist/cli-install.js +27 -24
- 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 +40 -0
- package/dist/cli-status.js +91 -0
- package/dist/cli.js +63 -989
- 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/container-spec.d.ts +5 -0
- package/dist/container-spec.js +11 -2
- package/dist/control-credential.d.ts +2 -0
- package/dist/control-credential.js +4 -4
- package/dist/control.d.ts +1 -1
- package/dist/control.js +96 -143
- package/dist/credentials.js +1 -1
- package/dist/db.d.ts +0 -10
- package/dist/db.js +1 -2
- package/dist/diagnostics.js +1 -1
- package/dist/ingress.d.ts +1 -2
- package/dist/ingress.js +1 -1
- package/dist/main.js +1 -1
- package/dist/node-address.d.ts +2 -0
- package/dist/node-address.js +6 -0
- package/dist/pki.d.ts +1 -1
- package/dist/pki.js +2 -2
- package/dist/protocol.d.ts +2 -34
- package/dist/reconciler.js +40 -7
- package/dist/runners.d.ts +0 -6
- package/dist/runners.js +1 -6
- package/dist/templates/control-compose.yml +1 -2
- package/package.json +1 -1
- package/dist/mesh.d.ts +0 -74
- package/dist/mesh.js +0 -219
package/README.md
CHANGED
|
@@ -1,25 +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
|
-
- Optional WireGuard mesh between nodes, health-aware cross-node service names (hosts-file entries injected into containers) and a managed Caddy ingress with automatic HTTPS, each opt-in. See [Networking](docs/networking.md).
|
|
19
|
-
- Private-by-default published ports: generated Compose binds them to the overlay IP, loopback or the node address, never every interface unless you write `0.0.0.0`. 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.
|
|
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.
|
|
21
16
|
|
|
22
|
-
|
|
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
|
|
23
97
|
|
|
24
98
|
```text
|
|
25
99
|
tug CLI / CI (operator credential)
|
|
@@ -38,33 +112,23 @@ Deploy Docker Compose applications to one or more ordinary Linux VPSes through a
|
|
|
38
112
|
| |
|
|
39
113
|
+------+------------+ +------+------------------+
|
|
40
114
|
| Node app1 | | Node app2 (ingress node)|
|
|
41
|
-
| Agent
|
|
115
|
+
| Agent | | Agent |
|
|
42
116
|
| Docker Engine | | Docker Engine |
|
|
43
117
|
| services | | services |
|
|
44
118
|
| | | managed Caddy ingress |
|
|
45
119
|
+-------------------+ +-------------------------+
|
|
46
|
-
|
|
120
|
+
\_________________________/
|
|
121
|
+
workload traffic between node addresses
|
|
122
|
+
over the operator's private network
|
|
47
123
|
|
|
48
124
|
SSH is used only by the CLI to install the control plane and nodes.
|
|
49
125
|
```
|
|
50
126
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
## Quick start
|
|
54
|
-
|
|
55
|
-
Install the CLI (Node >= 22.18), or run it without installing through `npx @wpelevator/tug`:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
npm install -g @wpelevator/tug
|
|
59
|
-
tug control-install root@203.0.113.5 --advertise-url https://203.0.113.5:8443 --install-docker
|
|
60
|
-
tug init tug-root.credential.json
|
|
61
|
-
tug node-install root@198.51.100.7 --name app1 --install-docker
|
|
62
|
-
tug deploy -f docker-compose.yml
|
|
63
|
-
```
|
|
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.
|
|
64
128
|
|
|
65
|
-
|
|
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.
|
|
66
130
|
|
|
67
|
-
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).
|
|
68
132
|
|
|
69
133
|
## Documentation
|
|
70
134
|
|
|
@@ -72,11 +136,11 @@ The full walkthrough, including nodes you cannot SSH to (`tug node-prepare-enrol
|
|
|
72
136
|
- [Terminology](docs/terminology.md): core concepts, the naming review (which recommendations are implemented), industry comparisons and the remaining proposed interface improvements.
|
|
73
137
|
- [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
|
|
74
138
|
- [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
|
|
75
|
-
- [Control plane](docs/control-plane.md): install, mutual TLS and the private CA, image delivery, control-plane backup and
|
|
139
|
+
- [Control plane](docs/control-plane.md): install, mutual TLS and the private CA, image delivery, control-plane backup and recovery on another host.
|
|
76
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).
|
|
77
141
|
- [Access control](docs/access-control.md): certificate identity classes, scoped operator credentials, roles, audit trail and a GitHub Actions deploy example.
|
|
78
142
|
- [Security](docs/security.md): deployment trust boundaries, security audit findings and validation limits.
|
|
79
|
-
- [Networking](docs/networking.md):
|
|
143
|
+
- [Networking](docs/networking.md): bring-your-own private networking, cross-node service names, ingress and TLS, firewall notes.
|
|
80
144
|
- [Nodes](docs/nodes.md): node names and IDs, SSH install, self-service enrollment, certificate renewal and recovery, registration and status.
|
|
81
145
|
- [CLI reference](docs/cli.md): every `tug` command and option.
|
|
82
146
|
- [Configuration](docs/configuration.md): CLI, control and agent environment variables.
|
|
@@ -86,6 +150,7 @@ The full walkthrough, including nodes you cannot SSH to (`tug node-prepare-enrol
|
|
|
86
150
|
- [Releases](docs/releases.md): changesets, npm and Docker image publishing.
|
|
87
151
|
- [Standalone binary](docs/standalone-binary.md): proposed Bun-compiled CLI binaries and Homebrew distribution, with the mTLS dependency findings.
|
|
88
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.
|
|
89
154
|
|
|
90
155
|
## Development
|
|
91
156
|
|
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.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ContainerSummary, DockerApi } from './docker-api.ts';
|
|
|
6
6
|
import type { AgentLiveCommand, AgentLiveFrame } from './protocol.ts';
|
|
7
7
|
import type { LiveEngineOptions } from './live.ts';
|
|
8
8
|
import { parseDuration } from './util.ts';
|
|
9
|
-
import type {
|
|
9
|
+
import type { HttpClient } from './runners.ts';
|
|
10
10
|
import type { Command, CommandResult, DesiredState, HealthCheck, ProjectStatus, ImageArtifact, PortBinding, ServiceEndpoint } from './protocol.ts';
|
|
11
11
|
export interface AgentContext {
|
|
12
12
|
docker: DockerApi;
|
|
@@ -17,10 +17,6 @@ export interface AgentContext {
|
|
|
17
17
|
controlUrl: string;
|
|
18
18
|
/** The node credential presented to control; absent only in tests with a fake `http`. */
|
|
19
19
|
credential?: Credential;
|
|
20
|
-
/** Runs `wg` / `ip` for the mesh. */
|
|
21
|
-
host?: HostRunner;
|
|
22
|
-
/** Manage the WireGuard mesh (generate a key, report it, reconcile `wg0`). */
|
|
23
|
-
mesh?: boolean;
|
|
24
20
|
caddyImage?: string;
|
|
25
21
|
/** Host port range (`LOW-HIGH`) for rolling-rollout revisions; default 20000-20999. */
|
|
26
22
|
rolloutPorts?: string;
|
|
@@ -96,8 +92,10 @@ export declare function resolveBindAddress(address: string | null, lookup?: (hos
|
|
|
96
92
|
address: string;
|
|
97
93
|
}>): Promise<string>;
|
|
98
94
|
export declare function reconcile(desiredState: DesiredState, ctx?: AgentContext, endpoints?: Record<string, ServiceEndpoint[]>, ports?: PortBinding, held?: string[]): Promise<ProjectStatus>;
|
|
99
|
-
/**
|
|
100
|
-
export declare function
|
|
95
|
+
/** Docker `extra_hosts` values must be IPs, so resolve hostname node addresses on this agent. */
|
|
96
|
+
export declare function endpointAddressesForAgent(endpoints?: Record<string, ServiceEndpoint[]>, localServices?: Set<string>, lookup?: (host: string) => Promise<{
|
|
97
|
+
address: string;
|
|
98
|
+
}>): Promise<Record<string, ServiceEndpoint[]>>;
|
|
101
99
|
export declare function pollOnce(ctx?: AgentContext): Promise<ProjectStatus[]>;
|
|
102
100
|
/** Loads the persisted identity, or enrolls with a join key. Without either the agent refuses to start. */
|
|
103
101
|
export declare function loadOrEnroll(): Promise<AgentDocument>;
|
package/dist/agent.js
CHANGED
|
@@ -13,7 +13,7 @@ import { agentIdentityCsr, createTransport, describeMissingIdentity, installIden
|
|
|
13
13
|
import { clientTlsOptions, parseCredential } from "./pki.js";
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
|
|
16
|
-
import { LABEL_PROJECT, LABEL_SERVICE, containerSpecs, networkName, volumeName, } from "./container-spec.js";
|
|
16
|
+
import { LABEL_PROJECT, LABEL_SERVICE, assertVolumeOwnership, containerSpecs, networkName, volumeName, } from "./container-spec.js";
|
|
17
17
|
import { API_VERSION, DockerEngine, isNotFound } from "./docker-api.js";
|
|
18
18
|
import { JobRunner } from "./agent-jobs.js";
|
|
19
19
|
import { LIVE_MAX_FRAME_BYTES, LIVE_START_TIMEOUT_MS } from "./protocol.js";
|
|
@@ -21,17 +21,13 @@ import { LiveOperationEngine } from "./live.js";
|
|
|
21
21
|
import { activeRollingStates, clearRolloutState, ensureProjectResources, loadRolloutState, projectContainers, reconcileRecreate, reconcileRolling, sweepOrphans, } from "./reconciler.js";
|
|
22
22
|
import { ensureDir, errMessage, isPublicAddress, now, parseDuration } from "./util.js";
|
|
23
23
|
import { openAgentDb } from "./db.js";
|
|
24
|
-
import { createHostRunner } from "./runners.js";
|
|
25
24
|
import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
|
|
26
|
-
import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey, } from "./mesh.js";
|
|
27
25
|
// Connect-only override (for example an SSH tunnel); the certificate is still verified against the credential's public URL.
|
|
28
26
|
const CONTROL_URL_OVERRIDE = process.env.TUG_CONTROL_URL;
|
|
29
27
|
let NODE_NAME = process.env.TUG_NODE_NAME || process.env.HOSTNAME || 'node1';
|
|
30
28
|
const STATE_DIR = process.env.TUG_STATE_DIR || '/var/lib/tug';
|
|
31
29
|
const APPLY = process.env.TUG_APPLY !== 'false';
|
|
32
30
|
const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
|
|
33
|
-
// Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
|
|
34
|
-
const MESH = process.env.TUG_MESH === 'true';
|
|
35
31
|
let activeDocument;
|
|
36
32
|
export function defaultContext(doc = activeDocument) {
|
|
37
33
|
if (!doc)
|
|
@@ -45,8 +41,6 @@ export function defaultContext(doc = activeDocument) {
|
|
|
45
41
|
apply: APPLY,
|
|
46
42
|
controlUrl: transport.base,
|
|
47
43
|
credential: doc,
|
|
48
|
-
host: createHostRunner(),
|
|
49
|
-
mesh: MESH,
|
|
50
44
|
caddyImage: process.env.TUG_CADDY_IMAGE,
|
|
51
45
|
rolloutPorts: process.env.TUG_ROLLOUT_PORTS,
|
|
52
46
|
};
|
|
@@ -506,7 +500,7 @@ export function runsInContainer() {
|
|
|
506
500
|
export function outsideContainerHint(health, inContainer = runsInContainer()) {
|
|
507
501
|
if (inContainer || health.type === 'docker')
|
|
508
502
|
return '';
|
|
509
|
-
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';
|
|
510
504
|
}
|
|
511
505
|
export async function waitForHealth(ctx, project, container, service, health) {
|
|
512
506
|
if (!ctx.apply || !health)
|
|
@@ -591,7 +585,7 @@ async function publicPortsCheck(ctx) {
|
|
|
591
585
|
name: 'ports.public',
|
|
592
586
|
ok: true,
|
|
593
587
|
stdout: stdout.slice(-4000),
|
|
594
|
-
warning: `${listeners.length} published port${listeners.length === 1 ? ' of Tug projects listens' : 's of Tug projects listen'} on a public address; bind them to 127.0.0.1
|
|
588
|
+
warning: `${listeners.length} published port${listeners.length === 1 ? ' of Tug projects listens' : 's of Tug projects listen'} on a public address; bind them to 127.0.0.1 or a private node address unless that is intended`,
|
|
595
589
|
}
|
|
596
590
|
: {
|
|
597
591
|
name: 'ports.public',
|
|
@@ -633,10 +627,6 @@ export async function executeCommand(command, ctx = defaultContext()) {
|
|
|
633
627
|
}
|
|
634
628
|
}
|
|
635
629
|
const extra = [];
|
|
636
|
-
if (ctx.mesh && ctx.host) {
|
|
637
|
-
const host = ctx.host;
|
|
638
|
-
extra.push(['wireguard.version', () => host('wg', ['--version'])], ['wireguard.interface', () => host('wg', ['show', WG_INTERFACE])]);
|
|
639
|
-
}
|
|
640
630
|
if (await fs.stat(path.join(ctx.stateDir, 'ingress')).then(() => true, () => false)) {
|
|
641
631
|
extra.push([
|
|
642
632
|
'ingress.caddy',
|
|
@@ -700,28 +690,21 @@ export async function executeCommand(command, ctx = defaultContext()) {
|
|
|
700
690
|
if (command.type === 'volume.delete') {
|
|
701
691
|
const { project, volume } = payload;
|
|
702
692
|
// Volumes are never removed by reconciliation; this explicit command is the only way one goes away.
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
693
|
+
const name = volumeName(project, volume);
|
|
694
|
+
const existing = await ctx.docker.inspectVolume(name);
|
|
695
|
+
if (existing)
|
|
696
|
+
assertVolumeOwnership(existing, project, volume);
|
|
697
|
+
if (existing)
|
|
698
|
+
await ctx.docker.removeVolume(name).catch((e) => {
|
|
699
|
+
if (!isNotFound(e))
|
|
700
|
+
throw e;
|
|
701
|
+
});
|
|
707
702
|
return {
|
|
708
703
|
commandId: command.id,
|
|
709
704
|
state: 'succeeded',
|
|
710
705
|
result: { project, volume, deleted: true },
|
|
711
706
|
};
|
|
712
707
|
}
|
|
713
|
-
if (command.type === 'wg.rotate-key') {
|
|
714
|
-
if (!ctx.mesh)
|
|
715
|
-
throw new Error('the WireGuard mesh is not enabled on this agent (TUG_MESH=true)');
|
|
716
|
-
const { publicKey } = await rotateWgKeys(ctx.stateDir);
|
|
717
|
-
await api(ctx, '/agent/v1/wireguard/key', {
|
|
718
|
-
method: 'POST',
|
|
719
|
-
headers: { 'content-type': 'application/json' },
|
|
720
|
-
body: JSON.stringify({ publicKey }),
|
|
721
|
-
});
|
|
722
|
-
await writeReportedKey(ctx.stateDir, publicKey);
|
|
723
|
-
return { commandId: command.id, state: 'succeeded', result: { publicKey } };
|
|
724
|
-
}
|
|
725
708
|
return {
|
|
726
709
|
commandId: command.id,
|
|
727
710
|
state: 'failed',
|
|
@@ -1051,7 +1034,7 @@ async function warnPublicPorts(ctx, desiredState, serviceNames, bind) {
|
|
|
1051
1034
|
? addressBoundServices(desiredState.compose, serviceNames, bind.loopback)
|
|
1052
1035
|
: [];
|
|
1053
1036
|
const message = exposed.length
|
|
1054
|
-
? `published ports of ${exposed.join(', ')} listen on public address ${bind.address};
|
|
1037
|
+
? `published ports of ${exposed.join(', ')} listen on public address ${bind.address}; give the node a private address, or write the host IP in ports to make the exposure explicit`
|
|
1055
1038
|
: '';
|
|
1056
1039
|
if ((publicPortWarnings.get(key) ?? '') === message)
|
|
1057
1040
|
return;
|
|
@@ -1070,44 +1053,44 @@ async function warnPublicPorts(ctx, desiredState, serviceNames, bind) {
|
|
|
1070
1053
|
}),
|
|
1071
1054
|
}).catch(() => { });
|
|
1072
1055
|
}
|
|
1073
|
-
/**
|
|
1074
|
-
export async function
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1056
|
+
/** Docker `extra_hosts` values must be IPs, so resolve hostname node addresses on this agent. */
|
|
1057
|
+
export async function endpointAddressesForAgent(endpoints = {}, localServices = new Set(), lookup = (host) => dns.lookup(host)) {
|
|
1058
|
+
const cache = new Map();
|
|
1059
|
+
const resolve = (address) => {
|
|
1060
|
+
if (net.isIP(address))
|
|
1061
|
+
return Promise.resolve(address);
|
|
1062
|
+
let resolved = cache.get(address);
|
|
1063
|
+
if (!resolved) {
|
|
1064
|
+
resolved = lookup(address).then(({ address: ip }) => ip, (error) => {
|
|
1065
|
+
throw new Error(`cannot resolve node address ${address} for cross-node service names: ${errMessage(error)}`, { cause: error });
|
|
1066
|
+
});
|
|
1067
|
+
cache.set(address, resolved);
|
|
1068
|
+
}
|
|
1069
|
+
return resolved;
|
|
1070
|
+
};
|
|
1071
|
+
return Object.fromEntries(await Promise.all(Object.entries(endpoints)
|
|
1072
|
+
.filter(([service]) => !localServices.has(service))
|
|
1073
|
+
.map(async ([service, list]) => [
|
|
1074
|
+
service,
|
|
1075
|
+
(await Promise.all(list.map(async (ep) => {
|
|
1076
|
+
try {
|
|
1077
|
+
return { ...ep, address: await resolve(ep.address) };
|
|
1078
|
+
}
|
|
1079
|
+
catch (error) {
|
|
1080
|
+
console.error(errMessage(error));
|
|
1081
|
+
return null;
|
|
1082
|
+
}
|
|
1083
|
+
}))).filter((ep) => ep !== null),
|
|
1084
|
+
])));
|
|
1097
1085
|
}
|
|
1098
1086
|
export async function pollOnce(ctx = defaultContext()) {
|
|
1099
|
-
if (ctx.mesh)
|
|
1100
|
-
await networkStep('wireguard key report failed', () => reportWgKey(ctx));
|
|
1101
1087
|
const work = await api(ctx, '/agent/v1/work');
|
|
1102
|
-
// The mesh comes up first so cross-node service endpoints are routable when containers start.
|
|
1103
|
-
if (ctx.mesh && work.network) {
|
|
1104
|
-
const network = work.network;
|
|
1105
|
-
await networkStep('wireguard reconcile failed', () => reconcileMesh(ctx.host ?? createHostRunner(), ctx.stateDir, network));
|
|
1106
|
-
}
|
|
1107
1088
|
const projects = [];
|
|
1108
1089
|
for (const desired of work.desiredStates || (work.desiredState ? [work.desiredState] : [])) {
|
|
1109
1090
|
try {
|
|
1110
|
-
projects.push(await reconcile(desired, ctx, work.serviceEndpoints?.[desired.project],
|
|
1091
|
+
projects.push(await reconcile(desired, ctx, await endpointAddressesForAgent(work.serviceEndpoints?.[desired.project], new Set(Object.entries(desired.services)
|
|
1092
|
+
.filter(([, spec]) => spec.placement?.nodes?.includes(ctx.nodeName))
|
|
1093
|
+
.map(([name]) => name))), work.ports, work.holds?.[desired.project]));
|
|
1111
1094
|
}
|
|
1112
1095
|
catch (e) {
|
|
1113
1096
|
projects.push({
|
|
@@ -1198,12 +1181,9 @@ async function enroll(joinKey) {
|
|
|
1198
1181
|
const transport = createTransport(decoded.credential, CONTROL_URL_OVERRIDE || decoded.connectUrl);
|
|
1199
1182
|
console.log(`enrolling with ${decoded.credential.url} as ${decoded.name}`);
|
|
1200
1183
|
const csr = await agentIdentityCsr(STATE_DIR);
|
|
1201
|
-
const wgPublicKey = MESH ? (await ensureWgKeys(STATE_DIR)).publicKey : undefined;
|
|
1202
1184
|
const body = {
|
|
1203
1185
|
csr,
|
|
1204
1186
|
address: process.env.TUG_NODE_ADDRESS,
|
|
1205
|
-
publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS,
|
|
1206
|
-
wgPublicKey,
|
|
1207
1187
|
};
|
|
1208
1188
|
try {
|
|
1209
1189
|
const res = await transport.http(`${transport.base}/agent/v1/enroll`, {
|
|
@@ -1214,8 +1194,6 @@ async function enroll(joinKey) {
|
|
|
1214
1194
|
if (!res.ok)
|
|
1215
1195
|
throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
|
|
1216
1196
|
const doc = await installIdentity(STATE_DIR, (await res.json()));
|
|
1217
|
-
if (wgPublicKey)
|
|
1218
|
-
await writeReportedKey(STATE_DIR, wgPublicKey);
|
|
1219
1197
|
console.log(`enrolled as node ${doc.nodeName}`);
|
|
1220
1198
|
return doc;
|
|
1221
1199
|
}
|
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-job.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Transform } from 'node:stream';
|
|
2
2
|
import { redactSecrets } from "./backup-destination.js";
|
|
3
|
-
import { LABEL_JOB, LABEL_PROJECT, LABEL_SERVICE } from "./container-spec.js";
|
|
3
|
+
import { LABEL_JOB, LABEL_PROJECT, LABEL_SERVICE, assertVolumeOwnership, volumeName, } from "./container-spec.js";
|
|
4
4
|
/** Docker volume holding restic's cache across jobs, so repeated runs do not re-read the repository index. */
|
|
5
5
|
export const RESTIC_CACHE_VOLUME = 'tug_restic_cache';
|
|
6
6
|
const TAIL_BYTES = 4096;
|
|
@@ -148,14 +148,26 @@ export async function runBackupJob(payload, commandId, ctx, kind = 'backup.run')
|
|
|
148
148
|
if (scratch) {
|
|
149
149
|
if (!dockerVolume)
|
|
150
150
|
throw new JobError('a scratch restore needs a scratch volume name');
|
|
151
|
-
|
|
151
|
+
let existing = await docker.inspectVolume(dockerVolume);
|
|
152
|
+
if (!existing) {
|
|
152
153
|
await docker.createVolume(dockerVolume, {
|
|
153
154
|
labels: { 'tug.project': payload.project, 'tug.restore': payload.runId },
|
|
154
155
|
});
|
|
156
|
+
existing = await docker.inspectVolume(dockerVolume);
|
|
157
|
+
}
|
|
158
|
+
if (existing?.Labels?.[LABEL_PROJECT] !== payload.project ||
|
|
159
|
+
existing.Labels?.['tug.restore'] !== payload.runId)
|
|
160
|
+
throw new JobError(`scratch volume ${dockerVolume} is not owned by this restore`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
if (dockerVolume !== volumeName(payload.project, payload.volume.name))
|
|
164
|
+
throw new JobError('volume name does not match the project and target');
|
|
165
|
+
const existing = await docker.inspectVolume(dockerVolume);
|
|
166
|
+
if (!existing)
|
|
167
|
+
// Docker would silently create an empty volume for a missing name and the backup would succeed with nothing in it.
|
|
168
|
+
throw new JobError(`volume ${dockerVolume} does not exist on this node; a volume is created when a service that uses it is placed here`);
|
|
169
|
+
assertVolumeOwnership(existing, payload.project, payload.volume.name);
|
|
155
170
|
}
|
|
156
|
-
else if (!(await docker.inspectVolume(dockerVolume)))
|
|
157
|
-
// Docker would silently create an empty volume for a missing name and the backup would succeed with nothing in it.
|
|
158
|
-
throw new JobError(`volume ${dockerVolume} does not exist on this node; a volume is created when a service that uses it is placed here`);
|
|
159
171
|
binds.push(`${dockerVolume}:/data${kind === 'backup.restore' ? '' : ':ro'}`);
|
|
160
172
|
}
|
|
161
173
|
const restic = await helper('restic', {
|