@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.
Files changed (78) hide show
  1. package/README.md +100 -35
  2. package/dist/agent-credential.js +3 -3
  3. package/dist/agent.d.ts +5 -7
  4. package/dist/agent.js +45 -67
  5. package/dist/backup-check.d.ts +1 -1
  6. package/dist/backup-config.js +32 -24
  7. package/dist/backup-destination.d.ts +1 -1
  8. package/dist/backup-destination.js +1 -1
  9. package/dist/backup-job.js +17 -5
  10. package/dist/backup-restore.js +69 -69
  11. package/dist/backup-scheduler.js +3 -1
  12. package/dist/cli-build.d.ts +2 -0
  13. package/dist/cli-build.js +15 -2
  14. package/dist/cli-config.d.ts +10 -2
  15. package/dist/cli-config.js +28 -49
  16. package/dist/cli-install.d.ts +10 -5
  17. package/dist/cli-install.js +27 -24
  18. package/dist/cli-live.js +4 -6
  19. package/dist/cli-onboarding.d.ts +31 -0
  20. package/dist/cli-onboarding.js +66 -0
  21. package/dist/cli-prompt.d.ts +9 -0
  22. package/dist/cli-prompt.js +60 -0
  23. package/dist/cli-secrets.d.ts +12 -0
  24. package/dist/cli-secrets.js +71 -0
  25. package/dist/cli-status.d.ts +40 -0
  26. package/dist/cli-status.js +91 -0
  27. package/dist/cli.js +63 -989
  28. package/dist/commands/backup.d.ts +2 -0
  29. package/dist/commands/backup.js +109 -0
  30. package/dist/commands/context.d.ts +54 -0
  31. package/dist/commands/context.js +96 -0
  32. package/dist/commands/control.d.ts +2 -0
  33. package/dist/commands/control.js +110 -0
  34. package/dist/commands/credential.d.ts +2 -0
  35. package/dist/commands/credential.js +36 -0
  36. package/dist/commands/deploy.d.ts +2 -0
  37. package/dist/commands/deploy.js +139 -0
  38. package/dist/commands/host.d.ts +18 -0
  39. package/dist/commands/host.js +99 -0
  40. package/dist/commands/init.d.ts +11 -0
  41. package/dist/commands/init.js +40 -0
  42. package/dist/commands/node.d.ts +28 -0
  43. package/dist/commands/node.js +320 -0
  44. package/dist/commands/observe.d.ts +2 -0
  45. package/dist/commands/observe.js +71 -0
  46. package/dist/commands/restore.d.ts +2 -0
  47. package/dist/commands/restore.js +85 -0
  48. package/dist/commands/secret.d.ts +2 -0
  49. package/dist/commands/secret.js +26 -0
  50. package/dist/commands/service.d.ts +2 -0
  51. package/dist/commands/service.js +62 -0
  52. package/dist/commands/volume.d.ts +2 -0
  53. package/dist/commands/volume.js +13 -0
  54. package/dist/container-spec.d.ts +5 -0
  55. package/dist/container-spec.js +11 -2
  56. package/dist/control-credential.d.ts +2 -0
  57. package/dist/control-credential.js +4 -4
  58. package/dist/control.d.ts +1 -1
  59. package/dist/control.js +96 -143
  60. package/dist/credentials.js +1 -1
  61. package/dist/db.d.ts +0 -10
  62. package/dist/db.js +1 -2
  63. package/dist/diagnostics.js +1 -1
  64. package/dist/ingress.d.ts +1 -2
  65. package/dist/ingress.js +1 -1
  66. package/dist/main.js +1 -1
  67. package/dist/node-address.d.ts +2 -0
  68. package/dist/node-address.js +6 -0
  69. package/dist/pki.d.ts +1 -1
  70. package/dist/pki.js +2 -2
  71. package/dist/protocol.d.ts +2 -34
  72. package/dist/reconciler.js +40 -7
  73. package/dist/runners.d.ts +0 -6
  74. package/dist/runners.js +1 -6
  75. package/dist/templates/control-compose.yml +1 -2
  76. package/package.json +1 -1
  77. package/dist/mesh.d.ts +0 -74
  78. package/dist/mesh.js +0 -219
package/README.md CHANGED
@@ -1,25 +1,99 @@
1
1
  # Tug
