@wpelevator/tug 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -66
- package/dist/agent.js +38 -2
- package/dist/backup-config.js +1 -1
- package/dist/backup-restore.d.ts +8 -1
- package/dist/backup-restore.js +72 -8
- package/dist/cli-config.d.ts +1 -1
- package/dist/cli-config.js +7 -2
- package/dist/cli.js +4 -0
- package/dist/commands/context.d.ts +4 -0
- package/dist/commands/context.js +15 -0
- package/dist/commands/control.js +2 -1
- package/dist/commands/deploy.d.ts +4 -0
- package/dist/commands/deploy.js +28 -8
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +13 -0
- package/dist/commands/host.d.ts +6 -0
- package/dist/commands/host.js +23 -0
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +13 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/node.js +38 -4
- package/dist/control.js +139 -8
- package/dist/credentials.js +2 -0
- package/dist/docker-api.d.ts +1 -0
- package/dist/live.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Tug
|
|
2
2
|
|
|
3
|
-
Deploy Docker Compose apps to
|
|
3
|
+
Deploy Docker Compose apps to your own Linux VPS, from your laptop or CI, without Kubernetes or a hosted platform.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A VPS is a durable computer with enough resources to host multiple sites or a medium-size application and its database. Tug helps you use that capacity: keep your Compose file, deploy with one command, and manage rollouts, secrets, backups and restores. Start with one server and add more when you need them. Tug is built for a small number of servers and Compose applications, including Node.js, Laravel, Ruby and WordPress apps.
|
|
6
|
+
|
|
7
|
+
Tug runs a small control plane and an agent, which can share the same VPS with your applications. It builds application images on the control server or your machine and delivers them to your nodes, so you do not need to run or configure an image registry for those builds. Published images are still pulled from their upstream registries.
|
|
6
8
|
|
|
7
9
|
## What you can do with it
|
|
8
10
|
|
|
@@ -14,48 +16,73 @@ You keep your `docker-compose.yml`. Tug installs a small control plane on one se
|
|
|
14
16
|
- **Look inside running services.** `tug logs`, `tug exec`, `tug run` (for example `wp` CLI), `tug ps` and `tug status` work through the control plane, so you never SSH to a node to see what is going on.
|
|
15
17
|
- **Give CI a limited key.** `tug credential create` makes read-only, deploy-only or admin credentials, and every change records who made it.
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
Agents only dial out to the control plane, so nodes can work behind NAT or a firewall that allows nothing inbound. Tug does not need a cloud provider's API or an overlay network.
|
|
18
20
|
|
|
19
21
|
## Try it
|
|
20
22
|
|
|
21
|
-
You need Node.js 22.18 or newer and Docker Compose v2 on your
|
|
23
|
+
Follow [Deploy your first service](docs/getting-started.md) to install Tug, deploy nginx to one VPS and verify that it works. You need Node.js 22.18 or newer and Docker Compose v2 on your computer, plus SSH access to a Linux server. Tug can install Docker on supported servers.
|
|
22
24
|
|
|
23
25
|
```bash
|
|
24
26
|
npm install -g @wpelevator/tug # or run it with npx @wpelevator/tug
|
|
25
27
|
|
|
26
|
-
tug setup root@203.0.113.5 # install the control plane
|
|
27
|
-
tug node add root@
|
|
28
|
-
tug deploy # deploy
|
|
28
|
+
tug setup root@203.0.113.5 # install the control plane
|
|
29
|
+
tug node add root@203.0.113.5 --name app1 # install the agent on the same VPS
|
|
30
|
+
tug deploy # deploy a Compose file in this directory
|
|
29
31
|
```
|
|
30
32
|
|
|
33
|
+
The control plane and agent share the VPS with your application. Once your service is running, you can [add HTTPS](docs/networking.md#layer-b-ingress-and-tls), [back up your data](docs/backups.md) or [add another server](docs/service-placement.md).
|
|
34
|
+
|
|
31
35
|
In a terminal Tug asks before it changes anything: whether to install Docker, which private address the node should use and whether it should handle ingress. It shows the plan and waits for you to confirm. `tug deploy` asks for any Compose variables, such as `${DB_PASSWORD}`, that no secret or `.env` provides. In CI or any other script nothing is ever asked, so pass the choices as flags (`--address`, `--ingress`, `--install-docker`, `-y`).
|
|
32
36
|
|
|
33
37
|
## Your Compose file, plus a little `x-tug`
|
|
34
38
|
|
|
35
|
-
|
|
39
|
+
This example builds on the [quickstart](docs/getting-started.md). It pins the app, database and database volume to `app1`. Placement can be omitted with one node, but explicit placement keeps the database on its chosen server when you add more nodes. Enable ingress with `tug node ingress app1`, point the hostname's DNS at the VPS, and allow public ports 80 and 443. Save this as `compose.yaml` and replace the hostname with yours:
|
|
36
40
|
|
|
37
41
|
```yaml
|
|
38
42
|
services:
|
|
39
43
|
db:
|
|
40
44
|
image: mariadb:11.4.8
|
|
41
|
-
volumes:
|
|
45
|
+
volumes:
|
|
46
|
+
- db-data:/var/lib/mysql
|
|
42
47
|
environment:
|
|
43
48
|
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
44
49
|
x-tug:
|
|
45
|
-
placement:
|
|
46
|
-
|
|
50
|
+
placement:
|
|
51
|
+
nodes:
|
|
52
|
+
- app1
|
|
53
|
+
health:
|
|
54
|
+
type: tcp
|
|
55
|
+
port: 3306
|
|
47
56
|
|
|
48
57
|
wordpress:
|
|
49
58
|
image: wordpress:6.8.3-apache
|
|
50
|
-
depends_on:
|
|
59
|
+
depends_on:
|
|
60
|
+
- db
|
|
61
|
+
environment:
|
|
62
|
+
WORDPRESS_DB_HOST: db
|
|
63
|
+
WORDPRESS_DB_USER: root
|
|
64
|
+
WORDPRESS_DB_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
65
|
+
ports:
|
|
66
|
+
- "8080:80"
|
|
51
67
|
x-tug:
|
|
52
|
-
placement:
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
placement:
|
|
69
|
+
nodes:
|
|
70
|
+
- app1
|
|
71
|
+
health:
|
|
72
|
+
type: http
|
|
73
|
+
port: 80
|
|
74
|
+
path: /wp-includes/version.php
|
|
75
|
+
ingress:
|
|
76
|
+
host: blog.example.com
|
|
77
|
+
port: 80
|
|
78
|
+
tls: auto
|
|
55
79
|
|
|
56
80
|
volumes:
|
|
57
81
|
db-data:
|
|
58
|
-
x-tug:
|
|
82
|
+
x-tug:
|
|
83
|
+
placement:
|
|
84
|
+
nodes:
|
|
85
|
+
- app1
|
|
59
86
|
```
|
|
60
87
|
|
|
61
88
|
```bash
|
|
@@ -65,7 +92,9 @@ tug status # what is running where, and anything failing
|
|
|
65
92
|
tug logs wordpress --follow
|
|
66
93
|
```
|
|
67
94
|
|
|
68
|
-
|
|
95
|
+
The named volume holds data on `app1`; it is not copied to another node automatically. Back it up and restore or move its data deliberately if you replace the server.
|
|
96
|
+
|
|
97
|
+
See the [examples directory](examples/) for stack-specific applications. Everything `x-tug` can express is in [Compose extensions](docs/compose.md).
|
|
69
98
|
|
|
70
99
|
## Everyday commands
|
|
71
100
|
|
|
@@ -89,68 +118,77 @@ Every command and option is in the [CLI reference](docs/cli.md). `tug status --j
|
|
|
89
118
|
|
|
90
119
|
## Is Tug right for you?
|
|
91
120
|
|
|
92
|
-
Tug fits if you run
|
|
121
|
+
Tug fits if you run Compose apps on one or a few VPSes, want deploys, rollbacks, secrets and backups handled in one tool, and are happy with a single control server that you back up. A server can host multiple apps as long as it has enough capacity; Tug does not automatically balance resource usage or replace a failed server.
|
|
93
122
|
|
|
94
123
|
It deliberately does not do Kubernetes-style scheduling, autoscaling, distributed storage or database failover, and it does not provision servers through a provider's API. Node-to-node traffic travels over your own private network (Tailscale, a VPC, a LAN), so you own its firewalling and encryption. If you want automatic rescheduling of containers across a cluster, see how it compares with [Docker Swarm](docs/swarm-comparison.md). The goals, principles and planned milestones are in the [Roadmap](docs/roadmap.md).
|
|
95
124
|
|
|
96
125
|
## How it works
|
|
97
126
|
|
|
98
127
|
```text
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
|
|
118
|
-
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
tug CLI / CI
|
|
129
|
+
|
|
|
130
|
+
| mTLS: deploy, status, logs, exec
|
|
131
|
+
v
|
|
132
|
+
+--------------------------------+ +--------------------------------+
|
|
133
|
+
| VPS app1 | | VPS app2 (optional) |
|
|
134
|
+
| | | |
|
|
135
|
+
| Control plane | | |
|
|
136
|
+
| API, SQLite, image builds | | |
|
|
137
|
+
| | | |
|
|
138
|
+
| Agent --> Docker Engine | | Agent --> Docker Engine |
|
|
139
|
+
| | | | | |
|
|
140
|
+
| +-- service A | | +-- service C |
|
|
141
|
+
| +-- database | | +-- service D |
|
|
142
|
+
| +-- more... | | +-- more... |
|
|
143
|
+
| (zero or more services) | | (zero or more services) |
|
|
144
|
+
| | | |
|
|
145
|
+
| Local volumes, optional Caddy | | Local volumes, optional Caddy |
|
|
146
|
+
+--------------------------------+ +--------------------------------+
|
|
147
|
+
| |
|
|
148
|
+
+------- private workload network --------+
|
|
149
|
+
|
|
150
|
+
Each agent connects to the control plane over mTLS. Services are
|
|
151
|
+
placed on either node; local volumes stay on their node. app1 can
|
|
152
|
+
run alone. SSH installs control and agents; normal operations use
|
|
153
|
+
the control API.
|
|
125
154
|
```
|
|
126
155
|
|
|
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
|
|
156
|
+
`tug deploy` reads your Compose file on your machine and sends it to the control plane, which stores it as an immutable desired state, builds application images when needed and pins published images to digests. Tug delivers its built images as archives; published images are pulled from their upstream registries. Each agent fetches the work for its node, applies it through the Docker Engine API, runs the health checks and reports back. The control plane marks the deployment done when every node reports it serving. Compose runs only on your machine for application normalization; nodes need Docker, and the control installation also uses Compose. Cross-node workload traffic uses your own private network.
|
|
128
157
|
|
|
129
158
|
Control serves mutual TLS directly on one port (`8443`). Every operator, CI job and node authenticates with a certificate from a private CA created at install, so the control address can be a bare IP and can change later. Do not terminate TLS in front of it. A `controlUrl` in `tug.yml` tells the CLI where control is (see [CLI configuration](docs/cli.md#config-file)). [Architecture](docs/architecture.md) covers every flow in detail, and [Security](docs/security.md) the trust boundaries.
|
|
130
159
|
|
|
131
|
-
The full walkthrough, including CI credentials, moving the control plane and recovery, is in [Getting started](docs/getting-started.md).
|
|
132
|
-
|
|
133
160
|
## Documentation
|
|
134
161
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
-
|
|
138
|
-
- [
|
|
139
|
-
- [
|
|
140
|
-
- [
|
|
141
|
-
-
|
|
142
|
-
- [
|
|
143
|
-
- [
|
|
144
|
-
- [
|
|
145
|
-
-
|
|
146
|
-
- [
|
|
147
|
-
- [
|
|
148
|
-
- [
|
|
149
|
-
- [
|
|
150
|
-
-
|
|
151
|
-
- [
|
|
152
|
-
- [
|
|
153
|
-
- [
|
|
162
|
+
Start with the [single-VPS tutorial](docs/getting-started.md), then choose the topic that matches your next task:
|
|
163
|
+
|
|
164
|
+
- **Deploy and operate**
|
|
165
|
+
- [Application examples](examples/README.md)
|
|
166
|
+
- [Health checks and rollouts](docs/healthchecks.md)
|
|
167
|
+
- [Troubleshooting](docs/troubleshooting.md)
|
|
168
|
+
- **Networking and access**
|
|
169
|
+
- [Hostnames, HTTPS and service networking](docs/networking.md)
|
|
170
|
+
- [Credentials and CI](docs/access-control.md)
|
|
171
|
+
- [Security trust boundaries](docs/security.md)
|
|
172
|
+
- **Servers and data**
|
|
173
|
+
- [Service placement and scaling](docs/service-placement.md)
|
|
174
|
+
- [Node management](docs/nodes.md)
|
|
175
|
+
- [Backups and recovery](docs/backups.md)
|
|
176
|
+
- [Moving to new servers](docs/migration.md)
|
|
177
|
+
- **Reference**
|
|
178
|
+
- [CLI](docs/cli.md)
|
|
179
|
+
- [Compose extensions](docs/compose.md)
|
|
180
|
+
- [Configuration](docs/configuration.md)
|
|
181
|
+
- [Control plane](docs/control-plane.md)
|
|
182
|
+
- **Contribute and design**
|
|
183
|
+
- [Development](docs/development.md)
|
|
184
|
+
- [Testing](docs/testing.md)
|
|
185
|
+
- [Releases](docs/releases.md)
|
|
186
|
+
- [Architecture](docs/architecture.md)
|
|
187
|
+
- [Roadmap](docs/roadmap.md)
|
|
188
|
+
- [Observability status and proposal](docs/observability.md)
|
|
189
|
+
- [Terminology](docs/terminology.md)
|
|
190
|
+
- [Standalone binary proposal](docs/standalone-binary.md)
|
|
191
|
+
- [Docker Swarm comparison](docs/swarm-comparison.md)
|
|
154
192
|
|
|
155
193
|
## Development
|
|
156
194
|
|
package/dist/agent.js
CHANGED
|
@@ -518,9 +518,11 @@ export async function waitForHealth(ctx, project, container, service, health) {
|
|
|
518
518
|
throw new Error(`${service}: docker health ${state.Health.Status}`);
|
|
519
519
|
}
|
|
520
520
|
else if (health.type === 'tcp') {
|
|
521
|
+
await assertRunning(ctx, container, service);
|
|
521
522
|
await tcpCheck(await containerIp(ctx, project, container), health.port, parseDuration(health.timeout || '2s'));
|
|
522
523
|
}
|
|
523
524
|
else if (health.type === 'http') {
|
|
525
|
+
await assertRunning(ctx, container, service);
|
|
524
526
|
const ip = await containerIp(ctx, project, container);
|
|
525
527
|
const res = await ctx.http(`http://${ip}:${health.port || 80}${health.path || '/'}`, {
|
|
526
528
|
signal: AbortSignal.timeout(parseDuration(health.timeout || '2s')),
|
|
@@ -537,6 +539,33 @@ export async function waitForHealth(ctx, project, container, service, health) {
|
|
|
537
539
|
}
|
|
538
540
|
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}${outsideContainerHint(health)}`);
|
|
539
541
|
}
|
|
542
|
+
/** Fails fast with the exit code and last log lines when the container is gone, instead of dialing a dead address. */
|
|
543
|
+
async function assertRunning(ctx, container, service) {
|
|
544
|
+
const state = (await ctx.docker.inspectContainer(container)).State;
|
|
545
|
+
if (!state || state.Running)
|
|
546
|
+
return;
|
|
547
|
+
const tail = await lastLogLines(ctx, container);
|
|
548
|
+
throw new Error(`${service}: container exited with code ${state.ExitCode ?? '?'}${tail ? `; last log: ${tail}` : ''}`);
|
|
549
|
+
}
|
|
550
|
+
async function lastLogLines(ctx, container, lines = 3) {
|
|
551
|
+
try {
|
|
552
|
+
const proc = await ctx.docker.logs(container, { follow: false, tail: lines });
|
|
553
|
+
let out = '';
|
|
554
|
+
const collect = (chunk) => void (out += chunk.toString());
|
|
555
|
+
proc.stdout.on('data', collect);
|
|
556
|
+
proc.stderr.on('data', collect);
|
|
557
|
+
await new Promise((resolve) => {
|
|
558
|
+
const timer = setTimeout(resolve, 2000);
|
|
559
|
+
proc.stdout.once('close', () => (clearTimeout(timer), resolve()));
|
|
560
|
+
proc.stdout.once('end', () => (clearTimeout(timer), resolve()));
|
|
561
|
+
});
|
|
562
|
+
proc.kill();
|
|
563
|
+
return out.trim().split('\n').slice(-lines).join(' | ').slice(0, 400);
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
return '';
|
|
567
|
+
}
|
|
568
|
+
}
|
|
540
569
|
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
541
570
|
function describeError(error) {
|
|
542
571
|
const e = error;
|
|
@@ -987,8 +1016,10 @@ export async function reconcile(desiredState, ctx = defaultContext(), endpoints
|
|
|
987
1016
|
markEvents('failed');
|
|
988
1017
|
}
|
|
989
1018
|
// Rolling services are untouched by a failure here, so keep reporting their current revision as serving.
|
|
990
|
-
if (!rolling.length)
|
|
991
|
-
throw
|
|
1019
|
+
if (!rolling.length) {
|
|
1020
|
+
// Callers turn the throw into a failed status; carry the per-service states so a held writer is still seen.
|
|
1021
|
+
throw Object.assign(e instanceof Error ? e : new Error(errMessage(e)), { serviceStates });
|
|
1022
|
+
}
|
|
992
1023
|
Object.assign(serviceStates, await activeRollingStates(dir, rolling));
|
|
993
1024
|
return failedStatus(errMessage(e));
|
|
994
1025
|
}
|
|
@@ -1099,6 +1130,11 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1099
1130
|
...(desired.revision ? { observedRevision: desired.revision } : {}),
|
|
1100
1131
|
health: 'failed',
|
|
1101
1132
|
error: errMessage(e),
|
|
1133
|
+
...(e.serviceStates
|
|
1134
|
+
? {
|
|
1135
|
+
serviceStates: e.serviceStates,
|
|
1136
|
+
}
|
|
1137
|
+
: {}),
|
|
1102
1138
|
});
|
|
1103
1139
|
}
|
|
1104
1140
|
}
|
package/dist/backup-config.js
CHANGED
|
@@ -145,7 +145,7 @@ export function resolveBackups(compose, project = '') {
|
|
|
145
145
|
? defaultSchedule.trim()
|
|
146
146
|
: undefined;
|
|
147
147
|
if (spec.schedule === undefined && schedule === undefined)
|
|
148
|
-
errors.push(`${label} needs a schedule: set ${label}.schedule or x-tug.backup.schedule`);
|
|
148
|
+
errors.push(`${label} needs a schedule: set ${label}.schedule or a project-level schedule at the top-level x-tug.backup.schedule`);
|
|
149
149
|
const retention = spec.retention !== undefined
|
|
150
150
|
? validateRetention(label, spec.retention, errors)
|
|
151
151
|
: defaultRetention;
|
package/dist/backup-restore.d.ts
CHANGED
|
@@ -31,7 +31,14 @@ export interface RestoreDeps {
|
|
|
31
31
|
clock?: () => Date;
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
34
|
+
* True when an environment entry points at `service` over the network: a host-like variable that is exactly the
|
|
35
|
+
* service name (`WORDPRESS_DB_HOST=db`), or a value that names it as a host (`db:3306`, `mysql://u:p@db/app`,
|
|
36
|
+
* `mysql:host=db;dbname=app`). A service reached across nodes has no `depends_on` to find it by.
|
|
37
|
+
*/
|
|
38
|
+
export declare function environmentReaches(service: string, key: string, value: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The services that write to a target. For a service they are the services that depend on it, transitively,
|
|
41
|
+
* plus the services whose environment names it as a host, which is how a service on another node reaches it (only for the target itself, not for what depends on the writers);
|
|
35
42
|
* for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
|
|
36
43
|
* because the per-node copies drop cross-node dependencies.
|
|
37
44
|
*/
|
package/dist/backup-restore.js
CHANGED
|
@@ -8,8 +8,36 @@ const VERIFY_TIMEOUT_MS = 5 * MINUTE;
|
|
|
8
8
|
/** Restore states in which the project must stay locked. */
|
|
9
9
|
const LOCKED_STATES = ['locking', 'holding', 'safety_backup', 'restoring', 'interrupted'];
|
|
10
10
|
const TERMINAL = ['succeeded', 'failed', 'released'];
|
|
11
|
+
const HOST_KEY = /host|server|addr|endpoint|url|uri|dsn/i;
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
+
* True when an environment entry points at `service` over the network: a host-like variable that is exactly the
|
|
14
|
+
* service name (`WORDPRESS_DB_HOST=db`), or a value that names it as a host (`db:3306`, `mysql://u:p@db/app`,
|
|
15
|
+
* `mysql:host=db;dbname=app`). A service reached across nodes has no `depends_on` to find it by.
|
|
16
|
+
*/
|
|
17
|
+
export function environmentReaches(service, key, value) {
|
|
18
|
+
const name = service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
if (value === service)
|
|
20
|
+
return HOST_KEY.test(key);
|
|
21
|
+
return [
|
|
22
|
+
new RegExp(`^${name}:\\d+$`),
|
|
23
|
+
new RegExp(`(?:://|@)(?:[^/@\\s]*@)?${name}(?::\\d+)?(?:[/?\\s]|$)`),
|
|
24
|
+
new RegExp(`(?:host|server)=${name}(?:[;:,\\s]|$)`, 'i'),
|
|
25
|
+
].some((pattern) => pattern.test(value));
|
|
26
|
+
}
|
|
27
|
+
function environmentOf(service) {
|
|
28
|
+
const env = service.environment;
|
|
29
|
+
if (Array.isArray(env))
|
|
30
|
+
return env.map(String).map((entry) => {
|
|
31
|
+
const at = entry.indexOf('=');
|
|
32
|
+
return at < 0 ? [entry, ''] : [entry.slice(0, at), entry.slice(at + 1)];
|
|
33
|
+
});
|
|
34
|
+
return env && typeof env === 'object'
|
|
35
|
+
? Object.entries(env).map(([k, v]) => [k, String(v ?? '')])
|
|
36
|
+
: [];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The services that write to a target. For a service they are the services that depend on it, transitively,
|
|
40
|
+
* plus the services whose environment names it as a host, which is how a service on another node reaches it (only for the target itself, not for what depends on the writers);
|
|
13
41
|
* for a volume, the services that mount it and everything depending on those. Stored `depends_on` is used,
|
|
14
42
|
* because the per-node copies drop cross-node dependencies.
|
|
15
43
|
*/
|
|
@@ -34,7 +62,12 @@ export function discoverWriters(compose, target) {
|
|
|
34
62
|
while (queue.length) {
|
|
35
63
|
const current = queue.pop();
|
|
36
64
|
for (const service of Object.keys(compose.services))
|
|
37
|
-
if (!writers.has(service) &&
|
|
65
|
+
if (!writers.has(service) &&
|
|
66
|
+
service !== name &&
|
|
67
|
+
(dependsOn(service).includes(current) ||
|
|
68
|
+
(kind === 'service' &&
|
|
69
|
+
current === name &&
|
|
70
|
+
environmentOf(compose.services[service]).some(([k, v]) => environmentReaches(name, k, v))))) {
|
|
38
71
|
writers.add(service);
|
|
39
72
|
queue.push(service);
|
|
40
73
|
}
|
|
@@ -106,7 +139,7 @@ export function createRestoreService(deps) {
|
|
|
106
139
|
return { error: `writer ${writer} is not a service of ${request.project}`, status: 400 };
|
|
107
140
|
if (!writers.length && !request.noWriters && !request.writers)
|
|
108
141
|
return {
|
|
109
|
-
error: `no services were found that write to ${request.target}; pass --writers to name them or --no-writers to restore without holding any`,
|
|
142
|
+
error: `no services were found that write to ${request.target} (looked at depends_on, volume mounts and host names in environment variables); pass --writers to name them or --no-writers to restore without holding any`,
|
|
110
143
|
status: 409,
|
|
111
144
|
};
|
|
112
145
|
}
|
|
@@ -136,8 +169,16 @@ export function createRestoreService(deps) {
|
|
|
136
169
|
function unlock(restore) {
|
|
137
170
|
db.run('DELETE FROM locks WHERE name=? AND owner=?', `project:${restore.project_id}`, `restore:${restore.id}`);
|
|
138
171
|
}
|
|
139
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Drops a restore's holds and lock. Holds a deploy placed for a move (no `restore_id`) are dropped with them,
|
|
174
|
+
* unless `keepMoveHolds`: a restore that failed before changing data must not start the writers against the
|
|
175
|
+
* empty database it was meant to fill.
|
|
176
|
+
*/
|
|
177
|
+
function release(restore, keepMoveHolds = false) {
|
|
140
178
|
db.run('DELETE FROM service_holds WHERE restore_id=?', restore.id);
|
|
179
|
+
if (!keepMoveHolds)
|
|
180
|
+
for (const service of JSON.parse(restore.writers_json))
|
|
181
|
+
db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', restore.project_id, service);
|
|
141
182
|
unlock(restore);
|
|
142
183
|
deps.wake(restore.project_id);
|
|
143
184
|
}
|
|
@@ -166,8 +207,12 @@ export function createRestoreService(deps) {
|
|
|
166
207
|
else
|
|
167
208
|
move(restore, 'holding');
|
|
168
209
|
if (restore.mode === 'in-place')
|
|
169
|
-
for (const service of restorePlan.writers)
|
|
210
|
+
for (const service of restorePlan.writers) {
|
|
211
|
+
// A hold a deploy placed for a move stays as it is; this restore drops it when it succeeds.
|
|
212
|
+
if (db.get('SELECT 1 FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', restore.project_id, service))
|
|
213
|
+
continue;
|
|
170
214
|
db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', restore.project_id, service, `restore ${restoreId}`, restoreId, ts);
|
|
215
|
+
}
|
|
171
216
|
deps.wake(restore.project_id);
|
|
172
217
|
advance();
|
|
173
218
|
return {
|
|
@@ -189,12 +234,29 @@ export function createRestoreService(deps) {
|
|
|
189
234
|
});
|
|
190
235
|
});
|
|
191
236
|
};
|
|
237
|
+
/** Says which writers never reported `held` and why: the node's own apply error, or that it has not reported. */
|
|
238
|
+
const holdDiagnosis = (restore, writers) => {
|
|
239
|
+
const desired = deployed(restore.project_id);
|
|
240
|
+
const problems = [];
|
|
241
|
+
for (const service of writers)
|
|
242
|
+
for (const node of desired?.services[service]?.placement.nodes ?? []) {
|
|
243
|
+
const row = db.get('SELECT s.* FROM node_statuses s JOIN nodes n ON n.id=s.node_id WHERE n.name=? AND s.project_id=?', node, restore.project_id);
|
|
244
|
+
const status = row ? JSON.parse(row.status_json) : undefined;
|
|
245
|
+
const state = status?.serviceStates?.[service]?.rolloutState;
|
|
246
|
+
if (state === 'held')
|
|
247
|
+
continue;
|
|
248
|
+
problems.push(!status
|
|
249
|
+
? `${service} on ${node}: the node has not reported`
|
|
250
|
+
: `${service} on ${node}: ${state ?? 'no state'}${status.error ? `; the node's apply failed with: ${status.error}` : ''}`);
|
|
251
|
+
}
|
|
252
|
+
return problems.join('; ');
|
|
253
|
+
};
|
|
192
254
|
/** Fails a restore. Before anything changed, holds and the lock are dropped; after, they stay until released. */
|
|
193
255
|
function fail(restore, error, dataChanged) {
|
|
194
256
|
if (dataChanged)
|
|
195
257
|
move(restore, 'interrupted', { error });
|
|
196
258
|
else {
|
|
197
|
-
release(restore);
|
|
259
|
+
release(restore, true);
|
|
198
260
|
move(restore, 'failed', { error });
|
|
199
261
|
}
|
|
200
262
|
event('error', 'restore.failed', restore, `restore of ${restore.target} failed: ${error}${dataChanged ? '; the writers stay held until tug restore release' : ''}`, { error, held: dataChanged });
|
|
@@ -212,8 +274,10 @@ export function createRestoreService(deps) {
|
|
|
212
274
|
}
|
|
213
275
|
function advanceHolding(restore, writers, age) {
|
|
214
276
|
if (!writersReport(restore, writers, 'held')) {
|
|
215
|
-
if (age > HOLD_TIMEOUT_MS)
|
|
216
|
-
|
|
277
|
+
if (age > HOLD_TIMEOUT_MS) {
|
|
278
|
+
const why = holdDiagnosis(restore, writers);
|
|
279
|
+
fail(restore, `the writers did not stop in time${why ? ` (${why})` : ''}`, false);
|
|
280
|
+
}
|
|
217
281
|
return;
|
|
218
282
|
}
|
|
219
283
|
if (!restore.safety_backup)
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare function applyConfigDefaults(command: OptionSource, values: objec
|
|
|
66
66
|
* overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
|
|
67
67
|
* connection flags passed to `init` are saved to the overlay instead.
|
|
68
68
|
*/
|
|
69
|
-
export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions): Promise<string[]>;
|
|
69
|
+
export declare function writeInitConfig(config: ResolvedCliConfig, credentialFile: string, explicit: ConnectionOptions, credentialUrl?: string): Promise<string[]>;
|
|
70
70
|
export declare function normalizeControlUrl(value: string): string;
|
|
71
71
|
export declare function integerOption(value: string, name: string, minimum?: number, maximum?: number): number;
|
|
72
72
|
export {};
|
package/dist/cli-config.js
CHANGED
|
@@ -272,11 +272,16 @@ function serialize(file, data, notice) {
|
|
|
272
272
|
* overlay at the credential file, git-ignoring it. An existing tracked file is never rewritten, so its comments survive;
|
|
273
273
|
* connection flags passed to `init` are saved to the overlay instead.
|
|
274
274
|
*/
|
|
275
|
-
export async function writeInitConfig(config, credentialFile, explicit) {
|
|
275
|
+
export async function writeInitConfig(config, credentialFile, explicit, credentialUrl) {
|
|
276
276
|
const written = [];
|
|
277
277
|
if (!config.fileExists) {
|
|
278
278
|
await fs.mkdir(path.dirname(config.file), { recursive: true });
|
|
279
|
-
await fs.writeFile(config.file, serialize(config.file, {
|
|
279
|
+
await fs.writeFile(config.file, serialize(config.file, {
|
|
280
|
+
controlUrl: explicit.controlUrl
|
|
281
|
+
? config.controlUrl
|
|
282
|
+
: (credentialUrl ?? config.controlUrl),
|
|
283
|
+
project: config.project,
|
|
284
|
+
}, `Safe to commit: keep credentials out of this file (use TUG_CREDENTIAL_FILE or ${path.basename(config.localFile)}).`));
|
|
280
285
|
written.push(config.file);
|
|
281
286
|
}
|
|
282
287
|
let local = {};
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import { registerInit } from "./commands/init.js";
|
|
|
14
14
|
import { registerNode } from "./commands/node.js";
|
|
15
15
|
import { registerObserve } from "./commands/observe.js";
|
|
16
16
|
import { registerRestore } from "./commands/restore.js";
|
|
17
|
+
import { registerHold } from "./commands/hold.js";
|
|
18
|
+
import { registerImage } from "./commands/image.js";
|
|
17
19
|
import { registerSecret } from "./commands/secret.js";
|
|
18
20
|
import { registerService } from "./commands/service.js";
|
|
19
21
|
import { registerVolume } from "./commands/volume.js";
|
|
@@ -33,6 +35,8 @@ registerNode(program);
|
|
|
33
35
|
registerDeploy(program);
|
|
34
36
|
registerObserve(program);
|
|
35
37
|
registerService(program);
|
|
38
|
+
registerHold(program);
|
|
39
|
+
registerImage(program);
|
|
36
40
|
registerSecret(program);
|
|
37
41
|
registerBackup(program);
|
|
38
42
|
registerRestore(program);
|
|
@@ -16,6 +16,10 @@ export interface DeployResponse {
|
|
|
16
16
|
deployment: DeploymentRecord;
|
|
17
17
|
desiredState?: unknown;
|
|
18
18
|
}
|
|
19
|
+
/** One line for a person: which project and version reached which state, and how many services it runs. */
|
|
20
|
+
export declare function formatDeployment(response: DeployResponse): string;
|
|
21
|
+
/** Prints the full API result with `--json`, otherwise the human summary. */
|
|
22
|
+
export declare function printResult<T>(json: boolean | undefined, value: T, summary: (value: T) => string): void;
|
|
19
23
|
export interface CommonOpts {
|
|
20
24
|
project?: string;
|
|
21
25
|
}
|
package/dist/commands/context.js
CHANGED
|
@@ -8,6 +8,21 @@ import { TERMINAL_DEPLOYMENT_STATES } from "../protocol.js";
|
|
|
8
8
|
// Templates live next to the commands' parent: src/templates in dev (`node src/cli.ts`), dist/templates in the
|
|
9
9
|
// built image (`node dist/cli.js`; tools/scripts/copy-templates.js puts them there).
|
|
10
10
|
export const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates');
|
|
11
|
+
/** One line for a person: which project and version reached which state, and how many services it runs. */
|
|
12
|
+
export function formatDeployment(response) {
|
|
13
|
+
const { deployment } = response;
|
|
14
|
+
const state = response.desiredState;
|
|
15
|
+
const services = Object.keys(state?.services ?? {}).length;
|
|
16
|
+
const label = [state?.project, state?.version].filter(Boolean).join(' ');
|
|
17
|
+
return `${label ? `${label}: ` : ''}deployment ${deployment.id} ${deployment.state}${services ? `, ${services} service${services === 1 ? '' : 's'}` : ''}${deployment.error_message ? ` (${deployment.error_message})` : ''}`;
|
|
18
|
+
}
|
|
19
|
+
/** Prints the full API result with `--json`, otherwise the human summary. */
|
|
20
|
+
export function printResult(json, value, summary) {
|
|
21
|
+
if (json)
|
|
22
|
+
print(value);
|
|
23
|
+
else
|
|
24
|
+
console.log(summary(value));
|
|
25
|
+
}
|
|
11
26
|
export const CONFIG_HELP = 'CLI config file, YAML or JSON (default: TUG_CONFIG, else tug.yml, tug.yaml, tug.json or .tug.json)';
|
|
12
27
|
let resolvedConfig;
|
|
13
28
|
/** Set by the root `preAction` hook before any command runs. */
|
package/dist/commands/control.js
CHANGED
|
@@ -98,7 +98,8 @@ export function registerControl(program) {
|
|
|
98
98
|
control
|
|
99
99
|
.command('backup')
|
|
100
100
|
.description('create a control-plane backup (state, PKI, master key and retained image archives; not workload volumes)')
|
|
101
|
-
.
|
|
101
|
+
.option('--no-images', 'leave the retained build archives out (deploy again to rebuild them)')
|
|
102
|
+
.action(async (o) => print(await request('POST', '/api/v1/control/backups/run', { images: o.images })));
|
|
102
103
|
control
|
|
103
104
|
.command('restore <artifactRef>')
|
|
104
105
|
.description('restore a control-plane backup on the control-plane host; requires a control restart')
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
/** The first conventional Compose file name in the working directory; `docker-compose.yml` when none exists. */
|
|
3
|
+
export declare function defaultComposeFile(cwd?: string): string;
|
|
4
|
+
/** The top-level `name:` of a Compose file, which Compose would use as the project name. */
|
|
5
|
+
export declare function composeName(file: string): Promise<string | undefined>;
|
|
2
6
|
export declare function registerDeploy(program: Command): void;
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openAsBlob } from 'node:fs';
|
|
1
|
+
import { existsSync, openAsBlob } from 'node:fs';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -7,7 +7,7 @@ import { canPrompt } from "../cli-prompt.js";
|
|
|
7
7
|
import { findMissingSecrets, promptForSecrets } from "../cli-secrets.js";
|
|
8
8
|
import { composeHash } from "../compose.js";
|
|
9
9
|
import { normalizeCompose } from "../normalize.js";
|
|
10
|
-
import { loadCfg, print, projectOf, request, waitForCommand, waitForDeployment, } from "./context.js";
|
|
10
|
+
import { loadCfg, collect, formatDeployment, print, printResult, projectOf, request, waitForCommand, waitForDeployment, } from "./context.js";
|
|
11
11
|
async function makeContext() {
|
|
12
12
|
const cfg = await loadCfg();
|
|
13
13
|
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
@@ -24,15 +24,27 @@ async function makeContext() {
|
|
|
24
24
|
}
|
|
25
25
|
return tmp;
|
|
26
26
|
}
|
|
27
|
+
/** The first conventional Compose file name in the working directory; `docker-compose.yml` when none exists. */
|
|
28
|
+
export function defaultComposeFile(cwd = process.cwd()) {
|
|
29
|
+
const names = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
|
30
|
+
return names.find((name) => existsSync(path.join(cwd, name))) ?? 'docker-compose.yml';
|
|
31
|
+
}
|
|
32
|
+
/** The top-level `name:` of a Compose file, which Compose would use as the project name. */
|
|
33
|
+
export async function composeName(file) {
|
|
34
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
35
|
+
return /^name:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*(?:#.*)?$/m.exec(text)?.[1];
|
|
36
|
+
}
|
|
27
37
|
export function registerDeploy(program) {
|
|
28
38
|
const deploy = program
|
|
29
39
|
.command('deploy')
|
|
30
40
|
.description('deploy the Compose project, or cancel a deployment with `deploy cancel <id>`')
|
|
31
|
-
.option('-f, --file <file>', 'compose file',
|
|
41
|
+
.option('-f, --file <file>', 'compose file', defaultComposeFile())
|
|
32
42
|
.option('--version <version>', 'display version (default: git-<Compose hash>)')
|
|
33
43
|
.option('--build-local', 'build application images locally and upload them without source')
|
|
34
44
|
.option('--no-wait', 'return after desired state is created')
|
|
35
45
|
.option('--timeout <seconds>', 'wait timeout in seconds', '300')
|
|
46
|
+
.option('--json', 'print the full result, including the desired state, as JSON')
|
|
47
|
+
.option('--move <service>', 'acknowledge that a service placed on another node starts empty (its data does not move); the services that write to it are held until you restore it or run `tug hold release` (repeatable)', collect, [])
|
|
36
48
|
.action(async (o) => {
|
|
37
49
|
const cfg = await loadCfg();
|
|
38
50
|
const project = o.project || cfg.project || 'site';
|
|
@@ -50,6 +62,10 @@ export function registerDeploy(program) {
|
|
|
50
62
|
secretNames.push(name);
|
|
51
63
|
}
|
|
52
64
|
}
|
|
65
|
+
const movePayload = o.move.length ? { moves: o.move } : {};
|
|
66
|
+
const declared = await composeName(o.file);
|
|
67
|
+
if (declared && declared !== project)
|
|
68
|
+
console.error(`warning: ${o.file} is named ${declared} but the Tug project is ${project}; secrets, backups and restores are scoped to the Tug project (set project: ${declared} in tug.yml, or pass --project)`);
|
|
53
69
|
const compose = await normalizeCompose(o.file, secretNames, { project });
|
|
54
70
|
const version = o.version || `git-${composeHash(compose)}`;
|
|
55
71
|
let response;
|
|
@@ -67,7 +83,7 @@ export function registerDeploy(program) {
|
|
|
67
83
|
const localImages = await prepareLocalImages(compose, source, directory);
|
|
68
84
|
for (const { field } of localImages)
|
|
69
85
|
form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
|
|
70
|
-
form.append('payload', JSON.stringify({ project, version, compose, localImages }));
|
|
86
|
+
form.append('payload', JSON.stringify({ project, version, compose, localImages, ...movePayload }));
|
|
71
87
|
response = await request('POST', '/api/v1/deployments', form, true);
|
|
72
88
|
}
|
|
73
89
|
finally {
|
|
@@ -78,7 +94,7 @@ export function registerDeploy(program) {
|
|
|
78
94
|
const tarball = await makeContext();
|
|
79
95
|
try {
|
|
80
96
|
const form = new FormData();
|
|
81
|
-
form.append('payload', JSON.stringify({ project, version, compose }));
|
|
97
|
+
form.append('payload', JSON.stringify({ project, version, compose, ...movePayload }));
|
|
82
98
|
form.append('context', await openAsBlob(tarball), 'context.tgz');
|
|
83
99
|
response = await request('POST', '/api/v1/deployments', form, true);
|
|
84
100
|
}
|
|
@@ -91,10 +107,11 @@ export function registerDeploy(program) {
|
|
|
91
107
|
project,
|
|
92
108
|
version,
|
|
93
109
|
compose,
|
|
110
|
+
...movePayload,
|
|
94
111
|
});
|
|
95
112
|
if (o.wait)
|
|
96
113
|
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
97
|
-
|
|
114
|
+
printResult(o.json, response, formatDeployment);
|
|
98
115
|
});
|
|
99
116
|
deploy
|
|
100
117
|
.command('cancel <id>')
|
|
@@ -106,6 +123,7 @@ export function registerDeploy(program) {
|
|
|
106
123
|
.option('--force')
|
|
107
124
|
.option('--no-wait', 'return immediately after activating rollback')
|
|
108
125
|
.option('--timeout <seconds>', 'wait timeout', '300')
|
|
126
|
+
.option('--json', 'print the full result, including the desired state, as JSON')
|
|
109
127
|
.action(async (o) => {
|
|
110
128
|
const response = await request('POST', '/api/v1/rollback', {
|
|
111
129
|
project: await projectOf(o),
|
|
@@ -113,16 +131,18 @@ export function registerDeploy(program) {
|
|
|
113
131
|
});
|
|
114
132
|
if (o.wait)
|
|
115
133
|
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
116
|
-
|
|
134
|
+
printResult(o.json, response, formatDeployment);
|
|
117
135
|
});
|
|
118
136
|
program
|
|
119
137
|
.command('scale <service> <replicas>')
|
|
120
138
|
.description('scale a service to N replicas across matching nodes')
|
|
139
|
+
.option('--json', 'print the full result as JSON')
|
|
121
140
|
.action(async (service, replicas, o) => {
|
|
122
141
|
const project = await projectOf(o);
|
|
123
142
|
if (!/^[1-9]\d*$/.test(replicas))
|
|
124
143
|
throw new Error('replicas must be a positive integer');
|
|
125
|
-
|
|
144
|
+
const result = await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) });
|
|
145
|
+
printResult(o.json, result, (r) => `${r.service} scaled to ${r.replicas} replica${r.replicas === 1 ? '' : 's'} on ${r.nodes.join(', ')}`);
|
|
126
146
|
});
|
|
127
147
|
program
|
|
128
148
|
.command('restart <service>')
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { print, projectOf, request } from "./context.js";
|
|
2
|
+
export function registerHold(program) {
|
|
3
|
+
const hold = program
|
|
4
|
+
.command('hold')
|
|
5
|
+
.description('services a deploy stopped because their data moved to another node');
|
|
6
|
+
hold
|
|
7
|
+
.command('release [services...]')
|
|
8
|
+
.description('start the services that `tug deploy --move` held, without restoring (all of them when none is named); a restore releases its own holds')
|
|
9
|
+
.action(async (services, o) => print(await request('POST', '/api/v1/holds/release', {
|
|
10
|
+
project: await projectOf(o),
|
|
11
|
+
services,
|
|
12
|
+
})));
|
|
13
|
+
}
|
package/dist/commands/host.d.ts
CHANGED
|
@@ -16,3 +16,9 @@ export declare function hostHasDocker(target: string, compose?: boolean): boolea
|
|
|
16
16
|
export declare function remoteHostname(target: string): string | undefined;
|
|
17
17
|
/** The host's global IPv4 addresses, private ones first; empty when the host cannot say. */
|
|
18
18
|
export declare function discoverAddresses(target: string): AddressCandidate[];
|
|
19
|
+
/**
|
|
20
|
+
* The script that retires a node host: stops the agent and ingress, removes the project containers, networks and
|
|
21
|
+
* agent state. Project volumes (your data) and the ingress certificates go only with `deleteData`. A host that runs
|
|
22
|
+
* the control plane is refused, because removing it would destroy the master key and the private CA.
|
|
23
|
+
*/
|
|
24
|
+
export declare function teardownScript(deleteData: boolean): string;
|
package/dist/commands/host.js
CHANGED
|
@@ -97,3 +97,26 @@ export function discoverAddresses(target) {
|
|
|
97
97
|
const result = spawnSync('ssh', ['--', target, LIST_ADDRESSES_COMMAND], { encoding: 'utf8' });
|
|
98
98
|
return result.status === 0 ? sortCandidates(parseAddressCandidates(String(result.stdout))) : [];
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The script that retires a node host: stops the agent and ingress, removes the project containers, networks and
|
|
102
|
+
* agent state. Project volumes (your data) and the ingress certificates go only with `deleteData`. A host that runs
|
|
103
|
+
* the control plane is refused, because removing it would destroy the master key and the private CA.
|
|
104
|
+
*/
|
|
105
|
+
export function teardownScript(deleteData) {
|
|
106
|
+
return [
|
|
107
|
+
'set -eu',
|
|
108
|
+
"if docker ps -a --format '{{.Names}}' | grep -q '^tug-control'; then echo 'this host runs the control plane; retire it by hand (see docs/migration.md)' >&2; exit 3; fi",
|
|
109
|
+
'docker rm -f tug-agent tug-caddy >/dev/null 2>&1 || true',
|
|
110
|
+
'ids=$(docker ps -aq --filter label=tug.project --filter label=tug.job; docker ps -aq --filter label=tug.project)',
|
|
111
|
+
'[ -z "$ids" ] || docker rm -fv $ids >/dev/null',
|
|
112
|
+
"docker network ls -q --filter 'name=^tug_' | xargs -r docker network rm >/dev/null 2>&1 || true",
|
|
113
|
+
'rm -rf /etc/tug /var/lib/tug',
|
|
114
|
+
...(deleteData
|
|
115
|
+
? [
|
|
116
|
+
'docker volume ls -q --filter label=tug.project | xargs -r docker volume rm >/dev/null',
|
|
117
|
+
'docker volume rm tug-caddy-data tug-caddy-config tug-caddy-etc tug_restic_cache >/dev/null 2>&1 || true',
|
|
118
|
+
]
|
|
119
|
+
: []),
|
|
120
|
+
'echo "node host cleaned"',
|
|
121
|
+
].join('\n');
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { print, request } from "./context.js";
|
|
2
|
+
export function registerImage(program) {
|
|
3
|
+
const image = program.command('image').description('manage the build archives kept on control');
|
|
4
|
+
image
|
|
5
|
+
.command('prune')
|
|
6
|
+
.description('delete build archives that rollback no longer needs: all but the newest N deployments of each project, the current one and any version a node still runs')
|
|
7
|
+
.option('--keep <n>', 'deployments per project whose archives are kept', '5')
|
|
8
|
+
.option('--dry-run', 'list what would be deleted without deleting it')
|
|
9
|
+
.action(async (o) => print(await request('POST', '/api/v1/images/prune', {
|
|
10
|
+
keep: Number(o.keep),
|
|
11
|
+
dryRun: Boolean(o.dryRun),
|
|
12
|
+
})));
|
|
13
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -15,7 +15,7 @@ export async function importCredential(cfg, text, connection) {
|
|
|
15
15
|
path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'tug', 'credentials');
|
|
16
16
|
const source = path.join(dir, `${new URL(credential.url).host.replace(/[^A-Za-z0-9.-]/g, '_')}.json`);
|
|
17
17
|
await writeFileDurable(source, `${JSON.stringify(credential, null, 2)}\n`, 0o600);
|
|
18
|
-
for (const file of await writeInitConfig(cfg, source, connection))
|
|
18
|
+
for (const file of await writeInitConfig(cfg, source, connection, credential.url))
|
|
19
19
|
console.log(`wrote ${file}`);
|
|
20
20
|
console.log(`imported credential to ${source}`);
|
|
21
21
|
}
|
package/dist/commands/node.js
CHANGED
|
@@ -7,7 +7,7 @@ import { isNodeHost } from "../node-address.js";
|
|
|
7
7
|
import { resolveImage } from "../image.js";
|
|
8
8
|
import { errMessage, loadTemplate } from "../util.js";
|
|
9
9
|
import { collect, loadCfg, print, readStdin, request, runCapture, runLocal, templatesDir, transportFor, } from "./context.js";
|
|
10
|
-
import { bootstrapDocker, discoverAddresses, remoteHostname, dockerBootstrap, hostHasDocker, preflightRemote, transferTugImage, } from "./host.js";
|
|
10
|
+
import { bootstrapDocker, discoverAddresses, remoteHostname, dockerBootstrap, hostHasDocker, preflightRemote, teardownScript, transferTugImage, } from "./host.js";
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
12
|
const AGENT_STATE = '/var/lib/tug';
|
|
13
13
|
function addNodeIdentityOptions(command) {
|
|
@@ -306,10 +306,44 @@ export function registerNode(program) {
|
|
|
306
306
|
.description("block a node's key immediately without deleting its metadata or placement (no SSH needed); bring it back with node replace")
|
|
307
307
|
.action(async (name) => print(await request('POST', `/api/v1/nodes/${encodeURIComponent(name)}/disable`, {})));
|
|
308
308
|
node
|
|
309
|
-
.command('remove <name>')
|
|
310
|
-
.description('remove a retired node from the control plane (its identity, statuses and queued commands); stop its agent and
|
|
309
|
+
.command('remove <name> [sshTarget]')
|
|
310
|
+
.description('remove a retired node from the control plane (its identity, statuses and queued commands); with --teardown also stop its agent and delete its containers and agent state over SSH')
|
|
311
311
|
.option('--force', 'remove even if the node is still reporting')
|
|
312
|
-
.
|
|
312
|
+
.option('--teardown', 'also clean the host over SSH (the SSH target defaults to nodes.<name>.ssh in the config file)')
|
|
313
|
+
.option('--delete-data', 'with --teardown, also delete project volumes and ingress certificates')
|
|
314
|
+
.option('-y, --yes', 'with --teardown, do not ask for confirmation')
|
|
315
|
+
.action(async (name, ssh, o) => {
|
|
316
|
+
if (!o.teardown) {
|
|
317
|
+
if (ssh || o.deleteData || o.yes)
|
|
318
|
+
throw new Error('an SSH target, --delete-data and --yes need --teardown');
|
|
319
|
+
return print(await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}${o.force ? '?force=true' : ''}`));
|
|
320
|
+
}
|
|
321
|
+
const cfg = await loadCfg();
|
|
322
|
+
const sshTarget = ssh ?? nodeSettings(cfg, name).ssh;
|
|
323
|
+
if (!sshTarget)
|
|
324
|
+
throw new Error(`--teardown needs an SSH target: pass <ssh-target> or set nodes.${name}.ssh in ${cfg.file}`);
|
|
325
|
+
if (!o.yes) {
|
|
326
|
+
if (!canPrompt())
|
|
327
|
+
throw new Error('--teardown deletes data on the host; pass -y to confirm without a terminal');
|
|
328
|
+
console.error(`This will stop the Tug agent on ${sshTarget}, remove its Tug containers and networks and delete /etc/tug and /var/lib/tug${o.deleteData
|
|
329
|
+
? ', and DELETE every project volume and the ingress certificates'
|
|
330
|
+
: ' (project volumes are kept)'}.`);
|
|
331
|
+
if (!(await confirm('Continue?', false)))
|
|
332
|
+
throw new Error('cancelled');
|
|
333
|
+
}
|
|
334
|
+
// The control record goes first: it refuses a node that is still placed in a current deployment before any
|
|
335
|
+
// host is touched. A node control no longer knows is cleaned anyway, so a failed cleanup can be retried.
|
|
336
|
+
let removed = { ok: true, node: name, alreadyRemoved: true };
|
|
337
|
+
try {
|
|
338
|
+
removed = await request('DELETE', `/api/v1/nodes/${encodeURIComponent(name)}?force=true`);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (!/node not found/.test(errMessage(error)))
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
runLocal('ssh', ['--', sshTarget, teardownScript(Boolean(o.deleteData))]);
|
|
345
|
+
print({ ...removed, teardown: sshTarget, deleteData: Boolean(o.deleteData) });
|
|
346
|
+
});
|
|
313
347
|
node
|
|
314
348
|
.command('ingress <name>')
|
|
315
349
|
.description('mark a node as an ingress node (managed Caddy on ports 80/443), or remove the role with --disable')
|
package/dist/control.js
CHANGED
|
@@ -29,7 +29,7 @@ import { parseStatusReport } from "./status-report.js";
|
|
|
29
29
|
import { ACTIVE_DEPLOYMENT_STATES, LIVE_MAX_FRAME_BYTES, LIVE_MAX_BUFFERED_BYTES, LIVE_MAX_SESSIONS, LIVE_START_TIMEOUT_MS, BACKUP_COMMAND_TYPES, } from "./protocol.js";
|
|
30
30
|
import { BACKUP_SECRET_PROJECT, isReservedProject, parseBackupDestination, projectRepository, repositoryPassword, redactSecrets, } from "./backup-destination.js";
|
|
31
31
|
import { createBackupService } from "./backup-scheduler.js";
|
|
32
|
-
import { createRestoreService } from "./backup-restore.js";
|
|
32
|
+
import { createRestoreService, discoverWriters } from "./backup-restore.js";
|
|
33
33
|
import { AUTO_ROLLBACK_ACTOR, ROUTE_ROLES, PROJECT_NAME_PATTERN, authenticate, hasRole, assertProject, bodyProject, createGuard, createCredential, listCredentials, parseCredentialRequest, queryProject, recordEvent, revokeCredential, } from "./credentials.js";
|
|
34
34
|
class WakeHub {
|
|
35
35
|
cursors = new Map();
|
|
@@ -758,6 +758,7 @@ export async function createApp(options = {}) {
|
|
|
758
758
|
WHERE ds.project_id=? AND ds.deployment_id != ? AND d.state IN (${states}) ORDER BY ds.created_at DESC LIMIT 1`, project, current?.current_deployment_id || '');
|
|
759
759
|
if (!previousDs)
|
|
760
760
|
throw httpError(404, 'no previous deployment found');
|
|
761
|
+
assertArtifactsAvailable(JSON.parse(previousDs.state_json), `roll back to ${previousDs.version}`);
|
|
761
762
|
const ts = now();
|
|
762
763
|
const depId = id('dep');
|
|
763
764
|
const previousState = JSON.parse(previousDs.state_json);
|
|
@@ -934,10 +935,17 @@ export async function createApp(options = {}) {
|
|
|
934
935
|
const global = ext.global === true;
|
|
935
936
|
const labels = Array.isArray(placement.labels) ? placement.labels : [];
|
|
936
937
|
const matched = nodeInfos.filter((node) => labels.every((label) => node.labels.includes(label)));
|
|
937
|
-
|
|
938
|
+
// Explicit `placement.nodes` (without labels or global, which deploy resolves into nodes) bound the pool; scaling never re-homes a service off them.
|
|
939
|
+
const pinned = !labels.length && !global && Array.isArray(placement.nodes)
|
|
940
|
+
? placement.nodes
|
|
941
|
+
: [];
|
|
942
|
+
const candidates = pinned.length
|
|
943
|
+
? matched.filter((node) => pinned.includes(node.name))
|
|
944
|
+
: matched;
|
|
945
|
+
const pool = candidates;
|
|
938
946
|
if (!pool.length)
|
|
939
|
-
throw httpError(400, labels.length
|
|
940
|
-
? 'no healthy nodes match the service placement
|
|
947
|
+
throw httpError(400, labels.length || pinned.length
|
|
948
|
+
? 'no healthy nodes match the service placement'
|
|
941
949
|
: 'no nodes available for scaling');
|
|
942
950
|
const selected = global ? pool : pool.slice(0, Math.min(replicas, pool.length));
|
|
943
951
|
state.services[service].placement = {
|
|
@@ -1536,7 +1544,70 @@ export async function createApp(options = {}) {
|
|
|
1536
1544
|
}
|
|
1537
1545
|
// Previous-process builds cannot finish after a restart; clear their unpublished files.
|
|
1538
1546
|
db.run("UPDATE deployments SET state='build_failed',error_message='control restarted during build' WHERE state IN ('uploading','building')");
|
|
1547
|
+
/** Archives deleted by `image prune`, or missing from a backup taken without images, can no longer be deployed. */
|
|
1548
|
+
async function markMissingArtifacts() {
|
|
1549
|
+
for (const row of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL"))
|
|
1550
|
+
if (!(await fs.stat(path.join(DATA_DIR, row.artifact_path)).catch(() => null)))
|
|
1551
|
+
db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), row.id);
|
|
1552
|
+
}
|
|
1553
|
+
/** Throws a 409 when a desired state needs a build archive that was pruned. */
|
|
1554
|
+
function assertArtifactsAvailable(state, action) {
|
|
1555
|
+
for (const [name, service] of Object.entries(state.services)) {
|
|
1556
|
+
if (!service.artifact)
|
|
1557
|
+
continue;
|
|
1558
|
+
const build = db.get('SELECT * FROM builds WHERE id=?', service.artifact.buildId);
|
|
1559
|
+
if (build?.state === 'pruned')
|
|
1560
|
+
throw httpError(409, `cannot ${action}: the build archive of ${name} was pruned (tug image prune); deploy it again instead`);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Deletes build archives no longer needed: everything except the newest `keep` desired states of each project,
|
|
1565
|
+
* the current deployment and any version a node still reports. The desired states stay, so history lists them,
|
|
1566
|
+
* but rolling back to a pruned one is refused.
|
|
1567
|
+
*/
|
|
1568
|
+
async function pruneArtifacts(keep, dryRun) {
|
|
1569
|
+
const kept = new Set();
|
|
1570
|
+
const reported = db
|
|
1571
|
+
.all('SELECT project_id,status_json FROM node_statuses')
|
|
1572
|
+
.map((row) => ({
|
|
1573
|
+
project: row.project_id,
|
|
1574
|
+
...JSON.parse(row.status_json),
|
|
1575
|
+
}));
|
|
1576
|
+
const current = new Set(db
|
|
1577
|
+
.all('SELECT current_deployment_id FROM projects')
|
|
1578
|
+
.map((row) => row.current_deployment_id));
|
|
1579
|
+
const rows = db.all('SELECT * FROM desired_states ORDER BY created_at DESC');
|
|
1580
|
+
const seen = new Map();
|
|
1581
|
+
for (const row of rows) {
|
|
1582
|
+
const index = seen.get(row.project_id) ?? 0;
|
|
1583
|
+
seen.set(row.project_id, index + 1);
|
|
1584
|
+
const state = JSON.parse(row.state_json);
|
|
1585
|
+
const lagging = reported.some((r) => r.project === row.project_id &&
|
|
1586
|
+
r.observedVersion === state.version &&
|
|
1587
|
+
(!r.observedRevision || r.observedRevision === state.revision));
|
|
1588
|
+
if (index >= keep && !lagging && !current.has(row.deployment_id))
|
|
1589
|
+
continue;
|
|
1590
|
+
for (const service of Object.values(state.services))
|
|
1591
|
+
if (service.artifact)
|
|
1592
|
+
kept.add(service.artifact.buildId);
|
|
1593
|
+
}
|
|
1594
|
+
const pruned = [];
|
|
1595
|
+
for (const build of db.all("SELECT * FROM builds WHERE state='complete' AND artifact_path IS NOT NULL")) {
|
|
1596
|
+
if (kept.has(build.id))
|
|
1597
|
+
continue;
|
|
1598
|
+
pruned.push({ id: build.id, service: build.service, sizeBytes: build.size_bytes });
|
|
1599
|
+
if (dryRun)
|
|
1600
|
+
continue;
|
|
1601
|
+
await fs.rm(path.join(DATA_DIR, build.artifact_path), { force: true });
|
|
1602
|
+
db.run("UPDATE builds SET state='pruned',artifact_path=NULL,updated_at=? WHERE id=?", now(), build.id);
|
|
1603
|
+
const shared = db.get("SELECT 1 FROM builds WHERE image_ref=? AND state='complete'", build.image_ref);
|
|
1604
|
+
if (!shared)
|
|
1605
|
+
await images.run('docker', ['image', 'rm', build.image_ref]).catch(() => { });
|
|
1606
|
+
}
|
|
1607
|
+
return { pruned, freedBytes: pruned.reduce((sum, build) => sum + build.sizeBytes, 0) };
|
|
1608
|
+
}
|
|
1539
1609
|
await collectArtifacts();
|
|
1610
|
+
await markMissingArtifacts();
|
|
1540
1611
|
const receiveDeployment = upload.any();
|
|
1541
1612
|
api('post', '/api/v1/deployments', null, (req, res) => {
|
|
1542
1613
|
receiveDeployment(req, res, (error) => {
|
|
@@ -1547,6 +1618,25 @@ export async function createApp(options = {}) {
|
|
|
1547
1618
|
});
|
|
1548
1619
|
});
|
|
1549
1620
|
});
|
|
1621
|
+
/**
|
|
1622
|
+
* Services that keep data on a node and are placed on different nodes by `compose` than by the current
|
|
1623
|
+
* deployment. Deploying such a change starts the service empty on the new node: the data does not move.
|
|
1624
|
+
*/
|
|
1625
|
+
function movedServices(project, compose) {
|
|
1626
|
+
const current = currentDesired(project);
|
|
1627
|
+
if (!current)
|
|
1628
|
+
return [];
|
|
1629
|
+
const before = JSON.parse(current.state_json);
|
|
1630
|
+
return Object.entries(compose.services || {})
|
|
1631
|
+
.filter(([name, svc]) => {
|
|
1632
|
+
const old = before.services?.[name]?.placement?.nodes ?? [];
|
|
1633
|
+
const next = svc['x-tug']?.placement?.nodes ?? [];
|
|
1634
|
+
const keepsData = Boolean(svc['x-tug']?.backup) ||
|
|
1635
|
+
(svc.volumes ?? []).some((mount) => typeof mount === 'object' && mount.type === 'volume');
|
|
1636
|
+
return keepsData && old.length > 0 && next.length > 0 && old.some((n) => !next.includes(n));
|
|
1637
|
+
})
|
|
1638
|
+
.map(([name]) => name);
|
|
1639
|
+
}
|
|
1550
1640
|
async function deployRequest(req, res, uploadError) {
|
|
1551
1641
|
const files = (Array.isArray(req.files) ? req.files : []);
|
|
1552
1642
|
let responseBody;
|
|
@@ -1620,6 +1710,10 @@ export async function createApp(options = {}) {
|
|
|
1620
1710
|
throw httpError(400, errMessage(e));
|
|
1621
1711
|
}
|
|
1622
1712
|
assertIngressHostsFree(project, compose);
|
|
1713
|
+
const acknowledgedMoves = Array.isArray(body.moves) ? body.moves.map(String) : [];
|
|
1714
|
+
const unacknowledged = movedServices(project, compose).filter((name) => !acknowledgedMoves.includes(name));
|
|
1715
|
+
if (unacknowledged.length)
|
|
1716
|
+
throw httpError(409, `${unacknowledged.join(', ')} would start empty on a different node: its data does not move. Back it up, then deploy with --move ${unacknowledged.join(' --move ')} to hold the services that write to it, and run tug restore run <target> --confirm`);
|
|
1623
1717
|
// The CLI interpolates control secrets as sentinels; a reference to a secret that is gone would deploy an empty value.
|
|
1624
1718
|
const referenced = sentinelNames(compose);
|
|
1625
1719
|
if (referenced.includes(REGISTRY_AUTH_SECRET))
|
|
@@ -1770,6 +1864,11 @@ export async function createApp(options = {}) {
|
|
|
1770
1864
|
!db.get('SELECT 1 FROM locks WHERE name=? AND owner=? AND expires_at>?', buildLock.name, buildLock.owner, now()))
|
|
1771
1865
|
throw httpError(409, 'deployment was cancelled or its build lease was lost');
|
|
1772
1866
|
db.run('INSERT INTO desired_states(id,project_id,deployment_id,version,state_json,created_at) VALUES(?,?,?,?,?,?)', id('ds'), project, publishingId, version, JSON.stringify(desired), ts);
|
|
1867
|
+
// The moved service starts empty: keep what writes to it stopped until its data is restored.
|
|
1868
|
+
for (const moved of acknowledgedMoves.filter((name) => compose.services?.[name]))
|
|
1869
|
+
for (const writer of discoverWriters(compose, `service:${moved}`))
|
|
1870
|
+
if (!acknowledgedMoves.includes(writer))
|
|
1871
|
+
db.run('INSERT OR REPLACE INTO service_holds(project_id,service,reason,restore_id,created_at) VALUES(?,?,?,?,?)', project, writer, `moved ${moved}`, null, ts);
|
|
1773
1872
|
for (const [volumeName, volumeSpec] of Object.entries(compose.volumes || {})) {
|
|
1774
1873
|
const lp = volumeSpec?.['x-tug'] || {};
|
|
1775
1874
|
const nodeName = lp.placement?.nodes?.[0] || null;
|
|
@@ -1933,6 +2032,31 @@ export async function createApp(options = {}) {
|
|
|
1933
2032
|
releaseLock(lock);
|
|
1934
2033
|
}
|
|
1935
2034
|
});
|
|
2035
|
+
api('post', '/api/v1/holds/release', bodyProject, async (req, res) => withWriter(async () => {
|
|
2036
|
+
const project = String(req.body?.project || 'site');
|
|
2037
|
+
const services = Array.isArray(req.body?.services)
|
|
2038
|
+
? req.body.services.map(String)
|
|
2039
|
+
: [];
|
|
2040
|
+
// Only holds a deploy placed for a move; a restore's holds are dropped by `restore release`.
|
|
2041
|
+
const rows = db.all('SELECT service FROM service_holds WHERE project_id=? AND restore_id IS NULL', project);
|
|
2042
|
+
const released = rows
|
|
2043
|
+
.map((row) => row.service)
|
|
2044
|
+
.filter((service) => !services.length || services.includes(service));
|
|
2045
|
+
for (const service of released)
|
|
2046
|
+
db.run('DELETE FROM service_holds WHERE project_id=? AND service=? AND restore_id IS NULL', project, service);
|
|
2047
|
+
wakeAll(project);
|
|
2048
|
+
res.json({ ok: true, project, released });
|
|
2049
|
+
}));
|
|
2050
|
+
api('post', '/api/v1/images/prune', null, async (req, res) => withWriter(async () => {
|
|
2051
|
+
const keep = req.body?.keep === undefined ? 5 : Number(req.body.keep);
|
|
2052
|
+
if (!Number.isInteger(keep) || keep < 1)
|
|
2053
|
+
return res.status(400).json({ error: 'keep must be a positive integer' });
|
|
2054
|
+
res.json({
|
|
2055
|
+
dryRun: Boolean(req.body?.dryRun),
|
|
2056
|
+
keep,
|
|
2057
|
+
...(await pruneArtifacts(keep, Boolean(req.body?.dryRun))),
|
|
2058
|
+
});
|
|
2059
|
+
}));
|
|
1936
2060
|
api('post', '/api/v1/control/backups/run', null, async (req, res) => withWriter(async () => {
|
|
1937
2061
|
const backupDir = process.env.TUG_CONTROL_BACKUP_DIR || path.join(DATA_DIR, 'control-backups');
|
|
1938
2062
|
// The archive holds the master key and the CA key, so nothing here is ever readable beyond the control user.
|
|
@@ -1954,14 +2078,16 @@ export async function createApp(options = {}) {
|
|
|
1954
2078
|
recursive: true,
|
|
1955
2079
|
filter: (src) => path.basename(src) !== 'server.json',
|
|
1956
2080
|
});
|
|
1957
|
-
|
|
1958
|
-
|
|
2081
|
+
const withImages = req.body?.images !== false;
|
|
2082
|
+
if (withImages)
|
|
2083
|
+
await fs.cp(artifactDir, path.join(staging, 'images'), { recursive: true });
|
|
2084
|
+
await fs.writeFile(path.join(staging, 'metadata.json'), JSON.stringify({ backupId, createdAt: ts, type: 'control', images: withImages }, null, 2), { mode: 0o600 });
|
|
1959
2085
|
await tar.c({ gzip: true, file: artifactRef, cwd: staging, mode: 0o600 }, [
|
|
1960
2086
|
'tug.db',
|
|
1961
2087
|
'master.key',
|
|
1962
2088
|
'metadata.json',
|
|
1963
2089
|
'pki',
|
|
1964
|
-
'images',
|
|
2090
|
+
...(withImages ? ['images'] : []),
|
|
1965
2091
|
]);
|
|
1966
2092
|
}
|
|
1967
2093
|
catch (error) {
|
|
@@ -2007,11 +2133,16 @@ export async function createApp(options = {}) {
|
|
|
2007
2133
|
for (const secret of snapshot.all('SELECT * FROM secrets'))
|
|
2008
2134
|
decrypt(secret.encrypted_value, key);
|
|
2009
2135
|
const states = snapshot.all('SELECT * FROM desired_states');
|
|
2010
|
-
|
|
2136
|
+
const meta = JSON.parse(await fs.readFile(path.join(restoreDir, 'metadata.json'), 'utf8').catch(() => '{}'));
|
|
2137
|
+
// A backup taken without images restores the archive-less builds as pruned on the next start.
|
|
2138
|
+
for (const stateRow of meta.images === false ? [] : states) {
|
|
2011
2139
|
const state = JSON.parse(stateRow.state_json);
|
|
2012
2140
|
for (const service of Object.values(state.services)) {
|
|
2013
2141
|
if (!service.artifact)
|
|
2014
2142
|
continue;
|
|
2143
|
+
// `image prune` already removed this one before the backup.
|
|
2144
|
+
if (snapshot.get("SELECT 1 FROM builds WHERE id=? AND state='pruned'", service.artifact.buildId))
|
|
2145
|
+
continue;
|
|
2015
2146
|
const build = snapshot.get('SELECT * FROM builds WHERE id=? AND state=?', service.artifact.buildId, 'complete');
|
|
2016
2147
|
if (!build?.artifact_path ||
|
|
2017
2148
|
!/^images\/build_[a-zA-Z0-9_-]+\.tar\.gz$/.test(build.artifact_path))
|
package/dist/credentials.js
CHANGED
|
@@ -53,6 +53,8 @@ export const ROUTE_ROLES = {
|
|
|
53
53
|
'DELETE /api/v1/backup/destination': 'admin',
|
|
54
54
|
'GET /api/v1/backup/repository-password': 'admin',
|
|
55
55
|
'POST /api/v1/control/backups/run': 'admin',
|
|
56
|
+
'POST /api/v1/images/prune': 'admin',
|
|
57
|
+
'POST /api/v1/holds/release': 'admin',
|
|
56
58
|
'POST /api/v1/control/restore': 'admin',
|
|
57
59
|
'GET /api/v1/diagnostics': 'admin',
|
|
58
60
|
'POST /api/v1/diagnostics/nodes': 'admin',
|
package/dist/docker-api.d.ts
CHANGED
package/dist/live.js
CHANGED
|
@@ -312,6 +312,7 @@ export async function startOneOff(docker, template, command) {
|
|
|
312
312
|
NetworkMode: host.NetworkMode,
|
|
313
313
|
...(host.Binds?.length ? { Binds: host.Binds } : {}),
|
|
314
314
|
...(host.Mounts?.length ? { Mounts: host.Mounts } : {}),
|
|
315
|
+
...(host.ExtraHosts?.length ? { ExtraHosts: host.ExtraHosts } : {}),
|
|
315
316
|
},
|
|
316
317
|
});
|
|
317
318
|
const remove = () => docker.removeContainer(id).catch(() => { });
|