@wpelevator/tug 0.5.0 → 0.7.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 (43) hide show
  1. package/README.md +3 -3
  2. package/dist/agent-jobs.d.ts +41 -0
  3. package/dist/agent-jobs.js +161 -0
  4. package/dist/agent.d.ts +8 -1
  5. package/dist/agent.js +76 -7
  6. package/dist/backup-check.d.ts +11 -0
  7. package/dist/backup-check.js +10 -0
  8. package/dist/backup-config.d.ts +52 -0
  9. package/dist/backup-config.js +276 -0
  10. package/dist/backup-destination.d.ts +22 -0
  11. package/dist/backup-destination.js +72 -0
  12. package/dist/backup-job.d.ts +32 -0
  13. package/dist/backup-job.js +352 -0
  14. package/dist/backup-restore.d.ts +67 -0
  15. package/dist/backup-restore.js +317 -0
  16. package/dist/backup-scheduler.d.ts +60 -0
  17. package/dist/backup-scheduler.js +418 -0
  18. package/dist/cli-build.d.ts +1 -1
  19. package/dist/cli-build.js +15 -2
  20. package/dist/cli-config.d.ts +45 -5
  21. package/dist/cli-config.js +269 -19
  22. package/dist/cli-live-transport.d.ts +1 -1
  23. package/dist/cli-live-transport.js +2 -2
  24. package/dist/cli-live.d.ts +2 -0
  25. package/dist/cli-live.js +1 -0
  26. package/dist/cli.js +226 -16
  27. package/dist/compose.js +5 -2
  28. package/dist/container-spec.d.ts +3 -2
  29. package/dist/container-spec.js +9 -34
  30. package/dist/control.js +253 -19
  31. package/dist/cron.d.ts +17 -0
  32. package/dist/cron.js +124 -0
  33. package/dist/db.d.ts +70 -0
  34. package/dist/db.js +7 -0
  35. package/dist/docker-api.d.ts +7 -0
  36. package/dist/live.d.ts +8 -0
  37. package/dist/live.js +84 -0
  38. package/dist/normalize.d.ts +6 -0
  39. package/dist/normalize.js +15 -1
  40. package/dist/protocol.d.ts +118 -7
  41. package/dist/protocol.js +7 -0
  42. package/dist/tokens.js +10 -0
  43. package/package.json +2 -1
package/dist/live.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { LIVE_MAX_FRAME_BYTES } from "./protocol.js";
3
+ import { LABEL_JOB } from "./container-spec.js";
3
4
  const identifier = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,255}$/;
4
5
  function object(value) {
5
6
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -22,6 +23,8 @@ function validate(value) {
22
23
  !value.command.every((arg) => typeof arg === 'string' && !arg.includes('\0') && Buffer.byteLength(arg) <= 32768) ||
23
24
  value.command[0].length === 0)
24
25
  throw new Error('Invalid exec command');
26
+ if (value.oneOff !== undefined && typeof value.oneOff !== 'boolean')
27
+ throw new Error('Invalid one-off flag');
25
28
  if (value.interactive !== undefined && typeof value.interactive !== 'boolean')
26
29
  throw new Error('Invalid interactive flag');
27
30
  if (value.tty !== undefined && typeof value.tty !== 'boolean')
@@ -187,6 +190,8 @@ export class LiveOperationEngine {
187
190
  tail: request.tail ?? 100,
188
191
  timestamps: request.timestamps,
189
192
  });
193
+ if (request.operation === 'exec' && request.oneOff)
194
+ return startOneOff(docker, request.container, request.command);
190
195
  const proc = await docker.execStart(request.container, {
191
196
  cmd: request.command,
192
197
  stdin: request.interactive !== false,
@@ -274,3 +279,82 @@ export class LiveOperationEngine {
274
279
  });
275
280
  }
276
281
  }
