@wpelevator/tug 0.6.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.
package/README.md CHANGED
@@ -72,7 +72,7 @@ The CLI installs the matching `ghcr.io/wpelevator/tug:<version>` image on your h
72
72
  - [Architecture](docs/architecture.md): components, reconciliation, workflow diagrams, secrets and runtime paths.
73
73
  - [Compose extensions](docs/compose.md): `x-tug` placement, health checks, volumes and limitations.
74
74
  - [Control plane](docs/control-plane.md): install, TLS, image delivery, backup and restore.
75
- - [Backups](docs/backups.md): interim WordPress backup sidecar with restic, per-node variants, and a restore drill until `x-tug.backup` (M11).
75
+ - [Backups](docs/backups.md): scheduled restic backups with `x-tug.backup`, restore, recipes for common databases, and a restore drill. See also the [WordPress example](examples/wordpress/README.md).
76
76
  - [Access control](docs/access-control.md): scoped API tokens, roles, audit trail and a GitHub Actions deploy example.
77
77
  - [Networking](docs/networking.md): WireGuard mesh, service DNS, ingress and TLS, firewall notes.
78
78
  - [Nodes](docs/nodes.md): SSH install, self-service enrollment, registration and status.
package/dist/agent.d.ts CHANGED
@@ -66,6 +66,13 @@ export declare class AgentLiveOperations {
66
66
  }
67
67
  /** The container's address on the project network, which health checks dial. */
68
68
  export declare function containerIp(ctx: AgentContext, project: string, container: string): Promise<string>;
69
+ /** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
70
+ export declare function runsInContainer(): boolean;
71
+ /**
72
+ * Health checks dial the container's address on the project network. An agent that runs as a plain host process
73
+ * cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
74
+ */
75
+ export declare function outsideContainerHint(health: HealthCheck, inContainer?: boolean): string;
69
76
  export declare function waitForHealth(ctx: AgentContext, project: string, container: string, service: string, health: HealthCheck | null | undefined): Promise<void>;
70
77
  export { parseDuration };
71
78
  export interface PublicListener {
package/dist/agent.js CHANGED
@@ -1,7 +1,7 @@
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";
@@ -485,6 +485,30 @@ function tcpCheck(host, port, timeoutMs = 2000) {
485
485
  s.on('error', reject);
486
486
  });
487
487
  }
488
+ let containerized;
489
+ /** True when this process runs inside a container: Docker, containerd, Podman or Kubernetes. */
490
+ export function runsInContainer() {
491
+ containerized ??= (() => {
492
+ if (process.env.container || existsSync('/.dockerenv'))
493
+ return true;
494
+ try {
495
+ return /docker|containerd|kubepods|libpod/.test(readFileSync('/proc/1/cgroup', 'utf8'));
496
+ }
497
+ catch {
498
+ return false;
499
+ }
500
+ })();
501
+ return containerized;
502
+ }
503
+ /**
504
+ * Health checks dial the container's address on the project network. An agent that runs as a plain host process
505
+ * cannot reach it on a machine where Docker lives in a VM (macOS, Windows), and the bare timeout does not say why.
506
+ */
507
+ export function outsideContainerHint(health, inContainer = runsInContainer()) {
508
+ if (inContainer || health.type === 'docker')
509
+ return '';
510
+ 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';
511
+ }
488
512
  export async function waitForHealth(ctx, project, container, service, health) {
489
513
  if (!ctx.apply || !health)
490
514
  return;
@@ -518,7 +542,7 @@ export async function waitForHealth(ctx, project, container, service, health) {
518
542
  await new Promise((r) => setTimeout(r, intervalMs));
519
543
  }
520
544
  }
521
- throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}`);
545
+ throw new Error(`${service}: ${describeHealthCheck(health)} failed after ${retries} attempt${retries === 1 ? '' : 's'}: ${describeError(last)}${outsideContainerHint(health)}`);
522
546
  }
523
547
  /** Node's fetch reports every network failure as "fetch failed"; the system error that explains it is on `cause`. */
524
548
  function describeError(error) {
@@ -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();
@@ -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;
@@ -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 };
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { fileURLToPath } from 'node:url';
13
13
  import { composeHash } from "./compose.js";
14
14
  import { normalizeCompose } from "./normalize.js";
15
15
  import { errMessage, token, loadTemplate } from "./util.js";
16
+ import { backupCheckProblems } from "./backup-check.js";
16
17
  import { PROJECT_NAME_PATTERN, ROLES, TOKEN_NAME_PATTERN } from "./tokens.js";
17
18
  import { packageVersion, resolveImage } from "./image.js";
18
19
  import { TERMINAL_DEPLOYMENT_STATES } from "./protocol.js";
@@ -451,6 +452,7 @@ program
451
452
  .command('backup-list')
452
453
  .description('show each backup target with its last success and next run, then recent runs')
453
454
  .option('--limit <n>', 'number of recent runs to show', '20')
455
+ .option('--check', 'exit nonzero when a target is stale, or its last run failed or was missed, or there is no destination; for cron and monitors')
454
456
  .action(async (o) => {
455
457
  const project = (await loadCfg()).project || 'site';
456
458
  const listing = await request('GET', `/api/v1/backups?project=${encodeURIComponent(project)}&limit=${encodeURIComponent(o.limit)}`);
@@ -462,6 +464,13 @@ program
462
464
  console.log(`${t.target}\t${t.node ?? '-'}\t${t.schedule}\tlast success ${t.lastSuccessAt ?? 'never'}\tnext ${t.enabled ? t.nextRunAt : 'disabled'}`);
463
465
  for (const r of listing.runs)
464
466
  console.log(`${r.finished_at ?? r.queued_at}\t${r.target}\t${r.trigger}\t${r.state}\t${r.snapshot_id?.slice(0, 8) ?? '-'}\t${formatBytes(r.size_bytes)}${r.error ? `\t${r.error}` : ''}`);
467
+ if (o.check) {
468
+ const problems = backupCheckProblems(listing);
469
+ for (const problem of problems)
470
+ console.error(`PROBLEM ${problem}`);
471
+ if (problems.length)
472
+ process.exitCode = 1;
473
+ }
465
474
  });
466
475
  program
467
476
  .command('restore <target>')
@@ -504,6 +513,8 @@ program
504
513
  const { restores } = await request('GET', `/api/v1/restores?project=${encodeURIComponent(project)}`);
505
514
  const restore = restores.find((item) => item.id === result.restoreId);
506
515
  if (restore?.state === 'succeeded') {
516
+ if (restore.error)
517
+ console.error(`warning: ${restore.error}`);
507
518
  console.log(`restore ${restore.id} completed${restore.scratch_volume ? `; files are in volume ${restore.scratch_volume}` : ''}`);
508
519
  return;
509
520
  }
@@ -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
  }
@@ -44,6 +44,8 @@ export interface BackupSpec {
44
44
  jitter?: number;
45
45
  /** Hard limit for one run, such as `30m` or `2h`. */
46
46
  timeout?: string | number;
47
+ /** Memory limit of the restic helper container, such as `256m` (default `512m`). */
48
+ memory?: string;
47
49
  /** Services only: run in the service's container; its standard output is the backup. A string runs through `sh -c`. */
48
50
  command?: string | string[];
49
51
  /** Services only: run in the service container to restore; the stored file arrives on its standard input. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/tug",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Portable VPS deployments with Docker Compose",
5
5
  "repository": {
6
6
  "type": "git",