2
2
 
3
- The name: tugboats pull ships into place, like the pull-based agents here; `tug` is the CLI.
3
+ Deploy Docker Compose apps to a few ordinary Linux servers, from your laptop or CI, without Kubernetes or a hosted platform.
4
4
 
5
- Deploy Docker Compose applications to one or more ordinary Linux VPSes through a small self-hosted control plane and a pull-based agent on each node, without Kubernetes or a hosted PaaS. Tug runs an agent on each node; the agents drive the node's Docker Engine API directly, and Compose is only used on your machine to read the file. What it is for, the deployment shapes it targets and the planned milestones are in the [Roadmap](docs/roadmap.md).
5
+ You keep your `docker-compose.yml`. Tug installs a small control plane on one server and an agent on each of the others, then `tug deploy` puts your services where you say, waits until they are healthy and rolls back if they are not. It is built for running one to a few dozen sites, WordPress first, on two to ten VPSes, with backups and restores included.
6
6
 
7
- ## Features
7
+ ## What you can do with it
8
8
 
9
- - One mutual-TLS port: every operator, CI job and node authenticates with a certificate from the control plane's private CA, and control authorizes it by public key. Scoped `read`/`deploy`/`admin` operator credentials for CI and teammates, an audit trail naming who acted, and SQLite metadata through Node's built-in driver.
10
- - Nodes join over SSH (`node-install`) or enroll themselves with a single `docker run` after `node-prepare-enrollment`, each with its own certificate and an immutable node ID. Agents only dial out, so nodes behind NAT work.
11
- - Agents pull desired state from control, reconcile containers through the Docker Engine API, run TCP/HTTP/Docker health checks and report status. An outbound WebSocket wakes them as soon as control queues work and carries live `exec`, followed logs, `ps` and `inspect` sessions, with long-poll and periodic-poll fallbacks for ordinary work.
12
- - `x-tug` placement of services and volumes on nodes, with immutable desired-state records per deployment.
13
- - Build application images on control from uploaded source, or locally with `deploy --build-local`. 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
- - 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
- ## How it fits together
17
+ Nodes only dial out to the control plane, so they work behind NAT or a firewall that allows nothing inbound. Tug does not need a cloud provider's API, a registry or an overlay network.
18
+
19
+ ## Try it
20
+
21
+ You need Node.js 22.18 or newer and Docker Compose v2 on your machine, and SSH access to one or more Linux servers (Debian, Ubuntu, Fedora or a RHEL-compatible distribution). Tug can install Docker on them for you.
22
+
23
+ ```bash
24
+ npm install -g @wpelevator/tug # or run it with npx @wpelevator/tug
25
+
26
+ tug setup root@203.0.113.5 # install the control plane on a server
27
+ tug node add root@198.51.100.7 # add a server that will run your apps
28
+ tug deploy # deploy ./docker-compose.yml
29
+ ```
30
+
31
+ In a terminal Tug asks before it changes anything: whether to install Docker, which private address the node should use and whether it should handle ingress. It shows the plan and waits for you to confirm. `tug deploy` asks for any Compose variables, such as `${DB_PASSWORD}`, that no secret or `.env` provides. In CI or any other script nothing is ever asked, so pass the choices as flags (`--address`, `--ingress`, `--install-docker`, `-y`).
32
+
33
+ ## Your Compose file, plus a little `x-tug`
34
+
35
+ A normal Compose file already works. Add `x-tug` to say where services run, how to tell they are healthy and where traffic comes in:
36
+
37
+ ```yaml
38
+ services:
39
+ db:
40
+ image: mariadb:11.4.8
41
+ volumes: [db-data:/var/lib/mysql]
42
+ environment:
43
+ MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
44
+ x-tug:
45
+ placement: { nodes: [app1] }
46
+ health: { type: tcp, port: 3306 }
47
+
48
+ wordpress:
49
+ image: wordpress:6.8.3-apache
50
+ depends_on: [db]
51
+ x-tug:
52
+ placement: { nodes: [app1] }
53
+ health: { type: http, port: 80, path: /wp-includes/version.php }
54
+ ingress: { host: blog.example.com, port: 80, tls: auto }
55
+
56
+ volumes:
57
+ db-data:
58
+ x-tug: { placement: { nodes: [app1] } }
59
+ ```
60
+
61
+ ```bash
62
+ tug secret set DB_ROOT_PASSWORD
63
+ tug deploy
64
+ tug status # what is running where, and anything failing
65
+ tug logs wordpress --follow
66
+ ```
67
+
68
+ A complete WordPress site with scheduled database and uploads backups is in the [WordPress example](examples/wordpress/README.md). Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
69
+
70
+ ## Everyday commands
71
+
72
+ Project commands sit at the top level; everything else reads `tug <subject> <verb>`.
73
+
74
+ | I want to... | Run |
75
+ | --- | --- |
76
+ | Deploy, or undo a deploy | `tug deploy`, `tug rollback` |
77
+ | See what is running and what failed | `tug status`, `tug events`, `tug ps` |
78
+ | Read logs or open a shell | `tug logs <service>`, `tug exec <service> sh` |
79
+ | Run a one-off command | `tug run <service> -- wp cache flush` |
80
+ | Change the number of replicas | `tug scale <service> 3` |
81
+ | Add, list or replace servers | `tug node add`, `tug node list`, `tug node replace <name>` |
82
+ | Add a server I cannot SSH to | `tug node enroll`, then run the printed `docker run` on it |
83
+ | Manage secrets | `tug secret set`, `tug secret list`, `tug secret remove` |
84
+ | Back up now, or restore | `tug backup run`, `tug restore run <target>` |
85
+ | Give CI a deploy key | `tug credential create ci --role deploy` |
86
+ | Check everything is healthy | `tug doctor` |
87
+
88
+ Every command and option is in the [CLI reference](docs/cli.md). `tug status --json` prints raw JSON for scripts.
89
+
90
+ ## Is Tug right for you?
91
+
92
+ Tug fits if you run a handful of Compose apps on VPSes you already pay for, want deploys, rollbacks, secrets and backups handled in one tool, and are happy with a single control server that you back up.
93
+
94
+ It deliberately does not do Kubernetes-style scheduling, autoscaling, distributed storage or database failover, and it does not provision servers through a provider's API. Node-to-node traffic travels over your own private network (Tailscale, a VPC, a LAN), so you own its firewalling and encryption. If you want automatic rescheduling of containers across a cluster, see how it compares with [Docker Swarm](docs/swarm-comparison.md). The goals, principles and planned milestones are in the [Roadmap](docs/roadmap.md).
95
+
96
+ ## How it works
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 |<->| Agent |
115
+ | Agent | | Agent |
42
116
  | Docker Engine | | Docker Engine |
