@wpelevator/tug 0.5.0 → 0.6.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/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
+ }
@@ -24,6 +24,40 @@ 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
+ /** Services only: run in the service's container; its standard output is the backup. A string runs through `sh -c`. */
48
+ command?: string | string[];
49
+ /** Services only: run in the service container to restore; the stored file arrives on its standard input. */
50
+ restore?: string | string[];
51
+ /** Services only: name of the file inside the snapshot (default `<service>.out`). */
52
+ filename?: string;
53
+ /** 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. */
54
+ verify?: string;
55
+ /** Volumes only: restic exclude patterns. */
56
+ exclude?: string[];
57
+ /** Services to hold during a restore. */
58
+ writers?: string[];
59
+ }
60
+ export type BackupConfig = boolean | string | BackupSpec;
27
61
  export type RolloutStrategy = 'recreate' | 'rolling';
28
62
  /** `x-tug.rollout`: how a service is replaced on a new deployment. `recreate` (default) is `docker compose up -d`. */
29
63
  export interface RolloutSpec {
@@ -39,11 +73,14 @@ export interface ServiceExtension {
39
73
  health?: HealthCheck;
40
74
  ingress?: IngressSpec;
41
75
  rollout?: RolloutSpec;
76
+ backup?: BackupConfig;
42
77
  }
43
78
  /** The top-level `x-tug` extension of a Compose file. */
44
79
  export interface ProjectExtension {
45
80
  /** `auto` creates a rollback deployment when a deployment fails on health; `manual` (default) only reports it. */
46
81
  rollback?: 'auto' | 'manual';
82
+ /** Defaults for every `x-tug.backup` target; they enable nothing by themselves. */
83
+ backup?: Pick<BackupSpec, 'schedule' | 'retention'>;
47
84
  }
48
85
  /** The `x-tug` extension on a top-level Compose volume. */
49
86
  export interface VolumeExtension {
@@ -51,10 +88,7 @@ export interface VolumeExtension {
51
88
  driver?: string;
52
89
  persistent?: boolean;
53
90
  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;
91
+ backup?: BackupConfig;
58
92
  }
59
93
  export type BuildConfig = string | {
60
94
  context?: string;
@@ -130,7 +164,74 @@ export declare const ACTIVE_DEPLOYMENT_STATES: readonly ["uploading", "building"
130
164
  /** Deployment states a CLI wait loop stops on. */
131
165
  export declare const TERMINAL_DEPLOYMENT_STATES: readonly ["succeeded", "failed", "build_failed", "cancelled"];
132
166
  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';
167
+ export type CommandType = 'diagnostics.run' | 'service.restart' | 'logs.fetch' | 'volume.delete' | 'wg.rotate-key' | 'backup.check' | 'backup.run' | 'backup.restore' | 'backup.list';
168
+ /** Commands whose helper containers need the backup destination's credentials. */
169
+ export declare const BACKUP_COMMAND_TYPES: readonly CommandType[];
170
+ /** `GET /agent/v1/backup/credentials`: environment for the restic helper of one running backup command. */
171
+ export interface BackupCredentialsResponse {
172
+ env: Record<string, string>;
173
+ }
174
+ /** Why a command may be refused or a run marked missed; recorded on the run. */
175
+ export type BackupMissReason = 'node_offline' | 'control_down' | 'deadline';
176
+ export type JobPhase = 'queued' | 'pulling' | 'checking' | 'dumping' | 'uploading' | 'forgetting' | 'restoring';
177
+ /** Payload of `backup.check`, `backup.run` and `backup.restore`. Restic credentials are never in a payload. */
178
+ export interface BackupJobPayload {
179
+ runId: string;
180
+ project: string;
181
+ /** `service:db` or `volume:uploads`; empty for `backup.check`. */
182
+ target: string;
183
+ type: BackupType;
184
+ /** The project's restic repository, `<destination>/<project>`. */
185
+ repository: string;
186
+ resticImage: string;
187
+ /** Used as restic's `--host`, so each node keeps its own snapshot chain. */
188
+ node: string;
189
+ tags: string[];
190
+ retention?: BackupRetention;
191
+ /** Command targets: what to run in the service's container, and how to name and check its output. */
192
+ command?: {
193
+ service: string;
194
+ argv: string[];
195
+ filename: string;
196
+ verify?: string;
197
+ /** Run in the service container with the snapshot's file on stdin when restoring. */
198
+ restoreArgv?: string[];
199
+ };
200
+ /** Volume targets: the Docker volume to mount read-only; it must already exist on the node. */
201
+ volume?: {
202
+ name: string;
203
+ dockerVolume: string;
204
+ exclude?: string[];
205
+ };
206
+ limits: {
207
+ memory: string;
208
+ timeoutSec: number;
209
+ };
210
+ restore?: {
211
+ snapshotId: string;
212
+ mode: 'in-place' | 'scratch';
213
+ scratchVolume?: string;
214
+ };
215
+ }
216
+ export interface BackupJobResult {
217
+ runId: string;
218
+ snapshotId?: string;
219
+ addedBytes?: number;
220
+ totalBytes?: number;
221
+ durationMs: number;
222
+ resticVersion?: string;
223
+ /** Repository state for `backup.check`. */
224
+ repository?: 'ok' | 'initialized';
225
+ warnings?: string[];
226
+ }
227
+ /** Live jobs in `StatusReport.jobs`, so control can tell "running" from "lost". */
228
+ export interface JobStatus {
229
+ commandId: string;
230
+ runId: string;
231
+ phase: JobPhase;
232
+ startedAt: string;
233
+ bytes?: number;
234
+ }
134
235
  /** A `commands` table row as delivered in `GET /agent/v1/work`. */
135
236
  export interface Command {
136
237
  id: string;
@@ -173,7 +274,7 @@ export type CommandResultPayload = {
173
274
  deleted: true;
174
275
  } | {
175
276
  publicKey: string;
176
- } | {
277
+ } | BackupJobResult | {
177
278
  error: string;
178
279
  };
179
280
  export interface CommandResult {
@@ -206,6 +307,8 @@ export type LiveRequest = {
206
307
  tty: boolean;
207
308
  columns?: number;
208
309
  rows?: number;
310
+ /** Run the command in a new container cloned from the service's, instead of in the running one. */
311
+ oneOff?: boolean;
209
312
  } | {
210
313
  operation: 'logs';
211
314
  project: string;
@@ -327,6 +430,8 @@ export interface WorkResponse {
327
430
  ingress?: IngressWork;
328
431
  /** Host addresses for published ports that name no host IP. */
329
432
  ports?: PortBinding;
433
+ /** Per project, services that must stay stopped because a restore is rewriting their data. */
434
+ holds?: Record<string, string[]>;
330
435
  }
331
436
  /**
332
437
  * Where a node's published ports listen when a `ports` entry names no host IP. Entries with an
@@ -393,7 +498,9 @@ export interface IngressStatus {
393
498
  error?: string;
394
499
  }
395
500
  export type ProjectHealth = 'serving' | 'skipped' | 'failed';
396
- export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving';
501
+ export type RolloutState = 'pending' | 'pulling' | 'starting' | 'health_checking' | 'serving'
502
+ /** Stopped on purpose while a restore rewrites its data. */
503
+ | 'held';
397
504
  export interface ServiceState {
398
505
  rolloutState: RolloutState;
399
506
  image?: string;
@@ -419,6 +526,8 @@ export interface StatusReport {
419
526
  projects: ProjectStatus[];
420
527
  commandResults: CommandResult[];
421
528
  ingress?: IngressStatus;
529
+ /** Backup jobs still running on this node; absent when none. */
530
+ jobs?: JobStatus[];
422
531
  }
423
532
  export interface SecretsResponse {
424
533
  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.6.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",