@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.
Files changed (49) hide show
  1. package/README.md +24 -22
  2. package/dist/agent-credential.d.ts +39 -0
  3. package/dist/agent-credential.js +135 -0
  4. package/dist/agent.d.ts +15 -9
  5. package/dist/agent.js +126 -104
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +5 -0
  9. package/dist/backup-config.js +23 -3
  10. package/dist/backup-destination.d.ts +1 -1
  11. package/dist/backup-destination.js +1 -1
  12. package/dist/backup-restore.js +28 -8
  13. package/dist/backup-scheduler.d.ts +7 -1
  14. package/dist/backup-scheduler.js +21 -6
  15. package/dist/cli-build.js +70 -4
  16. package/dist/cli-config.d.ts +9 -5
  17. package/dist/cli-config.js +89 -34
  18. package/dist/cli-install.js +1 -1
  19. package/dist/cli-live-transport.d.ts +3 -2
  20. package/dist/cli-live-transport.js +9 -5
  21. package/dist/cli.js +276 -142
  22. package/dist/compose.d.ts +3 -1
  23. package/dist/compose.js +14 -2
  24. package/dist/control-credential.d.ts +12 -0
  25. package/dist/control-credential.js +46 -0
  26. package/dist/control.d.ts +18 -1
  27. package/dist/control.js +586 -202
  28. package/dist/credentials.d.ts +111 -0
  29. package/dist/{tokens.js → credentials.js} +70 -54
  30. package/dist/db.d.ts +20 -5
  31. package/dist/db.js +3 -3
  32. package/dist/diagnostics.d.ts +44 -0
  33. package/dist/diagnostics.js +65 -0
  34. package/dist/ingress.js +4 -3
  35. package/dist/main.js +35 -0
  36. package/dist/normalize.d.ts +6 -0
  37. package/dist/normalize.js +16 -2
  38. package/dist/pki.d.ts +119 -0
  39. package/dist/pki.js +339 -0
  40. package/dist/protocol.d.ts +53 -22
  41. package/dist/status-report.d.ts +11 -0
  42. package/dist/status-report.js +147 -0
  43. package/dist/templates/agent.env +1 -3
  44. package/dist/templates/control-compose.yml +4 -3
  45. package/dist/templates/control-install.sh +8 -2
  46. package/dist/tls-server.d.ts +11 -0
  47. package/dist/tls-server.js +23 -0
  48. package/package.json +10 -8
  49. 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
- const CONTROL_URL_FROM_ENV = Boolean(process.env.TUG_CONTROL_URL);
27
- let CONTROL_URL = process.env.TUG_CONTROL_URL || 'http://localhost:8080';
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
- export function defaultContext() {
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: fetch,
39
- nodeName: NODE_NAME,
42
+ http: transport.http,
43
+ nodeName: doc.nodeName,
40
44
  stateDir: STATE_DIR,
41
45
  apply: APPLY,
42
- controlUrl: CONTROL_URL,
43
- token: TOKEN,
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, token) => new WebSocket(url, {
104
- headers: { Authorization: `Bearer ${token}` },
105
- maxPayload: LIVE_MAX_FRAME_BYTES,
106
- }), handlers = {}) {
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.token);
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)}`, { headers: authHeaders(ctx), redirect: 'error', signal: AbortSignal.timeout(10 * 60 * 1000) });
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 = { node: ctx.nodeName, projects, commandResults };
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 TUG_JOIN_KEY (expected base64url-encoded JSON)', { cause: e });
1196
+ throw new Error('invalid join key (expected a base64url-encoded v2 join key)', { cause: e });
1178
1197
  }
1179
- if (!decoded.enrollToken)
1180
- throw new Error('join key is missing enrollToken');
1181
- const controlUrl = !CONTROL_URL_FROM_ENV && decoded.controlUrl ? decoded.controlUrl : CONTROL_URL;
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 res = await fetch(`${controlUrl}/agent/v1/enroll`, {
1197
- method: 'POST',
1198
- headers: { 'content-type': 'application/json' },
1199
- body: JSON.stringify({
1200
- enrollToken: decoded.enrollToken,
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
- await saveCreds(creds);
1222
- if (wgPublicKey)
1223
- await writeReportedKey(STATE_DIR, wgPublicKey);
1224
- console.log(`enrolled as node ${creds.nodeName}`);
1225
- return creds;
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
- // Establish durable credentials before polling: explicit env token (SSH/push install) wins,
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 creds = TOKEN
1231
- ? { agentToken: TOKEN, nodeName: NODE_NAME, controlUrl: CONTROL_URL }
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('no TUG_AGENT_TOKEN, no persisted credentials, and no TUG_JOIN_KEY to enroll with');
1237
- creds = await enroll(joinKey);
1232
+ throw new Error(describeMissingIdentity());
1233
+ doc = await enroll(joinKey);
1238
1234
  }
1239
- TOKEN = creds.agentToken;
1240
- NODE_NAME = creds.nodeName || NODE_NAME;
1241
- if (!CONTROL_URL_FROM_ENV && creds.controlUrl)
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
- console.log(`tug-agent node=${NODE_NAME} control=${CONTROL_URL} apply=${APPLY}`);
1248
- const ctx = defaultContext();
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
+ }
@@ -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`. */
@@ -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 || placement.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-key` exists for offline escrow.
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-key` exists for offline escrow.
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');
@@ -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
- ? db.get(`SELECT * FROM backup_runs WHERE project_id=? AND target=? AND state='succeeded' AND snapshot_id IS NOT NULL
63
- ORDER BY finished_at DESC LIMIT 1`, project, target)
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
- const heldOnNodes = (restore, writers) => {
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 === 'held';
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 (!heldOnNodes(restore, writers)) {
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
- move(restore, 'succeeded');
251
- event('info', 'restore.completed', restore, `restore of ${restore.target} completed${restore.scratch_volume ? ` into volume ${restore.scratch_volume}` : ''}`, { scratchVolume: restore.scratch_volume });
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): {
@@ -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
- const period = cronPeriodMs(schedule.schedule, at);
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 };