282
+ /**
283
+ * Runs `command` in a new container cloned from `template`: the same image, environment (secrets included),
284
+ * user, working directory, network and mounts, with no published ports and none of the service's labels, so
285
+ * reconciliation never mistakes it for the service. The container is removed when the command ends.
286
+ */
287
+ export async function startOneOff(docker, template, command) {
288
+ const source = await docker.inspectContainer(template);
289
+ const image = source.Config?.Image;
290
+ if (!image)
291
+ throw new Error('the service container has no image to run from');
292
+ const host = source.HostConfig ?? {};
293
+ const name = `tug-run-${Math.random().toString(36).slice(2, 10)}`;
294
+ const id = await docker.createContainer(name, {
295
+ Image: image,
296
+ Env: source.Config?.Env,
297
+ User: source.Config?.User || undefined,
298
+ WorkingDir: source.Config?.WorkingDir || undefined,
299
+ // Bypass the image's entrypoint script, like `docker exec`, so the command is exactly what was typed.
300
+ Entrypoint: command,
301
+ Cmd: [],
302
+ Labels: { [LABEL_JOB]: 'run' },
303
+ HostConfig: {
304
+ NetworkMode: host.NetworkMode,
305
+ ...(host.Binds?.length ? { Binds: host.Binds } : {}),
306
+ ...(host.Mounts?.length ? { Mounts: host.Mounts } : {}),
307
+ },
308
+ });
309
+ const remove = () => docker.removeContainer(id).catch(() => { });
310
+ try {
311
+ await docker.startContainer(id);
312
+ const logs = await docker.logs(id, { follow: true });
313
+ let killed = false;
314
+ const proc = Object.assign(new EventEmitter(), {
315
+ stdin: null,
316
+ stdout: logs.stdout,
317
+ stderr: logs.stderr,
318
+ kill: () => {
319
+ killed = true;
320
+ logs.kill();
321
+ void remove();
322
+ return true;
323
+ },
324
+ });
325
+ const drained = (stream) => new Promise((resolve) => {
326
+ if (stream.readableEnded || stream.destroyed)
327
+ return resolve();
328
+ stream.once('end', resolve);
329
+ stream.once('close', resolve);
330
+ });
331
+ // The exit code comes from the container, not from the log stream, which only ends once it is consumed.
332
+ void (async () => {
333
+ let code = -1;
334
+ while (!killed) {
335
+ const state = (await docker.inspectContainer(id).catch(() => undefined))?.State;
336
+ if (!state)
337
+ break;
338
+ if (!state.Running && state.Status !== 'created') {
339
+ code = state.ExitCode ?? -1;
340
+ break;
341
+ }
342
+ await new Promise((resolve) => setTimeout(resolve, 100));
343
+ }
344
+ if (killed)
345
+ return;
346
+ // Give the consumer a moment to read the last output before the container goes away.
347
+ await Promise.race([
348
+ Promise.all([drained(logs.stdout), drained(logs.stderr)]),
349
+ new Promise((resolve) => setTimeout(resolve, 1500)),
350
+ ]);
351
+ await remove();
352
+ proc.emit('close', code, null);
353
+ })();
354
+ return proc;
355
+ }
356
+ catch (error) {
357
+ await remove();
358
+ throw error;
359
+ }
360
+ }
@@ -10,6 +10,12 @@ export declare function sentinelNames(value: unknown): string[];
10
10
  export declare function substituteSentinels(value: string, secrets: Record<string, string>): string;
11
11
  /** Arguments of the `docker compose` call that renders `file` canonically (as YAML, which keeps `x-tug`). */
12
12
  export declare function composeConfigArgs(file: string, project?: string): string[];
