@matthesketh/fleet 1.8.1 → 1.11.1

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 (230) hide show
  1. package/README.md +186 -16
  2. package/dist/bin/fleet-agent.d.ts +2 -0
  3. package/dist/bin/fleet-agent.js +7 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +73 -31
  6. package/dist/commands/add.d.ts +2 -1
  7. package/dist/commands/add.js +66 -59
  8. package/dist/commands/audit.d.ts +1 -0
  9. package/dist/commands/audit.js +144 -0
  10. package/dist/commands/backup.d.ts +1 -0
  11. package/dist/commands/backup.js +510 -0
  12. package/dist/commands/boot-start.d.ts +3 -1
  13. package/dist/commands/boot-start.js +39 -47
  14. package/dist/commands/completions.d.ts +6 -0
  15. package/dist/commands/completions.js +83 -0
  16. package/dist/commands/config.d.ts +16 -0
  17. package/dist/commands/config.js +96 -0
  18. package/dist/commands/deploy.js +3 -2
  19. package/dist/commands/deps.js +5 -1
  20. package/dist/commands/doctor.d.ts +32 -0
  21. package/dist/commands/doctor.js +186 -0
  22. package/dist/commands/egress.d.ts +1 -1
  23. package/dist/commands/egress.js +13 -10
  24. package/dist/commands/freeze.d.ts +8 -4
  25. package/dist/commands/freeze.js +77 -59
  26. package/dist/commands/git.js +2 -2
  27. package/dist/commands/health.d.ts +2 -1
  28. package/dist/commands/health.js +38 -56
  29. package/dist/commands/init.d.ts +2 -1
  30. package/dist/commands/init.js +83 -73
  31. package/dist/commands/install-mcp.d.ts +3 -1
  32. package/dist/commands/install-mcp.js +53 -34
  33. package/dist/commands/list.d.ts +2 -1
  34. package/dist/commands/list.js +22 -19
  35. package/dist/commands/logs.js +1 -1
  36. package/dist/commands/patch-systemd.d.ts +7 -1
  37. package/dist/commands/patch-systemd.js +71 -31
  38. package/dist/commands/remove.d.ts +3 -1
  39. package/dist/commands/remove.js +37 -26
  40. package/dist/commands/restart.d.ts +4 -1
  41. package/dist/commands/restart.js +17 -20
  42. package/dist/commands/rollback.d.ts +4 -1
  43. package/dist/commands/rollback.js +33 -42
  44. package/dist/commands/secrets.js +157 -9
  45. package/dist/commands/start.d.ts +4 -1
  46. package/dist/commands/start.js +17 -20
  47. package/dist/commands/status.d.ts +1 -1
  48. package/dist/commands/status.js +21 -26
  49. package/dist/commands/stop.d.ts +4 -1
  50. package/dist/commands/stop.js +17 -20
  51. package/dist/commands/testflight.d.ts +1 -0
  52. package/dist/commands/testflight.js +193 -0
  53. package/dist/commands/update.d.ts +16 -0
  54. package/dist/commands/update.js +95 -0
  55. package/dist/core/audit/cache.d.ts +4 -0
  56. package/dist/core/audit/cache.js +37 -0
  57. package/dist/core/audit/config.d.ts +5 -0
  58. package/dist/core/audit/config.js +35 -0
  59. package/dist/core/audit/greenlight.d.ts +11 -0
  60. package/dist/core/audit/greenlight.js +81 -0
  61. package/dist/core/audit/reporters/cli.d.ts +3 -0
  62. package/dist/core/audit/reporters/cli.js +68 -0
  63. package/dist/core/audit/suppress.d.ts +6 -0
  64. package/dist/core/audit/suppress.js +37 -0
  65. package/dist/core/audit/target.d.ts +5 -0
  66. package/dist/core/audit/target.js +26 -0
  67. package/dist/core/audit/types.d.ts +54 -0
  68. package/dist/core/audit/types.js +5 -0
  69. package/dist/core/backup/browser-api.d.ts +66 -0
  70. package/dist/core/backup/browser-api.js +197 -0
  71. package/dist/core/backup/browser-server.d.ts +11 -0
  72. package/dist/core/backup/browser-server.js +241 -0
  73. package/dist/core/backup/browser-ui.d.ts +5 -0
  74. package/dist/core/backup/browser-ui.js +268 -0
  75. package/dist/core/backup/cloudflare.d.ts +7 -0
  76. package/dist/core/backup/cloudflare.js +82 -0
  77. package/dist/core/backup/config.d.ts +9 -0
  78. package/dist/core/backup/config.js +80 -0
  79. package/dist/core/backup/detect.d.ts +11 -0
  80. package/dist/core/backup/detect.js +71 -0
  81. package/dist/core/backup/dump.d.ts +11 -0
  82. package/dist/core/backup/dump.js +82 -0
  83. package/dist/core/backup/index.d.ts +9 -0
  84. package/dist/core/backup/index.js +9 -0
  85. package/dist/core/backup/repo.d.ts +71 -0
  86. package/dist/core/backup/repo.js +256 -0
  87. package/dist/core/backup/schedule.d.ts +17 -0
  88. package/dist/core/backup/schedule.js +90 -0
  89. package/dist/core/backup/sensitive.d.ts +5 -0
  90. package/dist/core/backup/sensitive.js +37 -0
  91. package/dist/core/backup/status.d.ts +3 -0
  92. package/dist/core/backup/status.js +29 -0
  93. package/dist/core/backup/statuspage.d.ts +23 -0
  94. package/dist/core/backup/statuspage.js +145 -0
  95. package/dist/core/backup/system.d.ts +24 -0
  96. package/dist/core/backup/system.js +209 -0
  97. package/dist/core/backup/totp.d.ts +16 -0
  98. package/dist/core/backup/totp.js +116 -0
  99. package/dist/core/backup/types.d.ts +70 -0
  100. package/dist/core/backup/types.js +7 -0
  101. package/dist/core/backup/unlock.d.ts +19 -0
  102. package/dist/core/backup/unlock.js +69 -0
  103. package/dist/core/boot-refresh.d.ts +1 -1
  104. package/dist/core/boot-refresh.js +10 -9
  105. package/dist/core/deps/actors/pr-creator.d.ts +5 -3
  106. package/dist/core/deps/actors/pr-creator.js +71 -18
  107. package/dist/core/deps/collectors/fetch-with-timeout.d.ts +7 -0
  108. package/dist/core/deps/collectors/fetch-with-timeout.js +16 -0
  109. package/dist/core/deps/collectors/npm.js +3 -1
  110. package/dist/core/deps/collectors/vulnerability.d.ts +8 -0
  111. package/dist/core/deps/collectors/vulnerability.js +31 -2
  112. package/dist/core/deps/config.js +6 -0
  113. package/dist/core/deps/scanner.js +1 -1
  114. package/dist/core/deps/types.d.ts +8 -0
  115. package/dist/core/env.d.ts +3 -0
  116. package/dist/core/env.js +11 -0
  117. package/dist/core/exec.d.ts +1 -0
  118. package/dist/core/exec.js +4 -0
  119. package/dist/core/file-lock.d.ts +18 -0
  120. package/dist/core/file-lock.js +44 -0
  121. package/dist/core/git-onboard.js +10 -13
  122. package/dist/core/github.d.ts +3 -1
  123. package/dist/core/github.js +10 -7
  124. package/dist/core/logs-policy.d.ts +5 -0
  125. package/dist/core/logs-policy.js +20 -1
  126. package/dist/core/operator.d.ts +21 -0
  127. package/dist/core/operator.js +54 -0
  128. package/dist/core/registry.d.ts +18 -0
  129. package/dist/core/registry.js +26 -0
  130. package/dist/core/routines/schema.d.ts +11 -11
  131. package/dist/core/routines/schema.js +14 -3
  132. package/dist/core/routines/store.d.ts +8 -8
  133. package/dist/core/secrets-ops.d.ts +31 -6
  134. package/dist/core/secrets-ops.js +208 -102
  135. package/dist/core/secrets-providers.js +2 -2
  136. package/dist/core/secrets-rotation.d.ts +1 -1
  137. package/dist/core/secrets-rotation.js +58 -52
  138. package/dist/core/secrets-v2-cleanup.d.ts +19 -0
  139. package/dist/core/secrets-v2-cleanup.js +94 -0
  140. package/dist/core/secrets-v2-creds.d.ts +9 -0
  141. package/dist/core/secrets-v2-creds.js +44 -0
  142. package/dist/core/secrets-v2-install.d.ts +13 -0
  143. package/dist/core/secrets-v2-install.js +76 -0
  144. package/dist/core/secrets-v2-keypair.d.ts +10 -0
  145. package/dist/core/secrets-v2-keypair.js +31 -0
  146. package/dist/core/secrets-v2-migrate.d.ts +29 -0
  147. package/dist/core/secrets-v2-migrate.js +395 -0
  148. package/dist/core/secrets-v2-ops.d.ts +36 -0
  149. package/dist/core/secrets-v2-ops.js +184 -0
  150. package/dist/core/secrets-v2-protocol.d.ts +19 -0
  151. package/dist/core/secrets-v2-protocol.js +60 -0
  152. package/dist/core/secrets-v2-snapshot.d.ts +36 -0
  153. package/dist/core/secrets-v2-snapshot.js +115 -0
  154. package/dist/core/secrets-v2.d.ts +21 -0
  155. package/dist/core/secrets-v2.js +249 -0
  156. package/dist/core/secrets.d.ts +39 -4
  157. package/dist/core/secrets.js +91 -11
  158. package/dist/core/self-update.d.ts +32 -11
  159. package/dist/core/self-update.js +52 -14
  160. package/dist/core/testflight/asc.d.ts +12 -0
  161. package/dist/core/testflight/asc.js +101 -0
  162. package/dist/core/testflight/credentials.d.ts +3 -0
  163. package/dist/core/testflight/credentials.js +35 -0
  164. package/dist/core/testflight/resolve.d.ts +6 -0
  165. package/dist/core/testflight/resolve.js +44 -0
  166. package/dist/core/testflight/types.d.ts +13 -0
  167. package/dist/core/testflight/types.js +3 -0
  168. package/dist/core/testflight/workflow.d.ts +17 -0
  169. package/dist/core/testflight/workflow.js +65 -0
  170. package/dist/core/validate.d.ts +1 -0
  171. package/dist/core/validate.js +8 -0
  172. package/dist/index.js +0 -0
  173. package/dist/mcp/audit-tools.d.ts +2 -0
  174. package/dist/mcp/audit-tools.js +94 -0
  175. package/dist/mcp/git-tools.js +1 -1
  176. package/dist/mcp/registry-bridge.d.ts +10 -0
  177. package/dist/mcp/registry-bridge.js +65 -0
  178. package/dist/mcp/secrets-tools.js +2 -2
  179. package/dist/mcp/server.js +16 -82
  180. package/dist/mcp/testflight-tools.d.ts +2 -0
  181. package/dist/mcp/testflight-tools.js +52 -0
  182. package/dist/registry/context.d.ts +7 -0
  183. package/dist/registry/context.js +37 -0
  184. package/dist/registry/index.d.ts +5 -0
  185. package/dist/registry/index.js +44 -0
  186. package/dist/registry/parse-args.d.ts +13 -0
  187. package/dist/registry/parse-args.js +74 -0
  188. package/dist/registry/registry.d.ts +24 -0
  189. package/dist/registry/registry.js +26 -0
  190. package/dist/registry/render.d.ts +3 -0
  191. package/dist/registry/render.js +29 -0
  192. package/dist/registry/types.d.ts +50 -0
  193. package/dist/registry/types.js +1 -0
  194. package/dist/templates/agent-unit.d.ts +5 -0
  195. package/dist/templates/agent-unit.js +40 -0
  196. package/dist/templates/app-unit-edit.d.ts +2 -0
  197. package/dist/templates/app-unit-edit.js +46 -0
  198. package/dist/templates/compose-edit.d.ts +2 -0
  199. package/dist/templates/compose-edit.js +156 -0
  200. package/dist/templates/nginx.js +11 -0
  201. package/dist/templates/systemd.js +6 -0
  202. package/dist/tui/components/ArgForm.d.ts +7 -0
  203. package/dist/tui/components/ArgForm.js +64 -0
  204. package/dist/tui/components/ArgForm.test.d.ts +1 -0
  205. package/dist/tui/components/ArgForm.test.js +19 -0
  206. package/dist/tui/components/KeyHint.js +5 -0
  207. package/dist/tui/hooks/use-secrets.d.ts +8 -8
  208. package/dist/tui/hooks/use-secrets.js +7 -7
  209. package/dist/tui/router.d.ts +1 -0
  210. package/dist/tui/router.js +26 -9
  211. package/dist/tui/router.test.d.ts +1 -0
  212. package/dist/tui/router.test.js +13 -0
  213. package/dist/tui/routines/components/SignalsGrid.test.js +2 -2
  214. package/dist/tui/routines/tabs/ScaffoldTab.js +1 -1
  215. package/dist/tui/tests/redaction-rerender.test.d.ts +1 -0
  216. package/dist/tui/tests/redaction-rerender.test.js +53 -0
  217. package/dist/tui/tests/scroll-flicker-proof.test.d.ts +1 -0
  218. package/dist/tui/tests/scroll-flicker-proof.test.js +145 -0
  219. package/dist/tui/types.d.ts +1 -1
  220. package/dist/tui/views/CommandPalette.d.ts +5 -0
  221. package/dist/tui/views/CommandPalette.js +90 -0
  222. package/dist/tui/views/CommandPalette.test.d.ts +1 -0
  223. package/dist/tui/views/CommandPalette.test.js +117 -0
  224. package/dist/tui/views/Dashboard.js +9 -6
  225. package/dist/tui/views/HealthView.js +9 -4
  226. package/dist/tui/views/SecretEdit.js +15 -16
  227. package/dist/tui/views/SecretEdit.test.d.ts +1 -0
  228. package/dist/tui/views/SecretEdit.test.js +82 -0
  229. package/dist/tui/views/SecretsView.js +26 -16
  230. package/package.json +8 -5
