@wpelevator/tug 0.11.0 → 0.13.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 +110 -68
- package/dist/agent.js +38 -2
- package/dist/backup-config.js +1 -1
- package/dist/backup-restore.d.ts +9 -2
- package/dist/backup-restore.js +75 -9
- package/dist/backup-scheduler.js +3 -1
- package/dist/cli-build.d.ts +1 -1
- package/dist/cli-build.js +4 -1
- package/dist/cli-config.d.ts +1 -1
- package/dist/cli-config.js +7 -2
- package/dist/cli-process.d.ts +13 -0
- package/dist/cli-process.js +46 -0
- package/dist/cli-progress.d.ts +11 -0
- package/dist/cli-progress.js +37 -0
- package/dist/cli-prompt.js +5 -4
- package/dist/cli-selection.d.ts +13 -0
- package/dist/cli-selection.js +39 -0
- package/dist/cli.js +4 -0
- package/dist/commands/backup.js +48 -31
- package/dist/commands/context.d.ts +6 -5
- package/dist/commands/context.js +65 -28
- package/dist/commands/control.js +56 -42
- package/dist/commands/credential.js +21 -2
- package/dist/commands/deploy.d.ts +4 -0
- package/dist/commands/deploy.js +116 -69
- package/dist/commands/hold.d.ts +2 -0
- package/dist/commands/hold.js +21 -0
- package/dist/commands/host.d.ts +8 -2
- package/dist/commands/host.js +36 -8
- 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.d.ts +1 -0
- package/dist/commands/node.js +139 -73
- package/dist/commands/observe.js +7 -2
- package/dist/commands/restore.js +71 -50
- package/dist/commands/secret.js +8 -3
- package/dist/commands/selection.d.ts +22 -0
- package/dist/commands/selection.js +90 -0
- package/dist/commands/service.js +17 -6
- package/dist/commands/volume.js +3 -2
- package/dist/control.js +234 -55
- package/dist/credentials.js +7 -0
- package/dist/db-backup-destination.d.ts +14 -0
- package/dist/db-backup-destination.js +28 -0
- package/dist/db-locks.d.ts +10 -0
- package/dist/db-locks.js +23 -0
- package/dist/db-transaction.d.ts +3 -0
- package/dist/db-transaction.js +24 -0
- package/dist/docker-api.d.ts +1 -0
- package/dist/image.d.ts +9 -0
- package/dist/image.js +19 -0
- package/dist/live.js +1 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +6 -0
- package/docs/access-control.md +166 -0
- package/docs/architecture.md +254 -0
- package/docs/backups.md +160 -0
- package/docs/cli.md +182 -0
- package/docs/compose.md +245 -0
- package/docs/configuration.md +52 -0
- package/docs/control-plane.md +112 -0
- package/docs/development.md +140 -0
- package/docs/getting-started.md +75 -0
- package/docs/healthchecks.md +51 -0
- package/docs/migration.md +113 -0
- package/docs/networking.md +150 -0
- package/docs/nodes.md +143 -0
- package/docs/observability.md +213 -0
- package/docs/real-host-evaluation.md +224 -0
- package/docs/releases.md +39 -0
- package/docs/roadmap.md +234 -0
- package/docs/security.md +198 -0
- package/docs/service-placement.md +72 -0
- package/docs/standalone-binary.md +79 -0
- package/docs/swarm-comparison.md +197 -0
- package/docs/terminology.md +74 -0
- package/docs/testing.md +254 -0
- package/docs/troubleshooting.md +33 -0
- package/package.json +6 -3
package/dist/commands/deploy.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { selectDeployment, selectService } from "./selection.js";
|
|
2
|
+
import { existsSync, openAsBlob } from 'node:fs';
|
|
2
3
|
import fs from 'node:fs/promises';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { createLocalBuildSnapshot, createSourceArchive, prepareLocalImages } from "../cli-build.js";
|
|
7
|
+
import { createProgress, withProgress } from "../cli-progress.js";
|
|
6
8
|
import { canPrompt } from "../cli-prompt.js";
|
|
7
9
|
import { findMissingSecrets, promptForSecrets } from "../cli-secrets.js";
|
|
8
10
|
import { composeHash } from "../compose.js";
|
|
9
11
|
import { normalizeCompose } from "../normalize.js";
|
|
10
|
-
import { loadCfg, print, projectOf, request, waitForCommand, waitForDeployment, } from "./context.js";
|
|
12
|
+
import { loadCfg, collect, formatDeployment, print, printResult, projectOf, request, waitForCommand, waitForDeployment, } from "./context.js";
|
|
11
13
|
async function makeContext() {
|
|
12
14
|
const cfg = await loadCfg();
|
|
13
15
|
const tmp = path.join(os.tmpdir(), `tug-${Date.now()}.tgz`);
|
|
@@ -24,112 +26,157 @@ async function makeContext() {
|
|
|
24
26
|
}
|
|
25
27
|
return tmp;
|
|
26
28
|
}
|
|
29
|
+
/** The first conventional Compose file name in the working directory; `docker-compose.yml` when none exists. */
|
|
30
|
+
export function defaultComposeFile(cwd = process.cwd()) {
|
|
31
|
+
const names = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
|
32
|
+
return names.find((name) => existsSync(path.join(cwd, name))) ?? 'docker-compose.yml';
|
|
33
|
+
}
|
|
34
|
+
/** The top-level `name:` of a Compose file, which Compose would use as the project name. */
|
|
35
|
+
export async function composeName(file) {
|
|
36
|
+
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
37
|
+
return /^name:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*(?:#.*)?$/m.exec(text)?.[1];
|
|
38
|
+
}
|
|
27
39
|
export function registerDeploy(program) {
|
|
28
40
|
const deploy = program
|
|
29
41
|
.command('deploy')
|
|
30
42
|
.description('deploy the Compose project, or cancel a deployment with `deploy cancel <id>`')
|
|
31
|
-
.option('-f, --file <file>', 'compose file',
|
|
43
|
+
.option('-f, --file <file>', 'compose file', defaultComposeFile())
|
|
32
44
|
.option('--version <version>', 'display version (default: git-<Compose hash>)')
|
|
33
45
|
.option('--build-local', 'build application images locally and upload them without source')
|
|
34
46
|
.option('--no-wait', 'return after desired state is created')
|
|
35
47
|
.option('--timeout <seconds>', 'wait timeout in seconds', '300')
|
|
48
|
+
.option('--json', 'print the full result, including the desired state, as JSON')
|
|
49
|
+
.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
50
|
.action(async (o) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
const progress = createProgress();
|
|
52
|
+
try {
|
|
53
|
+
progress.stage('Preparing deployment');
|
|
54
|
+
const cfg = await loadCfg();
|
|
55
|
+
const project = o.project || cfg.project || 'site';
|
|
56
|
+
// Control secrets are interpolated as sentinels; the agent substitutes the node's released values.
|
|
57
|
+
const { secrets } = await request('GET', `/api/v1/secrets?project=${encodeURIComponent(project)}`);
|
|
58
|
+
const secretNames = secrets.map((secret) => secret.name);
|
|
59
|
+
const missing = await findMissingSecrets(o.file, secretNames);
|
|
60
|
+
if (missing.length) {
|
|
61
|
+
if (!canPrompt())
|
|
62
|
+
throw new Error(`${o.file} needs values that are not set: ${missing.join(', ')}; create each with tug secret set <name> (or run tug deploy in a terminal to be prompted)`);
|
|
63
|
+
console.error(`Missing secrets for project ${project}: ${missing.join(', ')}`);
|
|
64
|
+
// Only names that do not exist yet are stored, so an existing secret is never overwritten.
|
|
65
|
+
for (const [name, value] of Object.entries(await promptForSecrets(missing))) {
|
|
66
|
+
await request('POST', '/api/v1/secrets', { project, name, value });
|
|
67
|
+
secretNames.push(name);
|
|
68
|
+
}
|
|
51
69
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const movePayload = o.move.length ? { moves: o.move } : {};
|
|
71
|
+
const declared = await composeName(o.file);
|
|
72
|
+
if (declared && declared !== project)
|
|
73
|
+
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)`);
|
|
74
|
+
const compose = await normalizeCompose(o.file, secretNames, { project });
|
|
75
|
+
const version = o.version || `git-${composeHash(compose)}`;
|
|
76
|
+
let response;
|
|
77
|
+
if (o.buildLocal) {
|
|
78
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-local-build-'));
|
|
79
|
+
try {
|
|
80
|
+
const form = new FormData();
|
|
81
|
+
progress.stage('Preparing local build snapshot');
|
|
82
|
+
const source = Object.values(compose.services).some((service) => service.build)
|
|
83
|
+
? await createLocalBuildSnapshot(process.cwd(), directory, [
|
|
84
|
+
cfg.file,
|
|
85
|
+
cfg.localFile,
|
|
86
|
+
...(cfg.credentialFile ? [cfg.credentialFile] : []),
|
|
87
|
+
])
|
|
88
|
+
: process.cwd();
|
|
89
|
+
const localImages = await prepareLocalImages(compose, source, directory, undefined, progress.stage);
|
|
90
|
+
for (const { field } of localImages)
|
|
91
|
+
form.append(field, await openAsBlob(path.join(directory, `${field}.tar.gz`)), `${field}.tar.gz`);
|
|
92
|
+
form.append('payload', JSON.stringify({ project, version, compose, localImages, ...movePayload }));
|
|
93
|
+
progress.stage('Uploading images and preparing deployment on control');
|
|
94
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
98
|
+
}
|
|
72
99
|
}
|
|
73
|
-
|
|
74
|
-
|
|
100
|
+
else if (Object.values(compose.services).some((service) => service.build)) {
|
|
101
|
+
progress.stage('Archiving source for remote build');
|
|
102
|
+
const tarball = await makeContext();
|
|
103
|
+
try {
|
|
104
|
+
const form = new FormData();
|
|
105
|
+
form.append('payload', JSON.stringify({ project, version, compose, ...movePayload }));
|
|
106
|
+
form.append('context', await openAsBlob(tarball), 'context.tgz');
|
|
107
|
+
progress.stage('Uploading source and building images on control');
|
|
108
|
+
response = await request('POST', '/api/v1/deployments', form, true);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
await fs.rm(tarball, { force: true });
|
|
112
|
+
}
|
|
75
113
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
114
|
+
else {
|
|
115
|
+
progress.stage('Resolving images and preparing deployment on control');
|
|
116
|
+
response = await request('POST', '/api/v1/deployments', {
|
|
117
|
+
project,
|
|
118
|
+
version,
|
|
119
|
+
compose,
|
|
120
|
+
...movePayload,
|
|
121
|
+
});
|
|
84
122
|
}
|
|
85
|
-
|
|
86
|
-
|
|
123
|
+
if (o.wait) {
|
|
124
|
+
progress.stage(`Deployment ${response.deployment.id}: ${response.deployment.state}`);
|
|
125
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout), (deployment) => progress.stage(`Deployment ${deployment.id}: ${deployment.state}`));
|
|
87
126
|
}
|
|
127
|
+
printResult(o.json, response, formatDeployment);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
progress.stop();
|
|
88
131
|
}
|
|
89
|
-
else
|
|
90
|
-
response = await request('POST', '/api/v1/deployments', {
|
|
91
|
-
project,
|
|
92
|
-
version,
|
|
93
|
-
compose,
|
|
94
|
-
});
|
|
95
|
-
if (o.wait)
|
|
96
|
-
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
97
|
-
print(response);
|
|
98
132
|
});
|
|
99
133
|
deploy
|
|
100
|
-
.command('cancel
|
|
134
|
+
.command('cancel [id]')
|
|
101
135
|
.description('cancel a deployment that has not finished')
|
|
102
|
-
.action(async (id) => print(await request('POST', `/api/v1/deployments/${encodeURIComponent(id)}/cancel`, {})));
|
|
136
|
+
.action(async (id) => print(await request('POST', `/api/v1/deployments/${encodeURIComponent(await selectDeployment(id))}/cancel`, {})));
|
|
103
137
|
program
|
|
104
138
|
.command('rollback')
|
|
105
139
|
.description('activate the previous successful deployment')
|
|
106
140
|
.option('--force')
|
|
107
141
|
.option('--no-wait', 'return immediately after activating rollback')
|
|
108
142
|
.option('--timeout <seconds>', 'wait timeout', '300')
|
|
143
|
+
.option('--json', 'print the full result, including the desired state, as JSON')
|
|
109
144
|
.action(async (o) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
145
|
+
return withProgress('Preparing rollback on control', async (progress) => {
|
|
146
|
+
const response = await request('POST', '/api/v1/rollback', {
|
|
147
|
+
project: await projectOf(o),
|
|
148
|
+
force: Boolean(o.force),
|
|
149
|
+
});
|
|
150
|
+
if (o.wait) {
|
|
151
|
+
progress.stage(`Deployment ${response.deployment.id}: ${response.deployment.state}`);
|
|
152
|
+
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout), (deployment) => progress.stage(`Deployment ${deployment.id}: ${deployment.state}`));
|
|
153
|
+
}
|
|
154
|
+
printResult(o.json, response, formatDeployment);
|
|
113
155
|
});
|
|
114
|
-
if (o.wait)
|
|
115
|
-
response.deployment = await waitForDeployment(response.deployment.id, Number(o.timeout));
|
|
116
|
-
print(response);
|
|
117
156
|
});
|
|
118
157
|
program
|
|
119
|
-
.command('scale
|
|
158
|
+
.command('scale [service] [replicas]')
|
|
159
|
+
.option('--replicas <n>', 'replica count when choosing a service interactively')
|
|
120
160
|
.description('scale a service to N replicas across matching nodes')
|
|
161
|
+
.option('--json', 'print the full result as JSON')
|
|
121
162
|
.action(async (service, replicas, o) => {
|
|
163
|
+
if (o.replicas && replicas)
|
|
164
|
+
throw new Error('pass replicas positionally or with --replicas, not both');
|
|
165
|
+
replicas = replicas || o.replicas || '';
|
|
122
166
|
const project = await projectOf(o);
|
|
123
167
|
if (!/^[1-9]\d*$/.test(replicas))
|
|
124
168
|
throw new Error('replicas must be a positive integer');
|
|
125
|
-
|
|
169
|
+
service = await selectService(service);
|
|
170
|
+
const result = await request('POST', '/api/v1/scale', { project, service, replicas: Number(replicas) });
|
|
171
|
+
printResult(o.json, result, (r) => `${r.service} scaled to ${r.replicas} replica${r.replicas === 1 ? '' : 's'} on ${r.nodes.join(', ')}`);
|
|
126
172
|
});
|
|
127
173
|
program
|
|
128
|
-
.command('restart
|
|
174
|
+
.command('restart [service]')
|
|
129
175
|
.description('restart a service on every node that runs it')
|
|
130
176
|
.option('--timeout <seconds>', 'wait timeout', '60')
|
|
131
177
|
.action(async (svc, o) => {
|
|
132
178
|
const project = await projectOf(o);
|
|
179
|
+
svc = await selectService(svc);
|
|
133
180
|
const created = await request('POST', '/api/v1/restart', { project, service: svc });
|
|
134
181
|
const cmd = await waitForCommand(created.commandId, Number(o.timeout));
|
|
135
182
|
if (cmd.state === 'failed')
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { selectHolds } from "./selection.js";
|
|
2
|
+
import { print, projectOf, request } from "./context.js";
|
|
3
|
+
export function registerHold(program) {
|
|
4
|
+
const hold = program
|
|
5
|
+
.command('hold')
|
|
6
|
+
.description('services a deploy stopped because their data moved to another node');
|
|
7
|
+
hold
|
|
8
|
+
.command('release [services...]')
|
|
9
|
+
.description('start the services that `tug deploy --move` held, without restoring (all of them when none is named); a restore releases its own holds')
|
|
10
|
+
.option('--select', 'choose one or more move holds instead of releasing all')
|
|
11
|
+
.action(async (services, o) => {
|
|
12
|
+
if (o.select && services.length)
|
|
13
|
+
throw new Error('--select cannot be combined with explicit services');
|
|
14
|
+
if (o.select)
|
|
15
|
+
services = await selectHolds();
|
|
16
|
+
return print(await request('POST', '/api/v1/holds/release', {
|
|
17
|
+
project: await projectOf(o),
|
|
18
|
+
services,
|
|
19
|
+
}));
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/commands/host.d.ts
CHANGED
|
@@ -5,8 +5,8 @@ export declare function preflightRemote(target: string, { controlUrl, ports, min
|
|
|
5
5
|
minDiskMb?: number;
|
|
6
6
|
/** Requires the Compose plugin; only the control host runs Compose, nodes need the Docker Engine alone. */
|
|
7
7
|
compose?: boolean;
|
|
8
|
-
}): void
|
|
9
|
-
export declare function transferTugImage(target: string, imageTag: string): Promise<void>;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
export declare function transferTugImage(target: string, imageTag: string, stage: (label: string) => void): Promise<void>;
|
|
10
10
|
export declare function dockerBootstrap(): Promise<string>;
|
|
11
11
|
/** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
|
|
12
12
|
export declare function bootstrapDocker(target: string, compose?: boolean): Promise<void>;
|
|
@@ -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
|
@@ -6,7 +6,7 @@ import { saveImageArchive } from "../artifacts.js";
|
|
|
6
6
|
import { controlPortCheck, localImageBuildArgs, shellQuote as sh } from "../cli-install.js";
|
|
7
7
|
import { LIST_ADDRESSES_COMMAND, parseAddressCandidates, sortCandidates, } from "../cli-onboarding.js";
|
|
8
8
|
import { runCapture, runLocal, templatesDir } from "./context.js";
|
|
9
|
-
export function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
|
|
9
|
+
export async function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 1024, compose = false, } = {}) {
|
|
10
10
|
const checks = [
|
|
11
11
|
[
|
|
12
12
|
'docker.version',
|
|
@@ -50,18 +50,23 @@ export function preflightRemote(target, { controlUrl, ports = [], minDiskMb = 10
|
|
|
50
50
|
const script = `set -e
|
|
51
51
|
${checks.map(([name, cmd, fix]) => `echo "preflight: ${name}"; (${cmd}) || { echo "FAILED ${name}: ${fix}" >&2; exit 20; }`).join('\n')}
|
|
52
52
|
`;
|
|
53
|
-
runLocal('ssh', ['--', target, script]);
|
|
53
|
+
await runLocal('ssh', ['--', target, script]);
|
|
54
54
|
}
|
|
55
|
-
export async function transferTugImage(target, imageTag) {
|
|
55
|
+
export async function transferTugImage(target, imageTag, stage) {
|
|
56
|
+
stage(`Checking architecture on ${target}`);
|
|
56
57
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'tug-runtime-'));
|
|
57
58
|
const archive = path.join(directory, 'image.tar.gz');
|
|
58
59
|
const remote = `/tmp/${path.basename(directory)}.tar.gz`;
|
|
59
60
|
try {
|
|
60
|
-
const uname = runCapture('ssh', ['--', target, 'uname -m']);
|
|
61
|
-
|
|
61
|
+
const uname = await runCapture('ssh', ['--', target, 'uname -m']);
|
|
62
|
+
stage(`Building Tug image ${imageTag}`);
|
|
63
|
+
await runLocal('docker', localImageBuildArgs(imageTag, uname));
|
|
64
|
+
stage(`Exporting Tug image ${imageTag}`);
|
|
62
65
|
await saveImageArchive(imageTag, archive);
|
|
63
|
-
|
|
64
|
-
runLocal('
|
|
66
|
+
stage(`Transferring Tug image to ${target}`);
|
|
67
|
+
await runLocal('scp', ['--', archive, `${target}:${remote}`]);
|
|
68
|
+
stage(`Loading Tug image on ${target}`);
|
|
69
|
+
await runLocal('ssh', [
|
|
65
70
|
'--',
|
|
66
71
|
target,
|
|
67
72
|
`set -eu\ntrap ${sh(`rm -f ${sh(remote)}`)} EXIT\ndocker load --input ${sh(remote)}`,
|
|
@@ -76,7 +81,7 @@ export async function dockerBootstrap() {
|
|
|
76
81
|
}
|
|
77
82
|
/** Installs Docker over SSH; `compose` also installs the Compose plugin, which only the control host needs. */
|
|
78
83
|
export async function bootstrapDocker(target, compose = false) {
|
|
79
|
-
runLocal('ssh', [
|
|
84
|
+
await runLocal('ssh', [
|
|
80
85
|
'--',
|
|
81
86
|
target,
|
|
82
87
|
`${compose ? 'TUG_INSTALL_COMPOSE=1 ' : ''}sh -eu -c ${sh(await dockerBootstrap())}`,
|
|
@@ -97,3 +102,26 @@ export function discoverAddresses(target) {
|
|
|
97
102
|
const result = spawnSync('ssh', ['--', target, LIST_ADDRESSES_COMMAND], { encoding: 'utf8' });
|
|
98
103
|
return result.status === 0 ? sortCandidates(parseAddressCandidates(String(result.stdout))) : [];
|
|
99
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* The script that retires a node host: stops the agent and ingress, removes the project containers, networks and
|
|
107
|
+
* agent state. Project volumes (your data) and the ingress certificates go only with `deleteData`. A host that runs
|
|
108
|
+
* the control plane is refused, because removing it would destroy the master key and the private CA.
|
|
109
|
+
*/
|
|
110
|
+
export function teardownScript(deleteData) {
|
|
111
|
+
return [
|
|
112
|
+
'set -eu',
|
|
113
|
+
"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",
|
|
114
|
+
'docker rm -f tug-agent tug-caddy >/dev/null 2>&1 || true',
|
|
115
|
+
'ids=$(docker ps -aq --filter label=tug.project --filter label=tug.job; docker ps -aq --filter label=tug.project)',
|
|
116
|
+
'[ -z "$ids" ] || docker rm -fv $ids >/dev/null',
|
|
117
|
+
"docker network ls -q --filter 'name=^tug_' | xargs -r docker network rm >/dev/null 2>&1 || true",
|
|
118
|
+
'rm -rf /etc/tug /var/lib/tug',
|
|
119
|
+
...(deleteData
|
|
120
|
+
? [
|
|
121
|
+
'docker volume ls -q --filter label=tug.project | xargs -r docker volume rm >/dev/null',
|
|
122
|
+
'docker volume rm tug-caddy-data tug-caddy-config tug-caddy-etc tug_restic_cache >/dev/null 2>&1 || true',
|
|
123
|
+
]
|
|
124
|
+
: []),
|
|
125
|
+
'echo "node host cleaned"',
|
|
126
|
+
].join('\n');
|
|
127
|
+
}
|
|
@@ -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
|
}
|