13
+ /**
14
+ * `docker compose config` writes every literal `$` as `$$` so its output can be fed back to Compose. Tug does not
15
+ * interpolate again, so a command like `sh -c 'echo $$HOME'` would reach the container with the doubled dollar,
16
+ * where a shell reads `$$` as its own process ID. Undoing the escaping is exact: output never contains a bare `$`.
17
+ */
18
+ export declare function unescapeDollars<T>(value: T): T;
13
19
  /**
14
20
  * Renders `file` with Compose and returns the canonical definition. Every name in `secretNames` is set to
15
21
  * its sentinel in the environment, so it wins over a same-named local variable.
package/dist/normalize.js CHANGED
@@ -41,6 +41,20 @@ export function composeConfigArgs(file, project) {
41
41
  '--no-path-resolution',
42
42
  ];
43
43
  }
44
+ /**
45
+ * `docker compose config` writes every literal `$` as `$$` so its output can be fed back to Compose. Tug does not
46
+ * interpolate again, so a command like `sh -c 'echo $$HOME'` would reach the container with the doubled dollar,
47
+ * where a shell reads `$$` as its own process ID. Undoing the escaping is exact: output never contains a bare `$`.
48
+ */
49
+ export function unescapeDollars(value) {
50
+ if (typeof value === 'string')
51
+ return value.replaceAll('$$', '$');
52
+ if (Array.isArray(value))
53
+ return value.map(unescapeDollars);
54
+ if (value && typeof value === 'object')
55
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, unescapeDollars(item)]));
56
+ return value;
57
+ }
44
58
  /**
45
59
  * Renders `file` with Compose and returns the canonical definition. Every name in `secretNames` is set to
46
60
  * its sentinel in the environment, so it wins over a same-named local variable.
@@ -71,5 +85,5 @@ export async function normalizeCompose(file, secretNames, options = {}) {
71
85
  }
72
86
  if (!parsed || typeof parsed !== 'object' || !parsed.services)
73
87
  throw new Error(`${file} defines no services`);
74
- return parsed;
88
+ return unescapeDollars(parsed);
75
89
  }
@@ -24,6 +24,42 @@ export interface IngressSpec {
24
24
  cert?: string;
25
25
  key?: string;
26
26
  }
27
+ /** What a backup captures: the output of a command run in a service, or the files of a volume. */
28
+ export type BackupType = 'command' | 'volume';
29
+ /** A restic keep policy; at least one key is required. */
30
+ export interface BackupRetention {
31
+ last?: number;
32
+ hourly?: number;
33
+ daily?: number;
34
+ weekly?: number;
35
+ monthly?: number;
36
+ yearly?: number;
37
+ }
38
+ /** `x-tug.backup` on a service or top-level volume, as an object. */
39
+ export interface BackupSpec {
40
+ /** Five-field cron in UTC, or `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly`. */
41
+ schedule?: string;
42
+ retention?: BackupRetention;
43
+ /** Seconds of deterministic per-target delay after the scheduled minute. */
44
+ jitter?: number;
45
+ /** Hard limit for one run, such as `30m` or `2h`. */
46
+ timeout?: string | number;
47
+ /** Memory limit of the restic helper container, such as `256m` (default `512m`). */
48
+ memory?: string;
49
+ /** Services only: run in the service's container; its standard output is the backup. A string runs through `sh -c`. */
50
+ command?: string | string[];
51
+ /** Services only: run in the service container to restore; the stored file arrives on its standard input. */
52
+ restore?: string | string[];
53
+ /** Services only: name of the file inside the snapshot (default `<service>.out`). */
54
+ filename?: string;
55
+ /** Services only: text that must appear in the last 4 KiB of output, so a command cut short is not kept as a good backup. */
56
+ verify?: string;
57
+ /** Volumes only: restic exclude patterns. */
58
+ exclude?: string[];
59
+ /** Services to hold during a restore. */
60
+ writers?: string[];
61
+ }
62
+ export type BackupConfig = boolean | string | BackupSpec;
27
63
  export type RolloutStrategy = 'recreate' | 'rolling';
28
64
  /** `x-tug.rollout`: how a service is replaced on a new deployment. `recreate` (default) is `docker compose up -d`. */