@@ -0,0 +1,80 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { requireEnv } from '../env.js';
4
+ import { FleetError } from '../errors.js';
5
+ import { isPseudoApp } from './types.js';
6
+ // read at call time so test env overrides land. consumers that want the
7
+ // resolved path should call backupConfigDir() / backupVaultDir().
8
+ export function backupConfigDir() {
9
+ return process.env.FLEET_BACKUP_CONFIG_DIR ?? '/etc/fleet/backups';
10
+ }
11
+ // the vault holds the age-encrypted restic passwords — no safe default,
12
+ // so an unset FLEET_BACKUP_VAULT_DIR is a hard error rather than a guess.
13
+ export function backupVaultDir() {
14
+ return requireEnv('FLEET_BACKUP_VAULT_DIR');
15
+ }
16
+ export const DEFAULT_RETENTION = {
17
+ hourly: 24,
18
+ daily: 14,
19
+ weekly: 8,
20
+ monthly: 12,
21
+ };
22
+ export const DEFAULT_EXCLUDES = [
23
+ 'node_modules',
24
+ '.next',
25
+ 'dist',
26
+ 'build',
27
+ 'target',
28
+ '__pycache__',
29
+ '.cache',
30
+ '.venv',
31
+ 'venv',
32
+ '.npm',
33
+ '.yarn',
34
+ 'coverage',
35
+ '.pytest_cache',
36
+ '*.log',
37
+ '*.pid',
38
+ '*.lock',
39
+ '.DS_Store',
40
+ 'tmp',
41
+ ];
42
+ function configPath(app) {
43
+ return join(backupConfigDir(), `${app}.json`);
44
+ }
45
+ export function loadConfig(app) {
46
+ const path = configPath(app);
47
+ if (!existsSync(path))
48
+ return null;
49
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
50
+ // basic shape check
51
+ if (!raw.app || !Array.isArray(raw.paths) || !raw.retention) {
52
+ throw new FleetError(`malformed backup config at ${path}`);
53
+ }
54
+ return raw;
55
+ }
56
+ export function saveConfig(cfg) {
57
+ const dir = backupConfigDir();
58
+ if (!existsSync(dir)) {
59
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ }
61
+ writeFileSync(configPath(cfg.app), JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
62
+ }
63
+ export function listConfiguredApps() {
64
+ const dir = backupConfigDir();
65
+ if (!existsSync(dir))
66
+ return [];
67
+ return readdirSync(dir)
68
+ .filter(f => f.endsWith('.json'))
69
+ .map(f => f.slice(0, -5))
70
+ .sort();
71
+ }
72
+ export function validateAppName(app) {
73
+ if (!app)
74
+ throw new FleetError('app name required');
75
+ if (isPseudoApp(app))
76
+ return;
77
+ if (!/^[a-z0-9_][a-z0-9._-]{0,62}$/.test(app)) {
78
+ throw new FleetError(`invalid app name: ${app}`);
79
+ }
80
+ }
@@ -0,0 +1,11 @@
1
+ import { AppBackupConfig, DumpHook, Schedule } from './types.js';
2
+ /** detect the db dump hook (if any) for a registered fleet app. matches by
3
+ * container image keyword: postgres/mysql/mongo/redis. */
4
+ export declare function detectDumpHook(appName: string): DumpHook | undefined;
5
+ /** detect named docker volumes attached to the app's containers. anonymous
6
+ * volumes (uuid names) are skipped — they're transient. */
7
+ export declare function detectVolumes(appName: string): string[];
8
+ /** decide a sensible default schedule based on whether the app has a db. */
9
+ export declare function defaultScheduleFor(hasDump: boolean): Schedule;
10
+ /** build a baseline config for a registered fleet app. */
11
+ export declare function detectAppConfig(appName: string): AppBackupConfig | null;
@@ -0,0 +1,71 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { listContainers } from '../docker.js';
3
+ import { execSafe } from '../exec.js';
4
+ import { load as loadRegistry } from '../registry.js';
5
+ import { DEFAULT_RETENTION, DEFAULT_EXCLUDES } from './config.js';
6
+ /** detect the db dump hook (if any) for a registered fleet app. matches by
7
+ * container image keyword: postgres/mysql/mongo/redis. */
8
+ export function detectDumpHook(appName) {
9
+ const all = listContainers();
10
+ const candidates = all.filter(c => c.name.startsWith(appName) ||
11
+ c.name.endsWith(`-${appName}`) ||
12
+ c.name === appName);
13
+ for (const c of candidates) {
14
+ const img = c.image.toLowerCase();
15
+ if (img.includes('postgres') || img.includes('postgis')) {
16
+ return { type: 'postgres', container: c.name };
17
+ }
18
+ if (img.startsWith('mysql') || img.includes('mariadb')) {
19
+ return { type: 'mysql', container: c.name };
20
+ }
21
+ if (img.includes('mongo')) {
22
+ return { type: 'mongo', container: c.name };
23
+ }
24
+ if (img.startsWith('redis')) {
25
+ return { type: 'redis', container: c.name };
26
+ }
27
+ }
28
+ return undefined;
29
+ }
30
+ /** detect named docker volumes attached to the app's containers. anonymous
31
+ * volumes (uuid names) are skipped — they're transient. */
32
+ export function detectVolumes(appName) {
33
+ const r = execSafe('docker', ['ps', '-q', '--filter', `name=${appName}`], { timeout: 5_000 });
34
+ if (!r.ok)
35
+ return [];
36
+ const vols = new Set();
37
+ for (const cid of r.stdout.split('\n').filter(Boolean)) {
38
+ const v = execSafe('docker', ['inspect', cid, '--format', '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}|{{end}}{{end}}'], { timeout: 5_000 });
39
+ for (const name of v.stdout.split('|').filter(Boolean)) {
40
+ // skip anonymous (uuid-looking)
41
+ if (!/^[0-9a-f]{60,}$/i.test(name)) {
42
+ vols.add(name);
43
+ }
44
+ }
45
+ }
46
+ return [...vols].sort();
47
+ }
48
+ /** decide a sensible default schedule based on whether the app has a db. */
49
+ export function defaultScheduleFor(hasDump) {
50
+ return hasDump ? 'hourly' : 'daily';
51
+ }
52
+ /** build a baseline config for a registered fleet app. */
53
+ export function detectAppConfig(appName) {
54
+ const reg = loadRegistry();
55
+ const app = reg.apps.find(a => a.name === appName);
56
+ if (!app)
57
+ return null;
58
+ const composeDir = app.composePath;
59
+ const paths = existsSync(composeDir) ? [composeDir] : [];
60
+ const dump = detectDumpHook(appName);
61
+ const volumes = detectVolumes(appName);
62
+ return {
63
+ app: appName,
64
+ schedule: defaultScheduleFor(!!dump),
65
+ paths,
66
+ exclude: DEFAULT_EXCLUDES,
67
+ volumes: volumes.length > 0 ? volumes : undefined,
68
+ preDump: dump,
69
+ retention: DEFAULT_RETENTION,
70
+ };
71
+ }
@@ -0,0 +1,11 @@
1
+ import { FleetError } from '../errors.js';
2
+ import { DumpHook } from './types.js';
3
+ export declare class DumpError extends FleetError {
4
+ }
5
+ /** returns the shell command that streams a database dump to stdout.
6
+ * caller pipes the output into `restic backup --stdin` via sh -c so the
7
+ * dump bytes flow kernel-to-kernel and never enter node's spawnSync
8
+ * buffer (which has a 1mb ceiling and dies on multi-gb dumps). */
9
+ export declare function dumpStreamCommand(hook: DumpHook): string;
10
+ /** filename used inside the restic snapshot for the dump stream. */
11
+ export declare function dumpFilename(hook: DumpHook): string;
@@ -0,0 +1,82 @@
1
+ import { FleetError } from '../errors.js';
2
+ export class DumpError extends FleetError {
3
+ }
4
+ /** sh single-quote escape — wraps s in '...' with embedded quotes escaped
5
+ * as '\''. safe even if s contains $, `, \, *, spaces, or single quotes. */
6
+ function shq(s) {
7
+ return `'${s.replace(/'/g, "'\\''")}'`;
8
+ }
9
+ /** returns the shell command that streams a database dump to stdout.
10
+ * caller pipes the output into `restic backup --stdin` via sh -c so the
11
+ * dump bytes flow kernel-to-kernel and never enter node's spawnSync
12
+ * buffer (which has a 1mb ceiling and dies on multi-gb dumps). */
13
+ export function dumpStreamCommand(hook) {
14
+ switch (hook.type) {
15
+ case 'postgres': {
16
+ // postgres_user is set as env in shared-postgres compose; password
17
+ // auth not needed because pg_dumpall runs as the postgres unix user
18
+ // and gets peer auth on the unix socket inside the container.
19
+ const user = hook.user ? shq(hook.user) : `"$${hook.userEnv ?? 'POSTGRES_USER'}"`;
20
+ const inner = hook.db
21
+ ? `pg_dump -U ${user} -d ${shq(hook.db)} --no-owner --no-acl --clean --if-exists`
22
+ : `pg_dumpall -U ${user} --no-role-passwords`;
23
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
24
+ }
25
+ case 'mysql': {
26
+ const user = hook.user ?? (hook.userEnv ? `\${${hook.userEnv}}` : 'root');
27
+ const dbFlag = hook.db ? shq(hook.db) : '--all-databases';
28
+ const passwordExpr = hook.passwordFile
29
+ ? `"$(cat ${shq(hook.passwordFile)})"`
30
+ : hook.passwordEnv
31
+ ? `"\${${hook.passwordEnv}}"`
32
+ : '"${MYSQL_ROOT_PASSWORD}"';
33
+ const inner = `mysqldump -u${user} -p${passwordExpr} --single-transaction --routines --triggers ${dbFlag}`;
34
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
35
+ }
36
+ case 'mongo': {
37
+ const user = hook.user ?? (hook.userEnv ? `\${${hook.userEnv}}` : 'root');
38
+ const dbFlag = hook.db ? `--db=${shq(hook.db)}` : '';
39
+ const passwordExpr = hook.passwordFile
40
+ ? `"$(cat ${shq(hook.passwordFile)})"`
41
+ : hook.passwordEnv
42
+ ? `"\${${hook.passwordEnv}}"`
43
+ : '"${MONGO_INITDB_ROOT_PASSWORD}"';
44
+ const inner = `mongodump --archive --quiet --username ${user} --password ${passwordExpr} --authenticationDatabase admin ${dbFlag}`.trim();
45
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
46
+ }
47
+ case 'redis': {
48
+ const portFlag = hook.port ? `-p ${hook.port}` : '';
49
+ // redis-cli --rdb writes to a tempfile, then we cat it. >/dev/null on
50
+ // the rdb step keeps redis-cli's progress chatter out of the stream.
51
+ // when a host command supplies the password, inject it via docker exec
52
+ // -e so it never lives on the redis-cli cmdline (which would show in
53
+ // ps inside the container).
54
+ if (hook.passwordHostCommand) {
55
+ const inner = `redis-cli --no-auth-warning ${portFlag} -a "$REDIS_PASSWORD" --rdb /tmp/dump.rdb >/dev/null && cat /tmp/dump.rdb`;
56
+ return `docker exec -e REDIS_PASSWORD="$(${hook.passwordHostCommand})" ${hook.container} sh -c ${shq(inner)}`;
57
+ }
58
+ const passwordExpr = hook.passwordFile
59
+ ? `"$(cat ${shq(hook.passwordFile)})"`
60
+ : hook.passwordEnv
61
+ ? `"\${${hook.passwordEnv}}"`
62
+ : '"${REDIS_PASSWORD:-}"';
63
+ const inner = `redis-cli --no-auth-warning ${portFlag} -a ${passwordExpr} --rdb /tmp/dump.rdb >/dev/null && cat /tmp/dump.rdb`;
64
+ return `docker exec ${hook.container} sh -c ${shq(inner)}`;
65
+ }
66
+ default:
67
+ throw new DumpError(`unsupported dump type: ${hook.type}`);
68
+ }
69
+ }
70
+ /** filename used inside the restic snapshot for the dump stream. */
71
+ export function dumpFilename(hook) {
72
+ switch (hook.type) {
73
+ case 'postgres':
74
+ return `${hook.db ?? 'all'}.pg.sql`;
75
+ case 'mysql':
76
+ return `${hook.db ?? 'all'}.mysql.sql`;
77
+ case 'mongo':
78
+ return `${hook.db ?? 'all'}.mongo.archive`;
79
+ case 'redis':
80
+ return `dump.rdb`;
81
+ }
82
+ }
@@ -0,0 +1,9 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './unlock.js';
4
+ export * from './repo.js';
5
+ export * from './dump.js';
6
+ export * from './system.js';
7
+ export * from './cloudflare.js';
8
+ export * from './schedule.js';
9
+ export * from './detect.js';
@@ -0,0 +1,9 @@
1
+ export * from './types.js';
2
+ export * from './config.js';
3
+ export * from './unlock.js';
4
+ export * from './repo.js';
5
+ export * from './dump.js';
6
+ export * from './system.js';
7
+ export * from './cloudflare.js';
8
+ export * from './schedule.js';
9
+ export * from './detect.js';
@@ -0,0 +1,71 @@
1
+ import { ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import { FleetError } from '../errors.js';
3
+ import { Retention, SnapshotInfo, RepoStats } from './types.js';
4
+ export declare const SFTP_HOST_ALIAS: string;
5
+ export declare class ResticError extends FleetError {
6
+ }
7
+ /** true when the backend rejects deletions/rewrites (rest-server --append-only
8
+ * in particular). primary-side prune must skip on such backends — pruning
9
+ * happens locally on the backup vps via its own cron. */
10
+ export declare function isAppendOnly(): boolean;
11
+ export declare function initRepo(app: string): void;
12
+ export interface BackupOptions {
13
+ paths: string[];
14
+ excludes?: string[];
15
+ tags?: string[];
16
+ /** add a host=... override (useful for restoring tests). */
17
+ hostname?: string;
18
+ /** when set, restic gets --stdin --stdin-filename and paths are ignored
19
+ * (restic does not accept both). */
20
+ stdinFilename?: string;
21
+ /** small in-memory payloads piped via process stdin (~1mb safe). */
22
+ stdinData?: string;
23
+ /** for large/streaming payloads: a shell command whose stdout is piped
24
+ * into `restic backup --stdin` via `sh -c "<cmd> | restic ..."`. avoids
25
+ * the spawnSync 1mb buffer ceiling so multi-gb dumps work. */
26
+ stdinCommand?: string;
27
+ /** dry-run: restic walks paths and reports what would be added, no upload. */
28
+ dryRun?: boolean;
29
+ }
30
+ export declare function snapshot(app: string, opts: BackupOptions, timeoutMs?: number): SnapshotInfo;
31
+ export declare function listSnapshots(app: string): SnapshotInfo[];
32
+ export interface RestoreOptions {
33
+ snapshotId: string;
34
+ target: string;
35
+ /** restore only these subpaths (restic --include). */
36
+ include?: string[];
37
+ /** dry-run: list files that would be restored without writing. */
38
+ dryRun?: boolean;
39
+ /** post-restore integrity check: re-walks the restored tree and confirms
40
+ * file contents match the snapshot's chunk hashes (restic --verify). */
41
+ verify?: boolean;
42
+ }
43
+ export interface TreeEntry {
44
+ name: string;
45
+ type: 'dir' | 'file';
46
+ path: string;
47
+ size: number;
48
+ mtime: string;
49
+ }
50
+ /** parses `restic ls --json` output into the direct children of dirPath.
51
+ * restic ls recurses, so deeper descendants are filtered out here. */
52
+ export declare function parseLsOutput(stdout: string, dirPath: string): TreeEntry[];
53
+ /** lists the immediate children of dirPath within a snapshot. */
54
+ export declare function lsTree(app: string, snapshotId: string, dirPath: string): TreeEntry[];
55
+ /** the argv (after `restic`) for dumping a file from a snapshot. */
56
+ export declare function dumpFileArgs(app: string, snapshotId: string, filePath: string): string[];
57
+ /** spawns `restic dump`; caller pipes child.stdout to an http response and
58
+ * must handle 'error' / non-zero 'close'. streaming avoids buffering a
59
+ * potentially multi-gb file in node memory. */
60
+ export declare function dumpFileSpawn(app: string, snapshotId: string, filePath: string): ChildProcessWithoutNullStreams;
61
+ export declare function restore(app: string, opts: RestoreOptions, timeoutMs?: number): void;
62
+ /** orthogonal integrity check: walks the restic repo, recomputes chunk
63
+ * hashes, asserts the index is consistent. catches bit-rot on the
64
+ * backup-vps disk. callable independently or right after a restore. */
65
+ export declare function checkIntegrity(app: string, readDataPercent?: number): {
66
+ ok: boolean;
67
+ output: string;
68
+ };
69
+ export declare function prune(app: string, retention: Retention): void;
70
+ export declare function check(app: string): boolean;
71
+ export declare function stats(app: string): RepoStats | null;
@@ -0,0 +1,256 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { FleetError } from '../errors.js';
3
+ import { execSafe } from '../exec.js';
4
+ import { passwordCommandFor } from './unlock.js';
5
+ export const SFTP_HOST_ALIAS = process.env.FLEET_BACKUP_SFTP_ALIAS ?? 'backup-vps-sftp';
6
+ export class ResticError extends FleetError {
7
+ }
8
+ /** when fleet_backup_base_url is set we use it as the backend base — that
9
+ * enables rest:// + append-only protection. when unset we fall back to the
10
+ * legacy sftp alias. the systemd unit loads the url + rest creds from
11
+ * encrypted credstore so they never sit on disk in plaintext. */
12
+ function repoUri(app) {
13
+ const base = process.env.FLEET_BACKUP_BASE_URL;
14
+ if (base) {
15
+ return base.endsWith('/') ? `${base}${app}` : `${base}/${app}`;
16
+ }
17
+ return `sftp:${SFTP_HOST_ALIAS}:${app}`;
18
+ }
19
+ /** true when the backend rejects deletions/rewrites (rest-server --append-only
20
+ * in particular). primary-side prune must skip on such backends — pruning
21
+ * happens locally on the backup vps via its own cron. */
22
+ export function isAppendOnly() {
23
+ return (process.env.FLEET_BACKUP_BASE_URL ?? '').startsWith('rest:');
24
+ }
25
+ function resticEnv(app) {
26
+ // restic respects restic_rest_username/password env vars; the wrapper
27
+ // populates them from the encrypted credstore.
28
+ const env = { RESTIC_PASSWORD_COMMAND: passwordCommandFor(app) };
29
+ if (process.env.RESTIC_REST_USERNAME)
30
+ env.RESTIC_REST_USERNAME = process.env.RESTIC_REST_USERNAME;
31
+ if (process.env.RESTIC_REST_PASSWORD)
32
+ env.RESTIC_REST_PASSWORD = process.env.RESTIC_REST_PASSWORD;
33
+ return env;
34
+ }
35
+ function runRestic(app, args, timeoutMs = 60_000) {
36
+ const r = execSafe('restic', ['-r', repoUri(app), ...args], {
37
+ timeout: timeoutMs,
38
+ env: resticEnv(app),
39
+ });
40
+ return { stdout: r.stdout, stderr: r.stderr, ok: r.ok };
41
+ }
42
+ export function initRepo(app) {
43
+ // if it already exists, snapshots --no-lock succeeds; only init when it doesn't.
44
+ const probe = runRestic(app, ['snapshots', '--no-lock', '--quiet'], 15_000);
45
+ if (probe.ok)
46
+ return;
47
+ const init = runRestic(app, ['init'], 30_000);
48
+ if (!init.ok)
49
+ throw new ResticError(`restic init failed: ${init.stderr}`);
50
+ }
51
+ /** sh single-quote escape — wraps s in '...' with embedded quotes as '\''. */
52
+ function shellQuote(s) {
53
+ return `'${s.replace(/'/g, "'\\''")}'`;
54
+ }
55
+ export function snapshot(app, opts, timeoutMs = 30 * 60_000) {
56
+ const args = ['backup'];
57
+ if (opts.dryRun)
58
+ args.push('--dry-run');
59
+ for (const ex of opts.excludes ?? []) {
60
+ args.push('--exclude', ex);
61
+ }
62
+ for (const tag of opts.tags ?? []) {
63
+ args.push('--tag', tag);
64
+ }
65
+ if (opts.hostname) {
66
+ args.push('--host', opts.hostname);
67
+ }
68
+ if (opts.stdinFilename) {
69
+ // restic --stdin reads from stdin only; positional paths must be omitted.
70
+ args.push('--stdin', '--stdin-filename', opts.stdinFilename);
71
+ }
72
+ else {
73
+ args.push(...opts.paths);
74
+ }
75
+ args.push('--json');
76
+ let r;
77
+ if (opts.stdinCommand) {
78
+ // stream via bash — kernel pipes between the dump cmd and restic, so
79
+ // the dump bytes never enter node's spawnSync buffer. bash is used
80
+ // explicitly (rather than /bin/sh which is dash on ubuntu) because
81
+ // we need `pipefail` to surface dump errors that would otherwise be
82
+ // masked by restic's exit code.
83
+ const resticInvocation = ['restic', '-r', repoUri(app), ...args]
84
+ .map(shellQuote)
85
+ .join(' ');
86
+ const fullCmd = `set -eo pipefail; ${opts.stdinCommand} | ${resticInvocation}`;
87
+ if (process.env.FLEET_DEBUG_DUMP === '1') {
88
+ process.stderr.write(`[fleet-debug] bash -c: ${fullCmd}\n`);
89
+ }
90
+ r = execSafe('bash', ['-c', fullCmd], {
91
+ timeout: timeoutMs,
92
+ env: resticEnv(app),
93
+ });
94
+ }
95
+ else {
96
+ r = execSafe('restic', ['-r', repoUri(app), ...args], {
97
+ timeout: timeoutMs,
98
+ env: resticEnv(app),
99
+ input: opts.stdinData,
100
+ });
101
+ }
102
+ if (!r.ok)
103
+ throw new ResticError(`restic backup failed: ${r.stderr || r.stdout}`);
104
+ // last json line is the summary with snapshot_id (or counters for dry-run)
105
+ const lines = r.stdout.split('\n').filter(Boolean);
106
+ for (let i = lines.length - 1; i >= 0; i--) {
107
+ try {
108
+ const obj = JSON.parse(lines[i]);
109
+ if (obj.message_type === 'summary') {
110
+ return {
111
+ id: obj.snapshot_id ?? 'dry-run',
112
+ shortId: (obj.snapshot_id ?? 'dry-run').slice(0, 8),
113
+ time: new Date().toISOString(),
114
+ hostname: opts.hostname ?? '',
115
+ paths: opts.paths,
116
+ tags: opts.tags ?? [],
117
+ sizeBytes: obj.data_added,
118
+ };
119
+ }
120
+ }
121
+ catch { /* not json, skip */ }
122
+ }
123
+ throw new ResticError(`could not parse restic snapshot summary`);
124
+ }
125
+ export function listSnapshots(app) {
126
+ const r = runRestic(app, ['snapshots', '--json'], 15_000);
127
+ if (!r.ok) {
128
+ if (r.stderr.includes('does not exist'))
129
+ return [];
130
+ throw new ResticError(`restic snapshots failed: ${r.stderr}`);
131
+ }
132
+ if (!r.stdout)
133
+ return [];
134
+ const arr = JSON.parse(r.stdout);
135
+ return arr.map(s => ({
136
+ id: s.id,
137
+ shortId: s.short_id,
138
+ time: s.time,
139
+ hostname: s.hostname,
140
+ paths: s.paths,
141
+ tags: s.tags ?? [],
142
+ }));
143
+ }
144
+ /** parses `restic ls --json` output into the direct children of dirPath.
145
+ * restic ls recurses, so deeper descendants are filtered out here. */
146
+ export function parseLsOutput(stdout, dirPath) {
147
+ const base = dirPath === '/' ? '' : dirPath.replace(/\/+$/, '');
148
+ const entries = [];
149
+ for (const line of stdout.split('\n')) {
150
+ if (!line)
151
+ continue;
152
+ let node;
153
+ try {
154
+ node = JSON.parse(line);
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ if (node.struct_type !== 'node')
160
+ continue;
161
+ const path = typeof node.path === 'string' ? node.path : '';
162
+ if (!path || !path.startsWith(base + '/'))
163
+ continue;
164
+ const rest = path.slice(base.length + 1);
165
+ if (rest.length === 0 || rest.includes('/'))
166
+ continue;
167
+ entries.push({
168
+ name: typeof node.name === 'string' ? node.name : rest,
169
+ type: node.type === 'dir' ? 'dir' : 'file',
170
+ path,
171
+ size: typeof node.size === 'number' ? node.size : 0,
172
+ mtime: typeof node.mtime === 'string' ? node.mtime : '',
173
+ });
174
+ }
175
+ entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1);
176
+ return entries;
177
+ }
178
+ /** lists the immediate children of dirPath within a snapshot. */
179
+ export function lsTree(app, snapshotId, dirPath) {
180
+ const r = runRestic(app, ['ls', snapshotId, '--json', dirPath], 60_000);
181
+ if (!r.ok) {
182
+ throw new ResticError(`restic ls failed for ${dirPath}: ${r.stderr || r.stdout}`);
183
+ }
184
+ return parseLsOutput(r.stdout, dirPath);
185
+ }
186
+ /** the argv (after `restic`) for dumping a file from a snapshot. */
187
+ export function dumpFileArgs(app, snapshotId, filePath) {
188
+ return ['-r', repoUri(app), 'dump', snapshotId, filePath];
189
+ }
190
+ /** spawns `restic dump`; caller pipes child.stdout to an http response and
191
+ * must handle 'error' / non-zero 'close'. streaming avoids buffering a
192
+ * potentially multi-gb file in node memory. */
193
+ export function dumpFileSpawn(app, snapshotId, filePath) {
194
+ return spawn('restic', dumpFileArgs(app, snapshotId, filePath), {
195
+ env: { ...process.env, ...resticEnv(app) },
196
+ });
197
+ }
198
+ export function restore(app, opts, timeoutMs = 60 * 60_000) {
199
+ const args = ['restore', opts.snapshotId, '--target', opts.target];
200
+ if (opts.dryRun)
201
+ args.push('--dry-run');
202
+ if (opts.verify)
203
+ args.push('--verify');
204
+ for (const inc of opts.include ?? []) {
205
+ args.push('--include', inc);
206
+ }
207
+ const r = runRestic(app, args, timeoutMs);
208
+ if (!r.ok)
209
+ throw new ResticError(`restic restore failed: ${r.stderr}`);
210
+ }
211
+ /** orthogonal integrity check: walks the restic repo, recomputes chunk
212
+ * hashes, asserts the index is consistent. catches bit-rot on the
213
+ * backup-vps disk. callable independently or right after a restore. */
214
+ export function checkIntegrity(app, readDataPercent = 5) {
215
+ const r = runRestic(app, ['check', `--read-data-subset=${readDataPercent}%`], 30 * 60_000);
216
+ return { ok: r.ok, output: r.ok ? r.stdout : `${r.stdout}\n${r.stderr}`.trim() };
217
+ }
218
+ export function prune(app, retention) {
219
+ const args = ['forget', '--prune'];
220
+ if (retention.hourly)
221
+ args.push('--keep-hourly', String(retention.hourly));
222
+ if (retention.daily)
223
+ args.push('--keep-daily', String(retention.daily));
224
+ if (retention.weekly)
225
+ args.push('--keep-weekly', String(retention.weekly));
226
+ if (retention.monthly)
227
+ args.push('--keep-monthly', String(retention.monthly));
228
+ if (retention.yearly)
229
+ args.push('--keep-yearly', String(retention.yearly));
230
+ if (args.length === 2) {
231
+ throw new ResticError('retention is empty — refusing to forget all snapshots');
232
+ }
233
+ const r = runRestic(app, args, 5 * 60_000);
234
+ if (!r.ok)
235
+ throw new ResticError(`restic forget failed: ${r.stderr}`);
236
+ }
237
+ export function check(app) {
238
+ const r = runRestic(app, ['check', '--read-data-subset=5%'], 10 * 60_000);
239
+ return r.ok;
240
+ }
241
+ export function stats(app) {
242
+ const r = runRestic(app, ['stats', 'latest', '--json'], 15_000);
243
+ if (!r.ok || !r.stdout)
244
+ return null;
245
+ try {
246
+ const obj = JSON.parse(r.stdout);
247
+ return {
248
+ totalSize: obj.total_size ?? 0,
249
+ totalFileCount: obj.total_file_count ?? 0,
250
+ snapshotCount: obj.snapshots_count ?? 0,
251
+ };
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
@@ -0,0 +1,17 @@
1
+ import { Schedule } from './types.js';
2
+ export declare const SYSTEMD_UNIT_DIR: string;
3
+ export declare function timerUnitName(app: string): string;
4
+ export declare function serviceUnitName(app: string): string;
5
+ export interface ScheduleInstallResult {
6
+ timerPath: string;
7
+ timerContent: string;
8
+ sharedServicePath: string;
9
+ sharedServiceContent: string;
10
+ sharedServiceWrote: boolean;
11
+ }
12
+ /** plans the timer + service units. returns the unit file contents so callers
13
+ * can show a dry-run. set apply=true to actually write+enable. */
14
+ export declare function installScheduleUnits(app: string, schedule: Schedule, opts?: {
15
+ apply?: boolean;
16
+ }): ScheduleInstallResult;
17
+ export declare function disableSchedule(app: string): void;