43
117
  | services | | services |
44
118
  | | | managed Caddy ingress |
45
119
  +-------------------+ +-------------------------+
46
- <-> optional WireGuard mesh between nodes
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
- Operators and CI talk to the control API. The control plane stores desired state and builds images; each agent dials out to fetch the work for its node, applies it through the Docker Engine API and reports health back, so nodes behind NAT need no inbound ports. SSH is used only to install the control plane and its nodes. The optional WireGuard mesh and Caddy ingress are described in [Networking](docs/networking.md), and every flow in detail in [Architecture](docs/architecture.md).
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
- 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.
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 nodes you cannot SSH to (`tug node-prepare-enrollment`), CI credentials (`tug credential-create`), moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
131
+ The full walkthrough, including CI credentials, moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
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 restore.
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): WireGuard mesh, cross-node service names, ingress and TLS, firewall notes.
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
 
@@ -5,7 +5,7 @@ import { Agent, fetch as undiciFetch } from 'undici';
5
5
  import { clientTlsOptions, createCsr, generateKeyPair, identityUri, parseCredential, validateInstalledCertificate, writeFileDurable, } from "./pki.js";
6
6
  export const agentFile = (stateDir) => path.join(stateDir, 'agent.json');
7
7
  export const pendingFile = (stateDir) => path.join(stateDir, 'agent-pending.json');