29
65
  export interface RolloutSpec {
@@ -39,11 +75,14 @@ export interface ServiceExtension {
39
75
  health?: HealthCheck;
40
76
  ingress?: IngressSpec;
41
77
  rollout?: RolloutSpec;
78
+ backup?: BackupConfig;
42
79
  }
43
80
  /** The top-level `x-tug` extension of a Compose file. */
44
81
  export interface ProjectExtension {
45
82
  /** `auto` creates a rollback deployment when a deployment fails on health; `manual` (default) only reports it. */
46
83
  rollback?: 'auto' | 'manual';
84
+ /** Defaults for every `x-tug.backup` target; they enable nothing by themselves. */
85
+ backup?: Pick<BackupSpec, 'schedule' | 'retention'>;
47
86
  }
48
87
  /** The `x-tug` extension on a top-level Compose volume. */
49
88
  export interface VolumeExtension {
@@ -51,10 +90,7 @@ export interface VolumeExtension {
51
90
  driver?: string;
52
91
  persistent?: boolean;
53
92
  placement?: Placement;
54
- /** NFS exporter address; control fills in the exporter's overlay IP (or node address) at deploy time. */
55
- address?: string;
56
- /** Directory exported by the NFS server on the exporter node (default `/srv/tug/<volume>`). */
57
- export?: string;
93
+ backup?: BackupConfig;
58
94
  }
59
95
  export type BuildConfig = string | {
60
96
  context?: string;
@@ -130,7 +166,74 @@ export declare const ACTIVE_DEPLOYMENT_STATES: readonly ["uploading", "building"
130
166
  /** Deployment states a CLI wait loop stops on. */
131
167
  export declare const TERMINAL_DEPLOYMENT_STATES: readonly ["succeeded", "failed", "build_failed", "cancelled"];
132
168
  export type DeploymentState = (typeof ACTIVE_DEPLOYMENT_STATES)[number] | (typeof TERMINAL_DEPLOYMENT_STATES)[number];
133
- export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key';
169
+ export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
170
+ /** Commands whose helper containers need the backup destination's credentials. */
171
+ export declare const BACKUP_COMMAND_TYPES: readonly CommandType[];
172
+ /** `GET /agent/v1/backup/credentials`: environment for the restic helper of one running backup command. */
173
+ export interface BackupCredentialsResponse {
174
+ env: Record<string, string>;
175
+ }
176
+ /** Why a command may be refused or a run marked missed; recorded on the run. */
177
+ export type BackupMissReason = 'node_offline' | 'control_down' | 'deadline';
178
+ export type JobPhase = 'queued' | 'pulling' | 'checking' | 'dumping' | 'uploading' | 'forgetting' | 'restoring';
179
+ /** Payload of `backup.check`, `backup.run` and `backup.restore`. Restic credentials are never in a payload. */
180
+ export interface BackupJobPayload {
181
+ runId: string;
182
+ project: string;
183
+ /** `service:db` or `volume:uploads`; empty for `backup.check`. */
184
+ target: string;
185
+ type: BackupType;
186
+ /** The project's restic repository, `<destination>/<project>`. */
187
+ repository: string;
188
+ resticImage: string;
189
+ /** Used as restic's `--host`, so each node keeps its own snapshot chain. */
190
+ node: string;
191
+ tags: string[];
192
+ retention?: BackupRetention;
193
+ /** Command targets: what to run in the service's container, and how to name and check its output. */
194
+ command?: {
195
+ service: string;
196
+ argv: string[];
197
+ filename: string;
198
+ verify?: string;
199
+ /** Run in the service container with the snapshot's file on stdin when restoring. */
200
+ restoreArgv?: string[];
201
+ };
202
+ /** Volume targets: the Docker volume to mount read-only; it must already exist on the node. */
203
+ volume?: {
204
+ name: string;
205
+ dockerVolume: string;
206
+ exclude?: string[];
207
+ };
208
+ limits: {
209
+ memory: string;
210
+ timeoutSec: number;
211
+ };
212
+ restore?: {
213
+ snapshotId: string;
214
+ mode: 'in-place' | 'scratch';
215
+ scratchVolume?: string;
216
+ };
217
+ }
218
+ export interface BackupJobResult {
219
+ runId: string;
220
+ snapshotId?: string;
221
+ addedBytes?: number;
222
+ totalBytes?: number;
223
+ durationMs: number;
224
+ resticVersion?: string;
225
+ /** Repository state for `backup.check`. */
226
+ repository?: 'ok' | 'initialized';
227
+ warnings?: string[];
228
+ }
229
+ /** Live jobs in `StatusReport.jobs`, so control can tell "running" from "lost". */
230
+ export interface JobStatus {
231
+ commandId: string;
232
+ runId: string;
233
+ phase: JobPhase;
234
+ startedAt: string;
235
+ bytes?: number;
236
+ }
134
237
  /** A `commands` table row as delivered in `GET /agent/v1/work`. */
135
238
  export interface Command {
136
239
  id: string;
@@ -173,7 +276,7 @@ export type CommandResultPayload = {
173
276
  deleted: true;
174
277
  } | {
175
278
  publicKey: string;
176
- } | {
279
+ } | BackupJobResult | {
177
280
  error: string;
178
281
  };
179
282
  export interface CommandResult {
@@ -206,6 +309,8 @@ export type LiveRequest = {
206
309
  tty: boolean;
207
310
  columns?: number;
208
311
  rows?: number;
312
+ /** Run the command in a new container cloned from the service's, instead of in the running one. */
313
+ oneOff?: boolean;
209
314
  } | {
210
315
  operation: 'logs';
211
316
  project: string;
@@ -327,6 +432,8 @@ export interface WorkResponse {
327
432
  ingress?: IngressWork;
328
433
  /** Host addresses for published ports that name no host IP. */
329
434
  ports?: PortBinding;
435
+ /** Per project, services that must stay stopped because a restore is rewriting their data. */
436
+ holds?: Record<string, string[]>;
330
437
  }
331
438
  /**
332
439
  * Where a node's published ports listen when a `ports` entry names no host IP. Entries with an
@@ -393,7 +500,9 @@ export interface IngressStatus {
393
500
  error?: string;
394
501
  }
395
502
  export type ProjectHealth = 'serving' | 'skipped' | 'failed';
396
- export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving';
503
+ export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving'
504
+ /** Stopped on purpose while a restore rewrites its data. */
505
+ | 'held';
397
506
  export interface ServiceState {
398
507
  rolloutState: RolloutState;
399
508
  image?: string;
@@ -419,6 +528,8 @@ export interface StatusReport {
419
528
  projects: ProjectStatus[];
420
529
  commandResults: CommandResult[];
421
530
  ingress?: IngressStatus;
531
+ /** Backup jobs still running on this node; absent when none. */
532
+ jobs?: JobStatus[];
422
533
  }
423
534
  export interface SecretsResponse {
424
535
  registryAuth?: Record<string, {
package/dist/protocol.js CHANGED
@@ -16,6 +16,13 @@ export const TERMINAL_DEPLOYMENT_STATES = [
16
16
  'build_failed',
17
17
  'cancelled',
18
18
  ];
19
+ /** Commands whose helper containers need the backup destination's credentials. */
20
+ export const BACKUP_COMMAND_TYPES = [
21
+ 'backup.check',
22
+ 'backup.run',
23
+ 'backup.restore',
24
+ 'backup.list',
25
+ ];
19
26
  export const LIVE_MAX_FRAME_BYTES = 256 * 1024;
20
27
  export const LIVE_MAX_BUFFERED_BYTES = 1024 * 1024;
21
28
  export const LIVE_MAX_SESSIONS = 64;
package/dist/tokens.js CHANGED
@@ -42,6 +42,16 @@ export const ROUTE_ROLES = {
42
42
  'POST /api/v1/nodes/:name/rotate-wg-key': 'admin',
43
43
  'POST /api/v1/nodes/:name/ingress': 'admin',
44
44
  'DELETE /api/v1/nodes/:name': 'admin',
45
+ 'POST /api/v1/backups/run': 'deploy',
46
+ 'GET /api/v1/backups': 'read',
47
+ 'POST /api/v1/restores': 'admin',
48
+ 'GET /api/v1/restores': 'read',
49
+ 'POST /api/v1/restores/:id/release': 'admin',
50
+ 'POST /api/v1/restores/:id/resume': 'admin',
51
+ 'GET /api/v1/backup/destination': 'admin',
52
+ 'POST /api/v1/backup/destination': 'admin',
53
+ 'DELETE /api/v1/backup/destination': 'admin',
54
+ 'GET /api/v1/backup/key': 'admin',
45
55
  'POST /api/v1/control/backups/run': 'admin',
46
56
  'POST /api/v1/control/restore': 'admin',
47
57
  'GET /api/v1/diagnostics': 'admin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,6 +29,7 @@
29
29
  "test:integration": "bash tools/scripts/integration-test.sh",
30
30
  "test:integration:network": "bash tools/scripts/integration-network-test.sh",
31
31
  "test:integration:agent": "node --test 'tests/integration/**/*.test.ts'",
32
+ "test:integration:backups": "node --test tests/integration/backup-job.test.ts tests/integration/run-job.test.ts",
32
33
  "test:smoke:dist": "npm run build && CLI_ENTRY=dist/cli.js node --test src/cli.templates.test.ts",
33
34
  "changeset": "changeset",
34
35
  "version-packages": "changeset version && npm install --package-lock-only --ignore-scripts",