@wpelevator/tug 0.6.0 → 0.8.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 +24 -22
- package/dist/agent-credential.d.ts +39 -0
- package/dist/agent-credential.js +135 -0
- package/dist/agent.d.ts +15 -9
- package/dist/agent.js +126 -104
- package/dist/backup-check.d.ts +11 -0
- package/dist/backup-check.js +10 -0
- package/dist/backup-config.d.ts +5 -0
- package/dist/backup-config.js +23 -3
- package/dist/backup-destination.d.ts +1 -1
- package/dist/backup-destination.js +1 -1
- package/dist/backup-restore.js +28 -8
- package/dist/backup-scheduler.d.ts +7 -1
- package/dist/backup-scheduler.js +21 -6
- package/dist/cli-build.js +70 -4
- package/dist/cli-config.d.ts +9 -5
- package/dist/cli-config.js +89 -34
- package/dist/cli-install.js +1 -1
- package/dist/cli-live-transport.d.ts +3 -2
- package/dist/cli-live-transport.js +9 -5
- package/dist/cli.js +276 -142
- package/dist/compose.d.ts +3 -1
- package/dist/compose.js +14 -2
- package/dist/control-credential.d.ts +12 -0
- package/dist/control-credential.js +46 -0
- package/dist/control.d.ts +18 -1
- package/dist/control.js +586 -202
- package/dist/credentials.d.ts +111 -0
- package/dist/{tokens.js → credentials.js} +70 -54
- package/dist/db.d.ts +20 -5
- package/dist/db.js +3 -3
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +65 -0
- package/dist/ingress.js +4 -3
- package/dist/main.js +35 -0
- package/dist/normalize.d.ts +6 -0
- package/dist/normalize.js +16 -2
- package/dist/pki.d.ts +119 -0
- package/dist/pki.js +339 -0
- package/dist/protocol.d.ts +53 -22
- package/dist/status-report.d.ts +11 -0
- package/dist/status-report.js +147 -0
- package/dist/templates/agent.env +1 -3
- package/dist/templates/control-compose.yml +4 -3
- package/dist/templates/control-install.sh +8 -2
- package/dist/tls-server.d.ts +11 -0
- package/dist/tls-server.js +23 -0
- package/package.json +10 -8
- package/dist/tokens.d.ts +0 -91
package/dist/agent.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import { imageLocation } from "./registry.js";
|
|
4
|
-
import { createReadStream, createWriteStream } from 'node:fs';
|
|
4
|
+
import { createReadStream, createWriteStream, existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { Readable } from 'node:stream';
|
|
6
6
|
import { pipeline } from 'node:stream/promises';
|
|
7
7
|
import { validateImageArchive, verifyDownload } from "./artifacts.js";
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
10
9
|
import net from 'node:net';
|
|
11
10
|
import dns from 'node:dns/promises';
|
|
12
11
|
import { WebSocket } from 'ws';
|
|
12
|
+
import { agentIdentityCsr, createTransport, describeMissingIdentity, installIdentity, loadAgentDocument, renewCertificate, renewalTime, } from "./agent-credential.js";
|
|
13
|
+
import { clientTlsOptions, parseCredential } from "./pki.js";
|
|
13
14
|
import { createHash } from 'node:crypto';
|
|
14
15
|
import { addressBoundServices, isRolling, serviceSecretVersions } from "./compose.js";
|
|
15
16
|
import { LABEL_PROJECT, LABEL_SERVICE, containerSpecs, networkName, volumeName, } from "./container-spec.js";
|
|
@@ -23,24 +24,27 @@ import { openAgentDb } from "./db.js";
|
|
|
23
24
|
import { createHostRunner } from "./runners.js";
|
|
24
25
|
import { CADDY_CONTAINER, reconcileIngress, removeIngress } from "./ingress.js";
|
|
25
26
|
import { WG_INTERFACE, ensureWgKeys, readReportedKey, reconcileMesh, rotateWgKeys, writeReportedKey, } from "./mesh.js";
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
let TOKEN = process.env.TUG_AGENT_TOKEN;
|
|
27
|
+
// Connect-only override (for example an SSH tunnel); the certificate is still verified against the credential's public URL.
|
|
28
|
+
const CONTROL_URL_OVERRIDE = process.env.TUG_CONTROL_URL;
|
|
29
29
|
let NODE_NAME = process.env.TUG_NODE_NAME || process.env.HOSTNAME || 'node1';
|
|
30
30
|
const STATE_DIR = process.env.TUG_STATE_DIR || '/var/lib/tug';
|
|
31
31
|
const APPLY = process.env.TUG_APPLY !== 'false';
|
|
32
32
|
const INTERVAL = Number(process.env.TUG_POLL_INTERVAL_MS || 5000);
|
|
33
33
|
// Opt-in WireGuard mesh: needs `wg`/`ip` in the agent image, host networking and NET_ADMIN.
|
|
34
34
|
const MESH = process.env.TUG_MESH === 'true';
|
|
35
|
-
|
|
35
|
+
let activeDocument;
|
|
36
|
+
export function defaultContext(doc = activeDocument) {
|
|
37
|
+
if (!doc)
|
|
38
|
+
throw new Error(describeMissingIdentity());
|
|
39
|
+
const transport = createTransport(doc, CONTROL_URL_OVERRIDE);
|
|
36
40
|
return {
|
|
37
41
|
docker: new DockerEngine(),
|
|
38
|
-
http:
|
|
39
|
-
nodeName:
|
|
42
|
+
http: transport.http,
|
|
43
|
+
nodeName: doc.nodeName,
|
|
40
44
|
stateDir: STATE_DIR,
|
|
41
45
|
apply: APPLY,
|
|
42
|
-
controlUrl:
|
|
43
|
-
|
|
46
|
+
controlUrl: transport.base,
|
|
47
|
+
credential: doc,
|
|
44
48
|
host: createHostRunner(),
|
|
45
49
|
mesh: MESH,
|
|
46
50
|
caddyImage: process.env.TUG_CADDY_IMAGE,
|
|
@@ -74,18 +78,10 @@ async function agentDb(stateDir) {
|
|
|
74
78
|
}
|
|
75
79
|
return db;
|
|
76
80
|
}
|
|
77
|
-
function authHeaders(ctx) {
|
|
78
|
-
if (!ctx.token)
|
|
79
|
-
throw new Error('TUG_AGENT_TOKEN required');
|
|
80
|
-
return { Authorization: `Bearer ${ctx.token}` };
|
|
81
|
-
}
|
|
82
81
|
async function api(ctx, route, opts = {}) {
|
|
83
82
|
const res = await ctx.http(`${ctx.controlUrl}${route}`, {
|
|
84
83
|
...opts,
|
|
85
|
-
headers: {
|
|
86
|
-
...authHeaders(ctx),
|
|
87
|
-
...(opts.headers || {}),
|
|
88
|
-
},
|
|
84
|
+
headers: opts.headers || {},
|
|
89
85
|
});
|
|
90
86
|
if (!res.ok)
|
|
91
87
|
throw new Error(`${opts.method || 'GET'} ${route}: ${res.status} ${await res.text()}`);
|
|
@@ -100,10 +96,15 @@ export class AgentWakeChannel {
|
|
|
100
96
|
pending = false;
|
|
101
97
|
waiter;
|
|
102
98
|
handlers;
|
|
103
|
-
constructor(ctx, createSocket = (url,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
99
|
+
constructor(ctx, createSocket = (url, credential) => {
|
|
100
|
+
if (!credential)
|
|
101
|
+
throw new Error('agent credential required');
|
|
102
|
+
return new WebSocket(url, {
|
|
103
|
+
...clientTlsOptions(credential),
|
|
104
|
+
maxPayload: LIVE_MAX_FRAME_BYTES,
|
|
105
|
+
followRedirects: false,
|
|
106
|
+
});
|
|
107
|
+
}, handlers = {}) {
|
|
107
108
|
this.ctx = ctx;
|
|
108
109
|
this.createSocket = createSocket;
|
|
109
110
|
this.handlers = handlers;
|
|
@@ -115,13 +116,11 @@ export class AgentWakeChannel {
|
|
|
115
116
|
async connect() {
|
|
116
117
|
if (this.socket?.readyState === WebSocket.OPEN)
|
|
117
118
|
return true;
|
|
118
|
-
if (!this.ctx.token)
|
|
119
|
-
throw new Error('TUG_AGENT_TOKEN required');
|
|
120
119
|
const url = new URL('/agent/v1/stream', this.ctx.controlUrl);
|
|
121
120
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
122
121
|
return new Promise((resolve) => {
|
|
123
122
|
let settled = false;
|
|
124
|
-
const socket = this.createSocket(url.toString(), this.ctx.
|
|
123
|
+
const socket = this.createSocket(url.toString(), this.ctx.credential);
|
|
125
124
|
this.socket = socket;
|
|
126
125
|
const finish = (connected) => {
|
|
127
126
|
if (settled)
|
|
@@ -485,6 +484,30 @@ function tcpCheck(host, port, timeoutMs = 2000) {
|
|
|
485
484
|
s.on('error', reject);
|
|
486
485
|
});
|
|
487
486
|
}
|
|
487
|
+
let containerized;
|
|
488
|
+
/** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
|
|
489
|
+
export function runsInContainer() {
|
|
490
|
+
containerized ??= (() => {
|
|
491
|
+
if (process.env.container || existsSync('/.dockerenv'))
|
|
492
|
+
return true;
|
|
493
|
+
try {
|
|
494
|
+
return /docker|containerd|kubepods|libpod/.test(readFileSync('/proc/1/cgroup', 'utf8'));
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
return containerized;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Health checks dial the container's address on the project network. An agent that runs as a plain host process
|
|
504
|
+
* cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
|
|
505
|
+
*/
|
|
506
|
+
export function outsideContainerHint(health, inContainer = runsInContainer()) {
|
|
507
|
+
if (inContainer || health.type === 'docker')
|
|
508
|
+
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';
|
|
510
|
+
}
|
|
488
511
|
export async function waitForHealth(ctx, project, container, service, health) {
|
|
489
512
|
if (!ctx.apply || !health)
|
|
490
513
|
return;
|
|
@@ -518,7 +541,7 @@ export async function waitForHealth(ctx, project, container, service, health) {
|
|
|
518
541
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
519
542
|
}
|
|
520
543
|
}
|
|
521
|
-
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
|
|
544
|
+
throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}${outsideContainerHint(health)}`);
|
|
522
545
|
}
|
|
523
546
|
/** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
|
|
524
547
|
function describeError(error) {
|
|
@@ -762,7 +785,7 @@ export async function ensureArtifact(ctx, artifact) {
|
|
|
762
785
|
const downloadDir = await fs.mkdtemp(path.join(ctx.stateDir, 'image-download-'));
|
|
763
786
|
const file = path.join(downloadDir, 'image.tar.gz');
|
|
764
787
|
try {
|
|
765
|
-
const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, {
|
|
788
|
+
const response = await ctx.http(`${ctx.controlUrl}/agent/v1/images/${encodeURIComponent(artifact.buildId)}`, { redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
|
|
766
789
|
if (!response.ok || !response.body)
|
|
767
790
|
throw new Error(`image download failed: HTTP ${response.status}`);
|
|
768
791
|
await pipeline(Readable.fromWeb(response.body), verifyDownload(artifact), createWriteStream(file, { mode: 0o600 }));
|
|
@@ -1125,7 +1148,12 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1125
1148
|
}
|
|
1126
1149
|
const jobResults = jobs.pending();
|
|
1127
1150
|
commandResults.push(...jobResults);
|
|
1128
|
-
const report = {
|
|
1151
|
+
const report = {
|
|
1152
|
+
node: ctx.nodeName,
|
|
1153
|
+
sentAt: new Date().toISOString(),
|
|
1154
|
+
projects,
|
|
1155
|
+
commandResults,
|
|
1156
|
+
};
|
|
1129
1157
|
const running = jobs.statuses();
|
|
1130
1158
|
if (running.length)
|
|
1131
1159
|
report.jobs = running;
|
|
@@ -1141,19 +1169,6 @@ export async function pollOnce(ctx = defaultContext()) {
|
|
|
1141
1169
|
await jobs.acknowledge(jobResults);
|
|
1142
1170
|
return projects;
|
|
1143
1171
|
}
|
|
1144
|
-
const CREDS_FILE = () => path.join(STATE_DIR, 'agent.json');
|
|
1145
|
-
async function loadCreds() {
|
|
1146
|
-
try {
|
|
1147
|
-
return JSON.parse(await fs.readFile(CREDS_FILE(), 'utf8'));
|
|
1148
|
-
}
|
|
1149
|
-
catch {
|
|
1150
|
-
return null;
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
async function saveCreds(creds) {
|
|
1154
|
-
await ensureDir(STATE_DIR);
|
|
1155
|
-
await fs.writeFile(CREDS_FILE(), JSON.stringify(creds), { mode: 0o600 });
|
|
1156
|
-
}
|
|
1157
1172
|
async function readJoinKey() {
|
|
1158
1173
|
if (process.env.TUG_JOIN_KEY)
|
|
1159
1174
|
return process.env.TUG_JOIN_KEY.trim();
|
|
@@ -1168,84 +1183,90 @@ async function readJoinKey() {
|
|
|
1168
1183
|
}
|
|
1169
1184
|
return null;
|
|
1170
1185
|
}
|
|
1186
|
+
/** Enrolls with a single-use bootstrap certificate; a retry reuses the pending key, which control accepts by key. */
|
|
1171
1187
|
async function enroll(joinKey) {
|
|
1172
1188
|
let decoded;
|
|
1173
1189
|
try {
|
|
1174
1190
|
decoded = JSON.parse(Buffer.from(joinKey, 'base64url').toString('utf8'));
|
|
1191
|
+
if (decoded.v !== 2)
|
|
1192
|
+
throw new Error('unsupported join key version');
|
|
1193
|
+
parseCredential(decoded.credential);
|
|
1175
1194
|
}
|
|
1176
1195
|
catch (e) {
|
|
1177
|
-
throw new Error('invalid
|
|
1196
|
+
throw new Error('invalid join key (expected a base64url-encoded v2 join key)', { cause: e });
|
|
1178
1197
|
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1182
|
-
// node's fetch reads NODE_EXTRA_CA_CERTS only at startup, so apply an embedded CA by
|
|
1183
|
-
// persisting it and re-execing ourselves once with the env var set.
|
|
1184
|
-
if (decoded.ca && !process.env.NODE_EXTRA_CA_CERTS) {
|
|
1185
|
-
await ensureDir(STATE_DIR);
|
|
1186
|
-
const caPath = path.join(STATE_DIR, 'control-ca.pem');
|
|
1187
|
-
await fs.writeFile(caPath, decoded.ca);
|
|
1188
|
-
const r = spawnSync(process.execPath, process.argv.slice(1), {
|
|
1189
|
-
stdio: 'inherit',
|
|
1190
|
-
env: { ...process.env, NODE_EXTRA_CA_CERTS: caPath },
|
|
1191
|
-
});
|
|
1192
|
-
process.exit(r.status ?? 1);
|
|
1193
|
-
}
|
|
1194
|
-
console.log(`enrolling with ${controlUrl} as ${NODE_NAME}`);
|
|
1198
|
+
const transport = createTransport(decoded.credential, CONTROL_URL_OVERRIDE || decoded.connectUrl);
|
|
1199
|
+
console.log(`enrolling with ${decoded.credential.url} as ${decoded.name}`);
|
|
1200
|
+
const csr = await agentIdentityCsr(STATE_DIR);
|
|
1195
1201
|
const wgPublicKey = MESH ? (await ensureWgKeys(STATE_DIR)).publicKey : undefined;
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
name: NODE_NAME,
|
|
1202
|
-
address: process.env.TUG_NODE_ADDRESS,
|
|
1203
|
-
publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS,
|
|
1204
|
-
wgPublicKey,
|
|
1205
|
-
labels: decoded.labels ||
|
|
1206
|
-
(process.env.TUG_LABELS
|
|
1207
|
-
? process.env.TUG_LABELS.split(',')
|
|
1208
|
-
.map((s) => s.trim())
|
|
1209
|
-
.filter(Boolean)
|
|
1210
|
-
: undefined),
|
|
1211
|
-
}),
|
|
1212
|
-
});
|
|
1213
|
-
if (!res.ok)
|
|
1214
|
-
throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
|
|
1215
|
-
const out = (await res.json());
|
|
1216
|
-
const creds = {
|
|
1217
|
-
agentToken: out.agentToken,
|
|
1218
|
-
nodeName: out.node?.name || NODE_NAME,
|
|
1219
|
-
controlUrl,
|
|
1202
|
+
const body = {
|
|
1203
|
+
csr,
|
|
1204
|
+
address: process.env.TUG_NODE_ADDRESS,
|
|
1205
|
+
publicAddress: process.env.TUG_NODE_PUBLIC_ADDRESS,
|
|
1206
|
+
wgPublicKey,
|
|
1220
1207
|
};
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1208
|
+
try {
|
|
1209
|
+
const res = await transport.http(`${transport.base}/agent/v1/enroll`, {
|
|
1210
|
+
method: 'POST',
|
|
1211
|
+
headers: { 'content-type': 'application/json' },
|
|
1212
|
+
body: JSON.stringify(body),
|
|
1213
|
+
});
|
|
1214
|
+
if (!res.ok)
|
|
1215
|
+
throw new Error(`enroll failed: ${res.status} ${await res.text()}`);
|
|
1216
|
+
const doc = await installIdentity(STATE_DIR, (await res.json()));
|
|
1217
|
+
if (wgPublicKey)
|
|
1218
|
+
await writeReportedKey(STATE_DIR, wgPublicKey);
|
|
1219
|
+
console.log(`enrolled as node ${doc.nodeName}`);
|
|
1220
|
+
return doc;
|
|
1221
|
+
}
|
|
1222
|
+
finally {
|
|
1223
|
+
void transport.close();
|
|
1224
|
+
}
|
|
1226
1225
|
}
|
|
1227
|
-
|
|
1228
|
-
// then credentials persisted on the state volume, then a one-time enroll via the join key.
|
|
1226
|
+
/** Loads the persisted identity, or enrolls with a join key. Without either the agent refuses to start. */
|
|
1229
1227
|
export async function loadOrEnroll() {
|
|
1230
|
-
let
|
|
1231
|
-
|
|
1232
|
-
: await loadCreds();
|
|
1233
|
-
if (!creds) {
|
|
1228
|
+
let doc = await loadAgentDocument(STATE_DIR);
|
|
1229
|
+
if (!doc) {
|
|
1234
1230
|
const joinKey = await readJoinKey();
|
|
1235
1231
|
if (!joinKey)
|
|
1236
|
-
throw new Error(
|
|
1237
|
-
|
|
1232
|
+
throw new Error(describeMissingIdentity());
|
|
1233
|
+
doc = await enroll(joinKey);
|
|
1238
1234
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
CONTROL_URL = creds.controlUrl;
|
|
1243
|
-
return creds;
|
|
1235
|
+
NODE_NAME = doc.nodeName;
|
|
1236
|
+
activeDocument = doc;
|
|
1237
|
+
return doc;
|
|
1244
1238
|
}
|
|
1239
|
+
const RENEW_RETRY_MS = 15 * 60_000;
|
|
1245
1240
|
export async function main() {
|
|
1246
|
-
await loadOrEnroll();
|
|
1247
|
-
|
|
1248
|
-
|
|
1241
|
+
const doc = await loadOrEnroll();
|
|
1242
|
+
const ctx = defaultContext(doc);
|
|
1243
|
+
console.log(`tug-agent node=${NODE_NAME} control=${ctx.controlUrl} apply=${APPLY}`);
|
|
1244
|
+
let renewAt = renewalTime(doc.cert);
|
|
1245
|
+
/** Renews with the same key, saves, then swaps the client and reconnects. Failure keeps the old identity until it expires. */
|
|
1246
|
+
const renewIfDue = async () => {
|
|
1247
|
+
if (Date.now() < renewAt || !ctx.credential)
|
|
1248
|
+
return;
|
|
1249
|
+
try {
|
|
1250
|
+
const current = ctx.credential;
|
|
1251
|
+
const next = await renewCertificate(STATE_DIR, current, (csr) => api(ctx, '/agent/v1/renew', {
|
|
1252
|
+
method: 'POST',
|
|
1253
|
+
headers: { 'content-type': 'application/json' },
|
|
1254
|
+
body: JSON.stringify({ csr }),
|
|
1255
|
+
}));
|
|
1256
|
+
const transport = createTransport(next, CONTROL_URL_OVERRIDE);
|
|
1257
|
+
const previous = ctx.http;
|
|
1258
|
+
ctx.http = transport.http;
|
|
1259
|
+
ctx.credential = next;
|
|
1260
|
+
void previous;
|
|
1261
|
+
renewAt = renewalTime(next.cert);
|
|
1262
|
+
wake.close();
|
|
1263
|
+
console.log('node certificate renewed');
|
|
1264
|
+
}
|
|
1265
|
+
catch (e) {
|
|
1266
|
+
renewAt = Date.now() + RENEW_RETRY_MS;
|
|
1267
|
+
console.error(`certificate renewal failed (keeping the current one): ${errMessage(e)}`);
|
|
1268
|
+
}
|
|
1269
|
+
};
|
|
1249
1270
|
const wake = new AgentWakeChannel(ctx, undefined, {
|
|
1250
1271
|
onLive: (frame) => live.handle(frame),
|
|
1251
1272
|
onDisconnect: () => live.dispose(),
|
|
@@ -1254,6 +1275,7 @@ export async function main() {
|
|
|
1254
1275
|
wake.send(frame);
|
|
1255
1276
|
});
|
|
1256
1277
|
for (;;) {
|
|
1278
|
+
await renewIfDue();
|
|
1257
1279
|
try {
|
|
1258
1280
|
await pollOnce(ctx);
|
|
1259
1281
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The part of `GET /api/v1/backups` that `tug backup-list --check` judges. */
|
|
2
|
+
export interface BackupCheckInput {
|
|
3
|
+
destinationConfigured: boolean;
|
|
4
|
+
targets: {
|
|
5
|
+
target: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
problem?: string | null;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
/** What a monitor should be told: one line per problem, empty when every backup target is healthy. */
|
|
11
|
+
export declare function backupCheckProblems(listing: BackupCheckInput): string[];
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** What a monitor should be told: one line per problem, empty when every backup target is healthy. */
|
|
2
|
+
export function backupCheckProblems(listing) {
|
|
3
|
+
const active = listing.targets.filter((target) => target.enabled);
|
|
4
|
+
const problems = active
|
|
5
|
+
.filter((target) => target.problem)
|
|
6
|
+
.map((target) => `${target.target}: ${target.problem}`);
|
|
7
|
+
if (active.length && !listing.destinationConfigured)
|
|
8
|
+
problems.unshift('no backup destination is configured');
|
|
9
|
+
return problems;
|
|
10
|
+
}
|
package/dist/backup-config.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ResolvedBackup {
|
|
|
11
11
|
retention: BackupRetention;
|
|
12
12
|
jitterSeconds: number;
|
|
13
13
|
timeoutSec: number;
|
|
14
|
+
/** Memory limit of the restic helper container. */
|
|
15
|
+
memory: string;
|
|
14
16
|
/** Service targets: the command run in the service's container, and the snapshot file it is stored as. */
|
|
15
17
|
command?: string[];
|
|
16
18
|
/** Run in the service container to restore; the stored file arrives on its standard input. */
|
|
@@ -26,8 +28,11 @@ export interface ResolvedBackup {
|
|
|
26
28
|
}
|
|
27
29
|
export declare const DEFAULT_RETENTION: BackupRetention;
|
|
28
30
|
export declare const DEFAULT_BACKUP_TIMEOUT_SEC: number;
|
|
31
|
+
export declare const DEFAULT_BACKUP_MEMORY = "512m";
|
|
29
32
|
/** `30m`, `2h`, `90s` or a number of seconds. */
|
|
30
33
|
export declare function parseBackupTimeout(value: unknown): number | null;
|
|
34
|
+
/** Bytes in a Docker-style memory size (`256m`, `1g`, `1048576`), or null when it is not one. */
|
|
35
|
+
export declare function parseMemorySize(value: unknown): number | null;
|
|
31
36
|
/** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
|
|
32
37
|
export declare function defaultJitterSeconds(project: string, target: string): number;
|
|
33
38
|
/** Validates the project-level defaults in `x-tug.backup`. */
|
package/dist/backup-config.js
CHANGED
|
@@ -2,11 +2,15 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { parseCron } from "./cron.js";
|
|
3
3
|
export const DEFAULT_RETENTION = { daily: 7, weekly: 4, monthly: 6 };
|
|
4
4
|
export const DEFAULT_BACKUP_TIMEOUT_SEC = 2 * 3600;
|
|
5
|
+
export const DEFAULT_BACKUP_MEMORY = '512m';
|
|
6
|
+
/** Below this restic runs out of memory on any repository worth backing up. */
|
|
7
|
+
const MIN_MEMORY_BYTES = 128 * 1024 ** 2;
|
|
8
|
+
const MAX_MEMORY_BYTES = 16 * 1024 ** 3;
|
|
5
9
|
const MIN_TIMEOUT_SEC = 60;
|
|
6
10
|
const MAX_TIMEOUT_SEC = 24 * 3600;
|
|
7
11
|
const MAX_JITTER_SEC = 3600;
|
|
8
12
|
const RETENTION_KEYS = ['last', 'hourly', 'daily', 'weekly', 'monthly', 'yearly'];
|
|
9
|
-
const COMMON_KEYS = ['schedule', 'retention', 'jitter', 'timeout', 'writers'];
|
|
13
|
+
const COMMON_KEYS = ['schedule', 'retention', 'jitter', 'timeout', 'memory', 'writers'];
|
|
10
14
|
const SERVICE_KEYS = [...COMMON_KEYS, 'command', 'restore', 'filename', 'verify'];
|
|
11
15
|
const VOLUME_KEYS = [...COMMON_KEYS, 'exclude'];
|
|
12
16
|
const FILENAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
@@ -20,6 +24,14 @@ export function parseBackupTimeout(value) {
|
|
|
20
24
|
return null;
|
|
21
25
|
return Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2]];
|
|
22
26
|
}
|
|
27
|
+
/** Bytes in a Docker-style memory size (`256m`, `1g`, `1048576`), or null when it is not one. */
|
|
28
|
+
export function parseMemorySize(value) {
|
|
29
|
+
const match = typeof value === 'string' ? /^(\d+)([kmg]?)$/i.exec(value.trim()) : null;
|
|
30
|
+
if (!match)
|
|
31
|
+
return null;
|
|
32
|
+
return (Number(match[1]) *
|
|
33
|
+
{ '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[match[2].toLowerCase()]);
|
|
34
|
+
}
|
|
23
35
|
/** Deterministic delay in `[0, 900)` seconds so many sites on one node do not start together. */
|
|
24
36
|
export function defaultJitterSeconds(project, target) {
|
|
25
37
|
const digest = createHash('sha256').update(`${project}\0${target}`).digest();
|
|
@@ -98,7 +110,7 @@ function serviceNodes(svc) {
|
|
|
98
110
|
return {
|
|
99
111
|
nodes: placement.nodes || [],
|
|
100
112
|
dynamic: Array.isArray(placement.labels) && placement.labels.length > 0,
|
|
101
|
-
global: lp.global === true
|
|
113
|
+
global: lp.global === true,
|
|
102
114
|
};
|
|
103
115
|
}
|
|
104
116
|
function mountedVolumes(svc) {
|
|
@@ -152,6 +164,14 @@ export function resolveBackups(compose, project = '') {
|
|
|
152
164
|
else
|
|
153
165
|
timeoutSec = parsed;
|
|
154
166
|
}
|
|
167
|
+
let memory = DEFAULT_BACKUP_MEMORY;
|
|
168
|
+
if (spec.memory !== undefined) {
|
|
169
|
+
const bytes = parseMemorySize(spec.memory);
|
|
170
|
+
if (bytes === null || bytes < MIN_MEMORY_BYTES || bytes > MAX_MEMORY_BYTES)
|
|
171
|
+
errors.push(`${label}.memory must be a size from 128m to 16g such as 256m or 1g`);
|
|
172
|
+
else
|
|
173
|
+
memory = String(spec.memory).trim().toLowerCase();
|
|
174
|
+
}
|
|
155
175
|
if (spec.writers !== undefined) {
|
|
156
176
|
if (!Array.isArray(spec.writers) || spec.writers.some((w) => typeof w !== 'string'))
|
|
157
177
|
errors.push(`${label}.writers must be a list of service names`);
|
|
@@ -162,7 +182,7 @@ export function resolveBackups(compose, project = '') {
|
|
|
162
182
|
}
|
|
163
183
|
if (errors.length > before || !schedule || !retention)
|
|
164
184
|
return null;
|
|
165
|
-
return { schedule, retention, jitterSeconds, timeoutSec };
|
|
185
|
+
return { schedule, retention, jitterSeconds, timeoutSec, memory };
|
|
166
186
|
};
|
|
167
187
|
for (const [name, svc] of Object.entries(compose.services)) {
|
|
168
188
|
const config = svc['x-tug']?.backup;
|
|
@@ -15,7 +15,7 @@ export declare function projectRepository(destinationUrl: string, project: strin
|
|
|
15
15
|
/**
|
|
16
16
|
* The repository password of a project, derived from the control master key so nothing extra is stored and
|
|
17
17
|
* a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
|
|
18
|
-
* repository, which is why `tug backup-
|
|
18
|
+
* repository, which is why `tug backup-repository-password` exists for offline escrow.
|
|
19
19
|
*/
|
|
20
20
|
export declare function repositoryPassword(masterKey: string, project: string): string;
|
|
21
21
|
/** Removes every known secret value from text that is about to be stored, shown or put in an event. */
|
|
@@ -54,7 +54,7 @@ export function projectRepository(destinationUrl, project) {
|
|
|
54
54
|
/**
|
|
55
55
|
* The repository password of a project, derived from the control master key so nothing extra is stored and
|
|
56
56
|
* a node only ever holds the passwords of projects it runs backups for. Losing the master key loses every
|
|
57
|
-
* repository, which is why `tug backup-
|
|
57
|
+
* repository, which is why `tug backup-repository-password` exists for offline escrow.
|
|
58
58
|
*/
|
|
59
59
|
export function repositoryPassword(masterKey, project) {
|
|
60
60
|
return createHmac('sha256', masterKey).update(`tug-restic:${project}`).digest('hex');
|
package/dist/backup-restore.js
CHANGED
|
@@ -3,6 +3,10 @@ import { volumeName } from "./container-spec.js";
|
|
|
3
3
|
const MINUTE = 60_000;
|
|
4
4
|
const LOCK_TTL_MS = 15 * MINUTE;
|
|
5
5
|
const HOLD_TIMEOUT_MS = 5 * MINUTE;
|
|
6
|
+
/** How long a finished restore waits for its writers to serve again before it reports that they did not. */
|
|
7
|
+
const VERIFY_TIMEOUT_MS = 5 * MINUTE;
|
|
8
|
+
/** Restore states in which the project must stay locked. */
|
|
9
|
+
const LOCKED_STATES = ['locking', 'holding', 'safety_backup', 'restoring', 'interrupted'];
|
|
6
10
|
const TERMINAL = ['succeeded', 'failed', 'released'];
|
|
7
11
|
/**
|
|
8
12
|
* The services that write to a target. For a service they are the services that depend on it, transitively;
|
|
@@ -59,8 +63,9 @@ export function createRestoreService(deps) {
|
|
|
59
63
|
if (!latest && !from.startsWith('bkr_'))
|
|
60
64
|
return { snapshotId: from, time: null };
|
|
61
65
|
const run = latest
|
|
62
|
-
?
|
|
63
|
-
|
|
66
|
+
? // A safety snapshot records the state just before a restore, often a damaged one, so it is never "the latest backup".
|
|
67
|
+
db.get(`SELECT * FROM backup_runs WHERE project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL
|
|
68
|
+
AND trigger != 'pre-restore' ORDER BY finished_at DESC LIMIT 1`, project, target)
|
|
64
69
|
: db.get("SELECT * FROM backup_runs WHERE id=? AND project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL", from, project, target);
|
|
65
70
|
if (!run?.snapshot_id)
|
|
66
71
|
return {
|
|
@@ -170,7 +175,8 @@ export function createRestoreService(deps) {
|
|
|
170
175
|
...(restorePlan.scratchVolume ? { scratchVolume: restorePlan.scratchVolume } : {}),
|
|
171
176
|
};
|
|
172
177
|
}
|
|
173
|
-
|
|
178
|
+
/** True when every writer reports `rolloutState` on every node it is placed on. */
|
|
179
|
+
const writersReport = (restore, writers, rolloutState) => {
|
|
174
180
|
const desired = deployed(restore.project_id);
|
|
175
181
|
if (!desired)
|
|
176
182
|
return false;
|
|
@@ -179,7 +185,7 @@ export function createRestoreService(deps) {
|
|
|
179
185
|
return nodes.every((node) => {
|
|
180
186
|
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);
|
|
181
187
|
const status = row ? JSON.parse(row.status_json) : undefined;
|
|
182
|
-
return status?.serviceStates?.[service]?.rolloutState ===
|
|
188
|
+
return status?.serviceStates?.[service]?.rolloutState === rolloutState;
|
|
183
189
|
});
|
|
184
190
|
});
|
|
185
191
|
};
|
|
@@ -210,12 +216,12 @@ export function createRestoreService(deps) {
|
|
|
210
216
|
const age = clock().getTime() -
|
|
211
217
|
new Date(restore.state_changed_at ?? restore.started_at ?? stamp()).getTime();
|
|
212
218
|
// Keep the project locked while a restore is unfinished, across a control restart.
|
|
213
|
-
if (restore.mode === 'in-place')
|
|
219
|
+
if (restore.mode === 'in-place' && LOCKED_STATES.includes(restore.state))
|
|
214
220
|
lock(restore);
|
|
215
221
|
const writers = JSON.parse(restore.writers_json);
|
|
216
222
|
switch (restore.state) {
|
|
217
223
|
case 'holding': {
|
|
218
|
-
if (!
|
|
224
|
+
if (!writersReport(restore, writers, 'held')) {
|
|
219
225
|
if (age > HOLD_TIMEOUT_MS)
|
|
220
226
|
fail(restore, 'the writers did not stop in time', false);
|
|
221
227
|
break;
|
|
@@ -247,8 +253,11 @@ export function createRestoreService(deps) {
|
|
|
247
253
|
if (command.state === 'succeeded') {
|
|
248
254
|
if (restore.mode === 'in-place')
|
|
249
255
|
release(restore);
|
|
250
|
-
|
|
251
|
-
|
|
256
|
+
// The data is back; the restore is done when the services that were stopped are serving again.
|
|
257
|
+
if (restore.mode === 'in-place' && writers.length)
|
|
258
|
+
move(restore, 'verifying');
|
|
259
|
+
else
|
|
260
|
+
complete(restore);
|
|
252
261
|
}
|
|
253
262
|
else {
|
|
254
263
|
const error = JSON.parse(command.result_json ?? '{}').error ??
|
|
@@ -257,10 +266,21 @@ export function createRestoreService(deps) {
|
|
|
257
266
|
}
|
|
258
267
|
break;
|
|
259
268
|
}
|
|
269
|
+
case 'verifying': {
|
|
270
|
+
if (writersReport(restore, writers, 'serving'))
|
|
271
|
+
complete(restore);
|
|
272
|
+
else if (age > VERIFY_TIMEOUT_MS)
|
|
273
|
+
complete(restore, `the data was restored, but ${writers.join(', ')} did not report serving within ${VERIFY_TIMEOUT_MS / MINUTE} minutes; check tug status`);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
260
276
|
default:
|
|
261
277
|
}
|
|
262
278
|
}
|
|
263
279
|
}
|
|
280
|
+
function complete(restore, warning) {
|
|
281
|
+
move(restore, 'succeeded', warning ? { error: warning } : {});
|
|
282
|
+
event(warning ? 'warn' : 'info', 'restore.completed', restore, `restore of ${restore.target} completed${restore.scratch_volume ? ` into volume ${restore.scratch_volume}` : ''}${warning ? `, but ${warning}` : ''}`, { scratchVolume: restore.scratch_volume, ...(warning ? { warning } : {}) });
|
|
283
|
+
}
|
|
264
284
|
/** Drops the holds and the lock of an interrupted restore; the data may be partly restored. */
|
|
265
285
|
function releaseInterrupted(restoreId) {
|
|
266
286
|
const restore = db.get('SELECT * FROM restore_runs WHERE id=?', restoreId);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedBackup } from './backup-config.ts';
|
|
2
2
|
import type { Db } from './db.ts';
|
|
3
|
-
import type { BackupRunRow } from './db.ts';
|
|
3
|
+
import type { BackupRunRow, BackupScheduleRow } from './db.ts';
|
|
4
4
|
import type { BackupJobPayload, CommandResultPayload, CommandType, DesiredState, JobStatus } from './protocol.ts';
|
|
5
5
|
/** Pinned by tag; override on control with `TUG_RESTIC_IMAGE` (a digest reference is recommended for production). */
|
|
6
6
|
export declare const DEFAULT_RESTIC_IMAGE = "restic/restic:0.17.3";
|
|
@@ -25,7 +25,13 @@ export interface TargetStatus {
|
|
|
25
25
|
lastRunAt: string | null;
|
|
26
26
|
lastSuccessAt: string | null;
|
|
27
27
|
lastStatus: string | null;
|
|
28
|
+
/** Why this target needs attention (stale, last run failed or missed), or null when it is healthy. */
|
|
29
|
+
problem: string | null;
|
|
28
30
|
}
|
|
31
|
+
/** A target that has not succeeded for two periods is stale, even when every run failed alike. */
|
|
32
|
+
export declare function isStale(schedule: Pick<BackupScheduleRow, 'schedule' | 'last_success_at' | 'created_at'>, at: Date): boolean;
|
|
33
|
+
/** What a monitor should know about a target: null when it is healthy or switched off. */
|
|
34
|
+
export declare function backupProblem(row: BackupScheduleRow, at: Date): string | null;
|
|
29
35
|
/** Where a backup target runs, from the resolved desired state (labels are already resolved there). */
|
|
30
36
|
export declare function targetNode(target: ResolvedBackup, desired: DesiredState): string | undefined;
|
|
31
37
|
export declare function createBackupService(deps: BackupServiceDeps): {
|
package/dist/backup-scheduler.js
CHANGED
|
@@ -14,6 +14,24 @@ const MAX_DEADLINE_MS = 6 * 3600_000;
|
|
|
14
14
|
const MANUAL_DEADLINE_MS = 30 * MINUTE;
|
|
15
15
|
const STALE_REPEAT_MS = 24 * 3600_000;
|
|
16
16
|
const MAX_CATCH_UP_SLOTS = 20_000;
|
|
17
|
+
/** A target that has not succeeded for two periods is stale, even when every run failed alike. */
|
|
18
|
+
export function isStale(schedule, at) {
|
|
19
|
+
const period = cronPeriodMs(schedule.schedule, at);
|
|
20
|
+
if (!period)
|
|
21
|
+
return false;
|
|
22
|
+
const since = new Date(schedule.last_success_at ?? schedule.created_at ?? at.toISOString()).getTime();
|
|
23
|
+
return at.getTime() - since > 2 * period + 10 * MINUTE;
|
|
24
|
+
}
|
|
25
|
+
/** What a monitor should know about a target: null when it is healthy or switched off. */
|
|
26
|
+
export function backupProblem(row, at) {
|
|
27
|
+
if (!row.enabled)
|
|
28
|
+
return null;
|
|
29
|
+
if (isStale(row, at))
|
|
30
|
+
return `no successful backup since ${row.last_success_at ?? 'it was configured'}`;
|
|
31
|
+
if (row.last_status === 'failed' || row.last_status === 'missed')
|
|
32
|
+
return `the last run ${row.last_status}`;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
17
35
|
function parse(json, fallback) {
|
|
18
36
|
try {
|
|
19
37
|
return json ? JSON.parse(json) : fallback;
|
|
@@ -113,7 +131,7 @@ export function createBackupService(deps) {
|
|
|
113
131
|
`trigger:${trigger}`,
|
|
114
132
|
],
|
|
115
133
|
retention: config.retention,
|
|
116
|
-
limits: { memory: DEFAULT_MEMORY, timeoutSec: config.timeoutSec },
|
|
134
|
+
limits: { memory: config.memory ?? DEFAULT_MEMORY, timeoutSec: config.timeoutSec },
|
|
117
135
|
};
|
|
118
136
|
if (config.kind === 'service') {
|
|
119
137
|
payload.command = {
|
|
@@ -283,11 +301,7 @@ export function createBackupService(deps) {
|
|
|
283
301
|
}
|
|
284
302
|
// Independent of single runs: a target that has not succeeded for two periods is stale, even when each run failed alike.
|
|
285
303
|
for (const schedule of db.all('SELECT * FROM backup_schedules WHERE enabled=1')) {
|
|
286
|
-
|
|
287
|
-
if (!period)
|
|
288
|
-
continue;
|
|
289
|
-
const since = new Date(schedule.last_success_at ?? schedule.created_at ?? iso).getTime();
|
|
290
|
-
if (at.getTime() - since <= 2 * period + 10 * MINUTE)
|
|
304
|
+
if (!isStale(schedule, at))
|
|
291
305
|
continue;
|
|
292
306
|
if (schedule.stale_notified_at &&
|
|
293
307
|
at.getTime() - new Date(schedule.stale_notified_at).getTime() < STALE_REPEAT_MS)
|
|
@@ -376,6 +390,7 @@ export function createBackupService(deps) {
|
|
|
376
390
|
lastRunAt: row.last_run_at,
|
|
377
391
|
lastSuccessAt: row.last_success_at,
|
|
378
392
|
lastStatus: row.last_status,
|
|
393
|
+
problem: backupProblem(row, clock()),
|
|
379
394
|
}));
|
|
380
395
|
const runs = db.all('SELECT * FROM backup_runs WHERE project_id=? ORDER BY COALESCE(finished_at,queued_at) DESC LIMIT ?', project, limit);
|
|
381
396
|
return { targets, runs };
|