8
- const NO_AGENT_IDENTITY = 'this node has no identity: run `tug node-install`, `tug node-prepare-enrollment` or `tug node-repair-identity` from an operator machine';
8
+ const NO_AGENT_IDENTITY = 'this node has no identity: run `tug node add`, `tug node enroll` or `tug node repair` from an operator machine';
9
9
  async function readJson(file) {
10
10
  let text;
11
11
  try {
@@ -20,7 +20,7 @@ async function readJson(file) {
20
20
  return JSON.parse(text);
21
21
  }
22
22
  catch (e) {
23
- throw new Error(`${file} is corrupt; run \`tug node-repair-identity\` to replace this node's identity`, {
23
+ throw new Error(`${file} is corrupt; run \`tug node repair\` to replace this node's identity`, {
24
24
  cause: e,
25
25
  });
26
26
  }
@@ -37,7 +37,7 @@ export async function loadAgentDocument(stateDir) {
37
37
  return { ...credential, nodeId, nodeName };
38
38
  }
39
39
  catch (e) {
40
- throw new Error(`${agentFile(stateDir)} is invalid (${e instanceof Error ? e.message : String(e)}); run \`tug node-repair-identity\``, { cause: e });
40
+ throw new Error(`${agentFile(stateDir)} is invalid (${e instanceof Error ? e.message : String(e)}); run \`tug node repair\``, { cause: e });
41
41
  }
42
42
  }
43
43
  async function pendingKey(stateDir) {
package/dist/agent.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 { HostRunner, HttpClient } from './runners.ts';
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
- /** Reports this node's WireGuard public key when control has not seen the current one yet. */
100
- export declare function reportWgKey(ctx: AgentContext): Promise<void>;
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-install` does';
503
+ return '; this agent is not running in a container, so it may be unable to reach container addresses (a host process on macOS cannot); run it with `docker run` as `tug node add` does';
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, the overlay IP or a private address unless that is intended`,
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
- await ctx.docker.removeVolume(volumeName(project, volume)).catch((e) => {
704
- if (!isNotFound(e))
705
- throw e;
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}; enable the mesh, give the node a private address, or write the host IP in ports to make the exposure explicit`
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
- /** Reports this node's WireGuard public key when control has not seen the current one yet. */
1074
- export async function reportWgKey(ctx) {
1075
- const { publicKey } = await ensureWgKeys(ctx.stateDir);
1076
- if ((await readReportedKey(ctx.stateDir)) === publicKey)
1077
- return;
1078
- await api(ctx, '/agent/v1/wireguard/key', {
1079
- method: 'POST',
1080
- headers: { 'content-type': 'application/json' },
1081
- body: JSON.stringify({ publicKey }),
1082
- });
1083
- await writeReportedKey(ctx.stateDir, publicKey);
1084
- }
1085
- let lastNetworkError = '';
1086
- async function networkStep(label, fn) {
1087
- try {
1088
- await fn();
1089
- lastNetworkError = '';
1090
- }
1091
- catch (e) {
1092
- const message = `${label}: ${errMessage(e)}`;
1093
- if (message !== lastNetworkError)
1094
- console.error(message);
1095
- lastNetworkError = message;
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], work.ports, work.holds?.[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
  }
@@ -1,4 +1,4 @@
1
- /** The part of `GET /api/v1/backups` that `tug backup-list --check` judges. */
1
+ /** The part of `GET /api/v1/backups` that `tug backup list --check` judges. */
2
2
  export interface BackupCheckInput {
3
3
  destinationConfigured: boolean;
4
4
  targets: {
@@ -184,18 +184,25 @@ export function resolveBackups(compose, project = '') {
184
184
  return null;
185
185
  return { schedule, retention, jitterSeconds, timeoutSec, memory };
186
186
  };
187
- for (const [name, svc] of Object.entries(compose.services)) {
188
- const config = svc['x-tug']?.backup;
189
- if (config === undefined)
190
- continue;
191
- const label = `${name}: x-tug.backup`;
187
+ /** The object form of a backup config; null when it is off, or invalid (the error is reported). */
188
+ const specOf = (label, config) => {
192
189
  const spec = normalizeConfig(config);
193
190
  if (spec === null)
194
- continue;
191
+ return null;
195
192
  if (typeof spec !== 'object' || Array.isArray(spec)) {
196
193
  errors.push(`${label} must be true, false, a cron string or an object`);
197
- continue;
194
+ return null;
198
195
  }
196
+ return spec;
197
+ };
198
+ const serviceTarget = (name, svc) => {
199
+ const config = svc['x-tug']?.backup;
200
+ if (config === undefined)
201
+ return null;
202
+ const label = `${name}: x-tug.backup`;
203
+ const spec = specOf(label, config);
204
+ if (!spec)
205
+ return null;
199
206
  const before = errors.length;
200
207
  const base = common(label, 'service', name, spec);
201
208
  const argv = spec.command === undefined ? null : commandArgv(spec.command);
@@ -216,8 +223,8 @@ export function resolveBackups(compose, project = '') {
216
223
  if (global || replicas > 1 || nodes.length > 1)
217
224
  errors.push(`${label} needs a single instance; ${name} is global, replicated or placed on several nodes`);
218
225
  if (errors.length > before || !base || !argv)
219
- continue;
220
- targets.push({
226
+ return null;
227
+ return {
221
228
  target: `service:${name}`,
222
229
  kind: 'service',
223
230
  name,
@@ -229,20 +236,16 @@ export function resolveBackups(compose, project = '') {
229
236
  verify: spec.verify,
230
237
  writers: spec.writers,
231
238
  node: !dynamic && nodes.length === 1 ? nodes[0] : undefined,
232
- });
233
- }
234
- for (const [name, def] of Object.entries(compose.volumes || {})) {
239
+ };
240
+ };
241
+ const volumeTarget = (name, def) => {
235
242
  const config = isObject(def) ? def['x-tug']?.backup : undefined;
236
243
  if (config === undefined)
237
- continue;
244
+ return null;
238
245
  const label = `volumes.${name}: x-tug.backup`;
239
- const spec = normalizeConfig(config);
240
- if (spec === null)
241
- continue;
242
- if (typeof spec !== 'object' || Array.isArray(spec)) {
243
- errors.push(`${label} must be true, false, a cron string or an object`);
244
- continue;
245
- }
246
+ const spec = specOf(label, config);
247
+ if (!spec)
248
+ return null;
246
249
  const before = errors.length;
247
250
  const base = common(label, 'volume', name, spec);
248
251
  if (spec.exclude !== undefined &&
@@ -260,8 +263,8 @@ export function resolveBackups(compose, project = '') {
260
263
  else if (!placed.length && dynamicPlacement)
261
264
  errors.push(`${label} needs x-tug.placement.nodes on volume ${name}, since its services are placed by label or global`);
262
265
  if (errors.length > before || !base)
263
- continue;
264
- targets.push({
266
+ return null;
267
+ return {
265
268
  target: `volume:${name}`,
266
269
  kind: 'volume',
267
270
  name,
@@ -270,7 +273,12 @@ export function resolveBackups(compose, project = '') {
270
273
  exclude: spec.exclude,
271
274
  writers: spec.writers,
272
275
  node: candidates.length === 1 ? candidates[0] : undefined,
273
- });
274
- }
276
+ };
277
+ };
278
+ const found = [
279
+ ...Object.entries(compose.services).map(([name, svc]) => serviceTarget(name, svc)),
280
+ ...Object.entries(compose.volumes || {}).map(([name, def]) => volumeTarget(name, def)),
281
+ ];
282
+ targets.push(...found.filter((target) => target !== null));
275
283
  return { targets, errors };
276
284
  }
@@ -15,7 +15,7 @@ export declare function projectRepository(destinationUrl: string, project: strin
15
15
  /**
16
16
  * The repository password of a project, derived from the control master key so nothing extra is stored and
17
17
  * a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
18
- * repository, which is why `tug backup-repository-password` exists for offline escrow.
18
+ * repository, which is why `tug backup password` exists for offline escrow.
19
19
  */
20
20
  export declare function repositoryPassword(masterKey: string, project: string): string;
21
21
  /** Removes every known secret value from text that is about to be stored, shown or put in an event. */
@@ -54,7 +54,7 @@ export function projectRepository(destinationUrl, project) {
54
54
  /**
55
55
  * The repository password of a project, derived from the control master key so nothing extra is stored and
56
56
  * a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
57
- * repository, which is why `tug backup-repository-password` exists for offline escrow.
57
+ * repository, which is why `tug backup password` exists for offline escrow.
58
58
  */
59
59
  export function repositoryPassword(masterKey, project) {
60
60
  return createHmac('sha256', masterKey).update(`tug-restic:${project}`).digest('hex');
@@ -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
- if (!(await docker.inspectVolume(dockerVolume)))
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', {