Compare commits
2 Commits
3983adbdd7
...
7e16ddc4c6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e16ddc4c6 | |||
| 820417df7b |
@@ -8,6 +8,7 @@ Supported operations:
|
||||
- Execute commands with zero or more declared outputs, allowing preparation and cleanup steps.
|
||||
- Create compressed PostgreSQL or MySQL dumps using tools installed on the remote host.
|
||||
- Archive a remote directory as `tar.gz`.
|
||||
- Export restore-ready Backup Manager configuration with encrypted credential envelopes.
|
||||
- Run jobs manually or with timezone-aware cron schedules.
|
||||
- Retain a configured number of successful runs and notify by webhook or SMTP.
|
||||
|
||||
@@ -49,6 +50,7 @@ Every job targets one SSH host and runs its steps sequentially over one pinned c
|
||||
- **Remote command:** runs an executable with explicit arguments on the SSH host. It can collect generated host files or archives.
|
||||
- **PostgreSQL/MySQL dump:** creates a compressed dump using an encrypted password associated with that step.
|
||||
- **Directory archive:** archives an absolute host path.
|
||||
- **Backup Manager configuration:** creates a local, integrity-protected JSON artifact containing hosts, jobs, schedules, and notification settings. Credential values remain encrypted and restoration requires the original `MASTER_KEY`; the key itself is never exported.
|
||||
|
||||
Command arguments are entered one per line and are passed as distinct shell-quoted values; shell pipelines and redirection are not interpreted. Add an explicit script on the remote system when more complex command logic is required.
|
||||
|
||||
@@ -86,4 +88,6 @@ Run all checks in that order with `npm run check`.
|
||||
- `backups/<job-id>/<run-id>/` contains completed artifacts.
|
||||
- `backups/.staging/` contains in-progress downloads and is cleaned when a run fails.
|
||||
|
||||
Back up both the data and artifact volumes together. Restore workflows are not included in this release.
|
||||
Configuration exports omit run history, artifact metadata, artifact files, the administrator password, runtime paths, and `MASTER_KEY`. Store the exported JSON and the key separately. For a complete storage-level backup, back up both the data and artifact volumes together.
|
||||
|
||||
Restore a configuration export from **Settings > Restore configuration**. Restores require the original `MASTER_KEY`, reject modified exports, update records with matching stable identities, add missing records, and leave unrelated local configuration and run history untouched. Numeric IDs and host references are remapped when necessary. A backup from another manager can initialize an empty installation but cannot merge into a populated installation. Restore is blocked while any job is queued or running, and conflicting names owned by unrelated local records must be resolved first.
|
||||
|
||||
50
src/configuration-export.test.ts
Normal file
50
src/configuration-export.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { configurationExportSchema, createConfigurationExport } from './configuration-export.js';
|
||||
import { encryptJson } from './crypto.js';
|
||||
import { openDatabase } from './db.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('configuration export', () => {
|
||||
it('exports restore-relevant configuration without operational history', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-manager-export-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const db = openDatabase(directory);
|
||||
const key = Buffer.alloc(32, 7);
|
||||
const hostSecret = encryptJson({ password: 'host-plaintext' }, key);
|
||||
const jobSecret = encryptJson({ database: 'database-plaintext' }, key);
|
||||
const settingsSecret = encryptJson({ smtp: { password: 'smtp-plaintext' } }, key);
|
||||
db.prepare('INSERT INTO hosts (id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(7, 'server', 'server.local', 22, 'backup', `SHA256:${'A'.repeat(43)}`, 'password', hostSecret);
|
||||
db.prepare('INSERT INTO jobs (id, name, host_id, config, secret, schedule, timezone, enabled, retention_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(11, 'manager', 7, JSON.stringify({ steps: [{ id: 'config', name: 'Manager configuration', type: 'managerConfig', continueOnError: false, outputName: 'manager-config' }] }), jobSecret, '0 2 * * *', 'UTC', 1, 5);
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('notifications', settingsSecret);
|
||||
db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (?, 'succeeded', 'manual')").run(11);
|
||||
|
||||
const exported = createConfigurationExport(db, key, new Date('2026-09-05T12:00:00Z'));
|
||||
db.close();
|
||||
|
||||
expect(exported).toMatchObject({
|
||||
format: 'backup-script-manager/configuration', version: 1, exportedAt: '2026-09-05T12:00:00.000Z',
|
||||
hosts: [{ id: 7, authType: 'password', secret: hostSecret }],
|
||||
jobs: [{ id: 11, hostId: 7, secret: jobSecret, retentionCount: 5 }],
|
||||
settings: [{ key: 'notifications', value: settingsSecret }],
|
||||
});
|
||||
const serialized = JSON.stringify(exported);
|
||||
expect(serialized).not.toContain('runs');
|
||||
expect(serialized).not.toContain('artifacts');
|
||||
expect(serialized).not.toContain('host-plaintext');
|
||||
expect(serialized).not.toContain('database-plaintext');
|
||||
expect(serialized).not.toContain('smtp-plaintext');
|
||||
expect(exported.jobs[0]).not.toHaveProperty('nextRunAt');
|
||||
expect(configurationExportSchema.safeParse({ ...exported, version: 2 }).success).toBe(false);
|
||||
expect(configurationExportSchema.safeParse({ ...exported, unexpected: true }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
123
src/configuration-export.ts
Normal file
123
src/configuration-export.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { z } from 'zod';
|
||||
import type { HostRecord, JobRecord } from './db.js';
|
||||
import { jobConfigSchema, normalizeJobConfig } from './schemas.js';
|
||||
|
||||
const exportedHostSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
stableId: z.string().regex(/^[a-f0-9]{32}$/),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
hostname: z.string().trim().min(1).max(253),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
username: z.string().trim().min(1).max(100),
|
||||
fingerprint: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}=?$/, 'Expected an SHA256 SSH fingerprint'),
|
||||
authType: z.enum(['password', 'privateKey']),
|
||||
secret: z.string().min(1).max(2 * 1024 * 1024),
|
||||
}).strict();
|
||||
|
||||
const exportedJobSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
stableId: z.string().regex(/^[a-f0-9]{32}$/),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
hostId: z.number().int().positive(),
|
||||
config: jobConfigSchema,
|
||||
secret: z.string().min(1).max(2 * 1024 * 1024).nullable(),
|
||||
schedule: z.string().trim().min(1).nullable(),
|
||||
timezone: z.string().trim().min(1).max(100),
|
||||
enabled: z.boolean(),
|
||||
retentionCount: z.number().int().min(1).max(1000),
|
||||
}).strict().superRefine((job, context) => {
|
||||
if (!job.schedule) return;
|
||||
try {
|
||||
CronExpressionParser.parse(job.schedule, { tz: job.timezone });
|
||||
} catch {
|
||||
context.addIssue({ code: 'custom', path: ['schedule'], message: 'Invalid cron expression or timezone' });
|
||||
}
|
||||
});
|
||||
|
||||
const configurationExportFields = {
|
||||
format: z.literal('backup-script-manager/configuration'),
|
||||
version: z.literal(1),
|
||||
sourceInstanceId: z.uuid(),
|
||||
exportedAt: z.iso.datetime(),
|
||||
hosts: z.array(exportedHostSchema).max(10_000),
|
||||
jobs: z.array(exportedJobSchema).max(10_000),
|
||||
settings: z.array(z.object({ key: z.string().min(1).max(100).regex(/^[a-zA-Z0-9._-]+$/), value: z.string().min(1).max(2 * 1024 * 1024) }).strict()).max(1_000),
|
||||
};
|
||||
|
||||
export const configurationExportSchema = z.object({
|
||||
...configurationExportFields,
|
||||
integrity: z.string().regex(/^[A-Za-z0-9_-]{43}$/),
|
||||
}).strict().superRefine((configuration, context) => {
|
||||
checkUnique(configuration.hosts.map((host) => host.id), ['hosts'], 'Host IDs must be unique', context);
|
||||
checkUnique(configuration.hosts.map((host) => host.stableId), ['hosts'], 'Host stable IDs must be unique', context);
|
||||
checkUnique(configuration.hosts.map((host) => host.name), ['hosts'], 'Host names must be unique', context);
|
||||
checkUnique(configuration.jobs.map((job) => job.id), ['jobs'], 'Job IDs must be unique', context);
|
||||
checkUnique(configuration.jobs.map((job) => job.stableId), ['jobs'], 'Job stable IDs must be unique', context);
|
||||
checkUnique(configuration.jobs.map((job) => job.name), ['jobs'], 'Job names must be unique', context);
|
||||
checkUnique(configuration.settings.map((setting) => setting.key), ['settings'], 'Setting keys must be unique', context);
|
||||
const hostIds = new Set(configuration.hosts.map((host) => host.id));
|
||||
for (const [index, job] of configuration.jobs.entries()) {
|
||||
if (!hostIds.has(job.hostId)) context.addIssue({ code: 'custom', path: ['jobs', index, 'hostId'], message: 'Job references a host that is not in the export' });
|
||||
}
|
||||
});
|
||||
|
||||
export type ConfigurationExport = z.infer<typeof configurationExportSchema>;
|
||||
type UnsignedConfigurationExport = Omit<ConfigurationExport, 'integrity'>;
|
||||
|
||||
export function createConfigurationExport(db: Database.Database, masterKey: Buffer, exportedAt = new Date()): ConfigurationExport {
|
||||
return db.transaction(() => {
|
||||
const hostRows = db.prepare('SELECT * FROM hosts ORDER BY id').all() as HostRecord[];
|
||||
const jobRows = db.prepare('SELECT * FROM jobs ORDER BY id').all() as JobRecord[];
|
||||
const settings = db.prepare('SELECT key, value FROM settings ORDER BY key').all() as Array<{ key: string; value: string }>;
|
||||
const metadata = db.prepare("SELECT value FROM metadata WHERE key = 'instance_id'").get() as { value: string };
|
||||
const unsigned = {
|
||||
format: 'backup-script-manager/configuration' as const,
|
||||
version: 1 as const,
|
||||
sourceInstanceId: metadata.value,
|
||||
exportedAt: exportedAt.toISOString(),
|
||||
hosts: hostRows.map((host) => ({
|
||||
id: host.id,
|
||||
stableId: host.stable_id,
|
||||
name: host.name,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
fingerprint: host.fingerprint,
|
||||
authType: host.auth_type,
|
||||
secret: host.secret,
|
||||
})),
|
||||
jobs: jobRows.map((job) => ({
|
||||
id: job.id,
|
||||
stableId: job.stable_id,
|
||||
name: job.name,
|
||||
hostId: job.host_id,
|
||||
config: normalizeJobConfig(JSON.parse(job.config), String(job.id)),
|
||||
secret: job.secret,
|
||||
schedule: job.schedule,
|
||||
timezone: job.timezone,
|
||||
enabled: Boolean(job.enabled),
|
||||
retentionCount: job.retention_count,
|
||||
})),
|
||||
settings,
|
||||
};
|
||||
return { ...unsigned, integrity: configurationIntegrity(unsigned, masterKey) };
|
||||
})();
|
||||
}
|
||||
|
||||
export function verifyConfigurationExportIntegrity(configuration: ConfigurationExport, masterKey: Buffer): boolean {
|
||||
const { integrity, ...unsigned } = configuration;
|
||||
const expected = Buffer.from(configurationIntegrity(unsigned, masterKey), 'base64url');
|
||||
const actual = Buffer.from(integrity, 'base64url');
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
function checkUnique(values: Array<string | number>, path: PropertyKey[], message: string, context: z.RefinementCtx): void {
|
||||
if (new Set(values).size !== values.length) context.addIssue({ code: 'custom', path, message });
|
||||
}
|
||||
|
||||
function configurationIntegrity(configuration: UnsignedConfigurationExport, masterKey: Buffer): string {
|
||||
return createHmac('sha256', masterKey).update('backup-script-manager/configuration/v1\0').update(JSON.stringify(configuration)).digest('base64url');
|
||||
}
|
||||
154
src/configuration-restore.test.ts
Normal file
154
src/configuration-restore.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createConfigurationExport, type ConfigurationExport } from './configuration-export.js';
|
||||
import { ConfigurationRestoreError, restoreConfiguration } from './configuration-restore.js';
|
||||
import { encryptJson } from './crypto.js';
|
||||
import { openDatabase } from './db.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
const masterKey = Buffer.alloc(32, 9);
|
||||
const nextRunAt = '2030-01-01T02:00:00.000Z';
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('configuration restore', () => {
|
||||
it('updates matching records, adds missing records, and preserves unrelated configuration and history', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase(configuration);
|
||||
|
||||
const result = restoreConfiguration(db, configuration, masterKey, () => nextRunAt);
|
||||
|
||||
expect(result).toEqual({ hosts: { added: 0, updated: 1 }, jobs: { added: 0, updated: 1 }, settings: { added: 0, updated: 1 } });
|
||||
expect(db.prepare('SELECT name, hostname, secret FROM hosts WHERE id = 1').get()).toEqual({ name: 'restored-host', hostname: 'restored.local', secret: configuration.hosts[0]!.secret });
|
||||
expect(db.prepare('SELECT name, schedule, next_run_at AS nextRunAt, secret FROM jobs WHERE id = 1').get()).toEqual({ name: 'restored-job', schedule: '0 2 * * *', nextRunAt, secret: configuration.jobs[0]!.secret });
|
||||
expect(db.prepare('SELECT COUNT(*) AS count FROM runs WHERE job_id = 1').get()).toEqual({ count: 1 });
|
||||
expect(db.prepare('SELECT name FROM hosts WHERE id = 2').get()).toEqual({ name: 'unrelated-host' });
|
||||
expect(db.prepare('SELECT name FROM jobs WHERE id = 2').get()).toEqual({ name: 'unrelated-job' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects a backup encrypted with another key before changing records', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase(configuration);
|
||||
expect(() => restoreConfiguration(db, configuration, Buffer.alloc(32, 3), () => nextRunAt)).toThrow('MASTER_KEY');
|
||||
expect(db.prepare('SELECT name FROM hosts WHERE id = 1').get()).toEqual({ name: 'current-host' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects restore while a run is active', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase(configuration);
|
||||
db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (2, 'queued', 'manual')").run();
|
||||
try {
|
||||
restoreConfiguration(db, configuration, masterKey, () => nextRunAt);
|
||||
expect.unreachable('Restore should have been rejected');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ConfigurationRestoreError);
|
||||
expect((error as ConfigurationRestoreError).statusCode).toBe(409);
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rolls back all changes when a database write fails', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase(configuration);
|
||||
db.exec("CREATE TRIGGER reject_restore BEFORE UPDATE ON jobs BEGIN SELECT RAISE(FAIL, 'restore failure'); END");
|
||||
|
||||
expect(() => restoreConfiguration(db, configuration, masterKey, () => nextRunAt)).toThrow('restore failure');
|
||||
|
||||
expect(db.prepare('SELECT name FROM hosts WHERE id = 1').get()).toEqual({ name: 'current-host' });
|
||||
expect(db.prepare('SELECT name FROM jobs WHERE id = 1').get()).toEqual({ name: 'current-job' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects modified configuration even when its encrypted values are intact', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase(configuration);
|
||||
configuration.jobs[0]!.name = 'tampered-job';
|
||||
expect(() => restoreConfiguration(db, configuration, masterKey, () => nextRunAt)).toThrow('integrity verification failed');
|
||||
expect(db.prepare('SELECT name FROM jobs WHERE id = 1').get()).toEqual({ name: 'current-job' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects a foreign-instance backup when local configuration exists', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase();
|
||||
expect(() => restoreConfiguration(db, configuration, masterKey, () => nextRunAt)).toThrow('different Backup Manager instance');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('rejects names owned by unrelated records in the same manager', () => {
|
||||
const configuration = sourceConfiguration('unrelated-host');
|
||||
const db = targetDatabase(configuration);
|
||||
expect(() => restoreConfiguration(db, configuration, masterKey, () => nextRunAt)).toThrow('belongs to an unrelated local record');
|
||||
expect(db.prepare('SELECT name FROM hosts WHERE id = 1').get()).toEqual({ name: 'current-host' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('adopts the source identity when restoring into an empty manager', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = newDatabase('empty-target');
|
||||
|
||||
const result = restoreConfiguration(db, configuration, masterKey, () => nextRunAt);
|
||||
|
||||
expect(result.hosts).toEqual({ added: 1, updated: 0 });
|
||||
expect(db.prepare("SELECT value FROM metadata WHERE key = 'instance_id'").get()).toEqual({ value: configuration.sourceInstanceId });
|
||||
expect(db.prepare('SELECT name FROM jobs WHERE id = 1').get()).toEqual({ name: 'restored-job' });
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('does not overwrite reused numeric IDs or attach their history to restored jobs', () => {
|
||||
const configuration = sourceConfiguration();
|
||||
const db = targetDatabase(configuration, false);
|
||||
|
||||
const result = restoreConfiguration(db, configuration, masterKey, () => nextRunAt);
|
||||
|
||||
expect(result.jobs).toEqual({ added: 1, updated: 0 });
|
||||
expect(db.prepare('SELECT name FROM jobs WHERE id = 1').get()).toEqual({ name: 'current-job' });
|
||||
expect(db.prepare('SELECT jobs.name FROM runs JOIN jobs ON jobs.id = runs.job_id WHERE runs.job_id = 1').get()).toEqual({ name: 'current-job' });
|
||||
expect(db.prepare("SELECT jobs.name, hosts.name AS hostName FROM jobs JOIN hosts ON hosts.id = jobs.host_id WHERE jobs.stable_id = ?").get(configuration.jobs[0]!.stableId)).toEqual({ name: 'restored-job', hostName: 'restored-host' });
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
function sourceConfiguration(hostName = 'restored-host'): ConfigurationExport {
|
||||
const db = newDatabase('source');
|
||||
db.prepare('INSERT INTO hosts (id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, hostName, 'restored.local', 2222, 'restore', `SHA256:${'A'.repeat(43)}`, 'password', encryptJson({ password: 'restored-password' }, masterKey));
|
||||
const config = { steps: [{ id: 'database', name: 'Database', type: 'postgres', continueOnError: false, database: 'app', username: 'postgres', databaseHost: 'localhost', databasePort: 5432, outputName: 'app', timeoutSeconds: 300 }] };
|
||||
db.prepare('INSERT INTO jobs (id, name, host_id, config, secret, schedule, timezone, enabled, retention_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'restored-job', 1, JSON.stringify(config), encryptJson({ database: 'restored-database-password' }, masterKey), '0 2 * * *', 'UTC', 1, 5);
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('notifications', encryptJson({ notifySuccess: false, notifyFailure: true }, masterKey));
|
||||
const configuration = createConfigurationExport(db, masterKey, new Date('2026-09-07T12:00:00Z'));
|
||||
db.close();
|
||||
return configuration;
|
||||
}
|
||||
|
||||
function targetDatabase(configuration?: ConfigurationExport, preserveStableIds = true) {
|
||||
const db = newDatabase('target');
|
||||
if (configuration) db.prepare("UPDATE metadata SET value = ? WHERE key = 'instance_id'").run(configuration.sourceInstanceId);
|
||||
db.prepare('INSERT INTO hosts (id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'current-host', 'current.local', 22, 'backup', `SHA256:${'B'.repeat(43)}`, 'password', encryptJson({ password: 'current' }, masterKey));
|
||||
db.prepare('INSERT INTO hosts (id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(2, 'unrelated-host', 'unrelated.local', 22, 'backup', `SHA256:${'C'.repeat(43)}`, 'password', encryptJson({ password: 'unrelated' }, masterKey));
|
||||
const config = JSON.stringify({ steps: [{ id: 'files', name: 'Files', type: 'directory', continueOnError: false, path: '/srv/files', outputName: 'files', timeoutSeconds: 300 }] });
|
||||
db.prepare('INSERT INTO jobs (id, name, host_id, config, timezone, retention_count) VALUES (?, ?, ?, ?, ?, ?)').run(1, 'current-job', 1, config, 'UTC', 10);
|
||||
db.prepare('INSERT INTO jobs (id, name, host_id, config, timezone, retention_count) VALUES (?, ?, ?, ?, ?, ?)').run(2, 'unrelated-job', 2, config, 'UTC', 10);
|
||||
if (configuration && preserveStableIds) {
|
||||
db.prepare('UPDATE hosts SET stable_id = ? WHERE id = 1').run(configuration.hosts[0]!.stableId);
|
||||
db.prepare('UPDATE jobs SET stable_id = ? WHERE id = 1').run(configuration.jobs[0]!.stableId);
|
||||
}
|
||||
db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (1, 'succeeded', 'manual')").run();
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('notifications', encryptJson({ notifySuccess: true, notifyFailure: true }, masterKey));
|
||||
return db;
|
||||
}
|
||||
|
||||
function newDatabase(label: string) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `backup-manager-restore-${label}-`));
|
||||
temporaryDirectories.push(directory);
|
||||
return openDatabase(directory);
|
||||
}
|
||||
154
src/configuration-restore.ts
Normal file
154
src/configuration-restore.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { z } from 'zod';
|
||||
import { configurationExportSchema, type ConfigurationExport, verifyConfigurationExportIntegrity } from './configuration-export.js';
|
||||
import { decryptJson } from './crypto.js';
|
||||
import { normalizeStepSecrets } from './job-credentials.js';
|
||||
|
||||
const hostPasswordSchema = z.object({ password: z.string().min(1) }).strict();
|
||||
const hostPrivateKeySchema = z.object({ privateKey: z.string().min(1), passphrase: z.string().optional() }).strict();
|
||||
const stepSecretsSchema = z.record(z.string(), z.string());
|
||||
const notificationSettingsSchema = z.object({
|
||||
webhookUrl: z.url().optional(),
|
||||
notifySuccess: z.boolean(),
|
||||
notifyFailure: z.boolean(),
|
||||
smtp: z.object({
|
||||
host: z.string().min(1), port: z.number().int().min(1).max(65535), secure: z.boolean(),
|
||||
username: z.string().optional(), password: z.string().optional(), from: z.email(), to: z.email(),
|
||||
}).optional(),
|
||||
}).strict();
|
||||
|
||||
export interface RestoreResult {
|
||||
hosts: { added: number; updated: number };
|
||||
jobs: { added: number; updated: number };
|
||||
settings: { added: number; updated: number };
|
||||
}
|
||||
|
||||
export class ConfigurationRestoreError extends Error {
|
||||
constructor(message: string, readonly statusCode = 400) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreConfiguration(db: Database.Database, value: unknown, masterKey: Buffer, calculateNextRun: (schedule: string | null, timezone: string) => string | null): RestoreResult {
|
||||
const configuration = configurationExportSchema.parse(value);
|
||||
if (db.prepare("SELECT id FROM runs WHERE status IN ('queued', 'running') LIMIT 1").get()) throw new ConfigurationRestoreError('Cannot restore configuration while a job is queued or running', 409);
|
||||
if (!verifyConfigurationExportIntegrity(configuration, masterKey)) throw new ConfigurationRestoreError('Configuration integrity verification failed; the backup may be modified or use a different MASTER_KEY');
|
||||
validateSourceInstance(db, configuration);
|
||||
validateSecrets(configuration, masterKey);
|
||||
validateNameConflicts(db, configuration);
|
||||
|
||||
const existingHosts = identityMap(db, 'hosts');
|
||||
const existingJobs = identityMap(db, 'jobs');
|
||||
const existingSettingKeys = new Set((db.prepare('SELECT key FROM settings').all() as Array<{ key: string }>).map((row) => row.key));
|
||||
const result: RestoreResult = {
|
||||
hosts: countChanges(configuration.hosts.map((host) => host.stableId), new Set(existingHosts.keys())),
|
||||
jobs: countChanges(configuration.jobs.map((job) => job.stableId), new Set(existingJobs.keys())),
|
||||
settings: countChanges(configuration.settings.map((setting) => setting.key), existingSettingKeys),
|
||||
};
|
||||
|
||||
db.transaction(() => {
|
||||
const localInstance = db.prepare("SELECT value FROM metadata WHERE key = 'instance_id'").get() as { value: string };
|
||||
if (localInstance.value !== configuration.sourceInstanceId) db.prepare("UPDATE metadata SET value = ? WHERE key = 'instance_id'").run(configuration.sourceInstanceId);
|
||||
const temporaryHostName = db.prepare('UPDATE hosts SET name = ? WHERE id = ?');
|
||||
const temporaryJobName = db.prepare('UPDATE jobs SET name = ? WHERE id = ?');
|
||||
for (const host of configuration.hosts) {
|
||||
const existing = existingHosts.get(host.stableId);
|
||||
if (existing) temporaryHostName.run(`__restore_host_${randomUUID()}`, existing.id);
|
||||
}
|
||||
for (const job of configuration.jobs) {
|
||||
const existing = existingJobs.get(job.stableId);
|
||||
if (existing) temporaryJobName.run(`__restore_job_${randomUUID()}`, existing.id);
|
||||
}
|
||||
|
||||
const updateHost = db.prepare('UPDATE hosts SET stable_id = ?, name = ?, hostname = ?, port = ?, username = ?, fingerprint = ?, auth_type = ?, secret = ? WHERE id = ?');
|
||||
const insertHostWithId = db.prepare('INSERT INTO hosts (id, stable_id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const insertHost = db.prepare('INSERT INTO hosts (stable_id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const hostIdMap = new Map<number, number>();
|
||||
for (const host of configuration.hosts) {
|
||||
const existing = existingHosts.get(host.stableId);
|
||||
if (existing) {
|
||||
updateHost.run(host.stableId, host.name, host.hostname, host.port, host.username, host.fingerprint, host.authType, host.secret, existing.id);
|
||||
hostIdMap.set(host.id, existing.id);
|
||||
} else if (db.prepare('SELECT 1 FROM hosts WHERE id = ?').get(host.id)) {
|
||||
const inserted = insertHost.run(host.stableId, host.name, host.hostname, host.port, host.username, host.fingerprint, host.authType, host.secret);
|
||||
hostIdMap.set(host.id, Number(inserted.lastInsertRowid));
|
||||
} else {
|
||||
insertHostWithId.run(host.id, host.stableId, host.name, host.hostname, host.port, host.username, host.fingerprint, host.authType, host.secret);
|
||||
hostIdMap.set(host.id, host.id);
|
||||
}
|
||||
}
|
||||
|
||||
const updateJob = db.prepare('UPDATE jobs SET stable_id = ?, name = ?, host_id = ?, config = ?, secret = ?, schedule = ?, timezone = ?, enabled = ?, retention_count = ?, next_run_at = ? WHERE id = ?');
|
||||
const insertJobWithId = db.prepare('INSERT INTO jobs (id, stable_id, name, host_id, config, secret, schedule, timezone, enabled, retention_count, next_run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
const insertJob = db.prepare('INSERT INTO jobs (stable_id, name, host_id, config, secret, schedule, timezone, enabled, retention_count, next_run_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
for (const job of configuration.jobs) {
|
||||
const existing = existingJobs.get(job.stableId);
|
||||
const hostId = hostIdMap.get(job.hostId)!;
|
||||
const values = [job.stableId, job.name, hostId, JSON.stringify(job.config), job.secret, job.schedule, job.timezone, Number(job.enabled), job.retentionCount, calculateNextRun(job.schedule, job.timezone)] as const;
|
||||
if (existing) updateJob.run(...values, existing.id);
|
||||
else if (db.prepare('SELECT 1 FROM jobs WHERE id = ?').get(job.id)) insertJob.run(...values);
|
||||
else insertJobWithId.run(job.id, ...values);
|
||||
}
|
||||
|
||||
const upsertSetting = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||
for (const setting of configuration.settings) upsertSetting.run(setting.key, setting.value);
|
||||
})();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateSourceInstance(db: Database.Database, configuration: ConfigurationExport): void {
|
||||
const local = db.prepare("SELECT value FROM metadata WHERE key = 'instance_id'").get() as { value: string };
|
||||
if (local.value === configuration.sourceInstanceId) return;
|
||||
const populated = ['hosts', 'jobs', 'settings'].some((table) => (db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() as unknown) !== undefined);
|
||||
if (populated) throw new ConfigurationRestoreError('This backup belongs to a different Backup Manager instance; restore it into an empty manager', 409);
|
||||
}
|
||||
|
||||
function validateSecrets(configuration: ConfigurationExport, masterKey: Buffer): void {
|
||||
try {
|
||||
for (const host of configuration.hosts) {
|
||||
const secret = decryptJson<unknown>(host.secret, masterKey);
|
||||
(host.authType === 'password' ? hostPasswordSchema : hostPrivateKeySchema).parse(secret);
|
||||
}
|
||||
for (const job of configuration.jobs) {
|
||||
const decrypted = job.secret ? stepSecretsSchema.parse(decryptJson<unknown>(job.secret, masterKey)) : {};
|
||||
const secrets = normalizeStepSecrets(decrypted, job.config);
|
||||
for (const step of job.config.steps) {
|
||||
if ((step.type === 'postgres' || step.type === 'mysql') && !secrets[step.id]) throw new Error('Missing database password');
|
||||
}
|
||||
}
|
||||
for (const setting of configuration.settings) {
|
||||
const decrypted = decryptJson<unknown>(setting.value, masterKey);
|
||||
if (setting.key === 'notifications') notificationSettingsSchema.parse(decrypted);
|
||||
}
|
||||
} catch {
|
||||
throw new ConfigurationRestoreError('Configuration credentials cannot be decrypted and validated with the current MASTER_KEY');
|
||||
}
|
||||
}
|
||||
|
||||
function validateNameConflicts(db: Database.Database, configuration: ConfigurationExport): void {
|
||||
const importedHostIds = new Set(configuration.hosts.map((host) => host.stableId));
|
||||
const importedJobIds = new Set(configuration.jobs.map((job) => job.stableId));
|
||||
for (const host of configuration.hosts) {
|
||||
const conflict = db.prepare('SELECT stable_id AS stableId FROM hosts WHERE name = ?').get(host.name) as { stableId: string } | undefined;
|
||||
if (conflict && conflict.stableId !== host.stableId && !importedHostIds.has(conflict.stableId)) throw new ConfigurationRestoreError(`Host name "${host.name}" belongs to an unrelated local record`, 409);
|
||||
}
|
||||
for (const job of configuration.jobs) {
|
||||
const conflict = db.prepare('SELECT stable_id AS stableId FROM jobs WHERE name = ?').get(job.name) as { stableId: string } | undefined;
|
||||
if (conflict && conflict.stableId !== job.stableId && !importedJobIds.has(conflict.stableId)) throw new ConfigurationRestoreError(`Job name "${job.name}" belongs to an unrelated local record`, 409);
|
||||
}
|
||||
}
|
||||
|
||||
function identityMap(db: Database.Database, table: 'hosts' | 'jobs'): Map<string, { id: number }> {
|
||||
const rows = db.prepare(`SELECT id, stable_id AS stableId FROM ${table}`).all() as Array<{ id: number; stableId: string }>;
|
||||
return new Map(rows.map((row) => [row.stableId, { id: row.id }]));
|
||||
}
|
||||
|
||||
function countChanges<T>(values: T[], existing: Set<T>): { added: number; updated: number } {
|
||||
return values.reduce((count, value) => {
|
||||
if (existing.has(value)) count.updated += 1;
|
||||
else count.added += 1;
|
||||
return count;
|
||||
}, { added: 0, updated: 0 });
|
||||
}
|
||||
34
src/db.test.ts
Normal file
34
src/db.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { openDatabase } from './db.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('database migrations', () => {
|
||||
it('assigns stable identities to existing and newly inserted records', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-manager-migration-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const legacy = new Database(path.join(directory, 'app.db'));
|
||||
legacy.exec(`
|
||||
CREATE TABLE hosts (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, hostname TEXT NOT NULL, port INTEGER NOT NULL, username TEXT NOT NULL, fingerprint TEXT NOT NULL, auth_type TEXT NOT NULL, secret TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE TABLE jobs (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT, config TEXT NOT NULL, secret TEXT, schedule TEXT, timezone TEXT NOT NULL DEFAULT 'UTC', enabled INTEGER NOT NULL DEFAULT 1, retention_count INTEGER NOT NULL DEFAULT 10, next_run_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
|
||||
INSERT INTO hosts (name, hostname, port, username, fingerprint, auth_type, secret) VALUES ('legacy', 'legacy.local', 22, 'backup', 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', 'password', 'secret');
|
||||
INSERT INTO jobs (name, host_id, config) VALUES ('legacy-job', 1, '{}');
|
||||
`);
|
||||
legacy.close();
|
||||
|
||||
const db = openDatabase(directory);
|
||||
expect((db.prepare('SELECT stable_id AS stableId FROM hosts WHERE id = 1').get() as { stableId: string }).stableId).toMatch(/^[a-f0-9]{32}$/);
|
||||
expect((db.prepare('SELECT stable_id AS stableId FROM jobs WHERE id = 1').get() as { stableId: string }).stableId).toMatch(/^[a-f0-9]{32}$/);
|
||||
const id = Number(db.prepare('INSERT INTO hosts (name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?)').run('new', 'new.local', 22, 'backup', `SHA256:${'B'.repeat(43)}`, 'password', 'secret').lastInsertRowid);
|
||||
expect((db.prepare('SELECT stable_id AS stableId FROM hosts WHERE id = ?').get(id) as { stableId: string }).stableId).toMatch(/^[a-f0-9]{32}$/);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
25
src/db.ts
25
src/db.ts
@@ -1,9 +1,11 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export interface HostRecord {
|
||||
id: number;
|
||||
stable_id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
@@ -16,6 +18,7 @@ export interface HostRecord {
|
||||
|
||||
export interface JobRecord {
|
||||
id: number;
|
||||
stable_id: string;
|
||||
name: string;
|
||||
host_id: number;
|
||||
config: string;
|
||||
@@ -49,11 +52,13 @@ export function openDatabase(dataDir: string): Database.Database {
|
||||
CREATE TABLE IF NOT EXISTS hosts (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, hostname TEXT NOT NULL, port INTEGER NOT NULL,
|
||||
username TEXT NOT NULL, fingerprint TEXT NOT NULL, auth_type TEXT NOT NULL, secret TEXT NOT NULL,
|
||||
stable_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE RESTRICT,
|
||||
config TEXT NOT NULL, secret TEXT, schedule TEXT, timezone TEXT NOT NULL DEFAULT 'UTC', enabled INTEGER NOT NULL DEFAULT 1,
|
||||
stable_id TEXT,
|
||||
retention_count INTEGER NOT NULL DEFAULT 10, next_run_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
@@ -67,8 +72,28 @@ export function openDatabase(dataDir: string): Database.Database {
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS runs_job_id ON runs(job_id, id DESC);
|
||||
`);
|
||||
ensureColumn(db, 'hosts', 'stable_id TEXT');
|
||||
ensureColumn(db, 'jobs', 'stable_id TEXT');
|
||||
db.exec(`
|
||||
UPDATE hosts SET stable_id = lower(hex(randomblob(16))) WHERE stable_id IS NULL;
|
||||
UPDATE jobs SET stable_id = lower(hex(randomblob(16))) WHERE stable_id IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS hosts_stable_id ON hosts(stable_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS jobs_stable_id ON jobs(stable_id);
|
||||
CREATE TRIGGER IF NOT EXISTS hosts_assign_stable_id AFTER INSERT ON hosts WHEN NEW.stable_id IS NULL
|
||||
BEGIN UPDATE hosts SET stable_id = lower(hex(randomblob(16))) WHERE id = NEW.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS jobs_assign_stable_id AFTER INSERT ON jobs WHEN NEW.stable_id IS NULL
|
||||
BEGIN UPDATE jobs SET stable_id = lower(hex(randomblob(16))) WHERE id = NEW.id; END;
|
||||
`);
|
||||
db.prepare("INSERT OR IGNORE INTO metadata (key, value) VALUES ('instance_id', ?)").run(randomUUID());
|
||||
db.prepare("UPDATE runs SET status = 'failed', finished_at = CURRENT_TIMESTAMP, error = 'Interrupted by application restart' WHERE status IN ('queued', 'running')").run();
|
||||
return db;
|
||||
}
|
||||
|
||||
function ensureColumn(db: Database.Database, table: 'hosts' | 'jobs', definition: string): void {
|
||||
const column = definition.split(' ')[0]!;
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (!columns.some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { executeSequentialSteps } from './executor.js';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { openDatabase, type RunRecord } from './db.js';
|
||||
import { BackupExecutor, executeSequentialSteps } from './executor.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('step execution policy', () => {
|
||||
it('continues after tolerated failures and records a warning', async () => {
|
||||
@@ -23,3 +34,52 @@ describe('step execution policy', () => {
|
||||
expect(visited).toEqual(['first']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local configuration execution', () => {
|
||||
it('publishes a protected configuration artifact without opening SSH', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-manager-local-step-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const backupDir = path.join(directory, 'backups');
|
||||
fs.mkdirSync(backupDir);
|
||||
const db = openDatabase(path.join(directory, 'data'));
|
||||
db.prepare('INSERT INTO hosts (id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'unused-host', 'unreachable.invalid', 22, 'backup', `SHA256:${'A'.repeat(43)}`, 'password', 'not-an-encrypted-envelope');
|
||||
db.prepare('INSERT INTO jobs (id, name, host_id, config, timezone, retention_count) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'manager-config', 1, JSON.stringify({ steps: [{ id: 'config', name: 'Manager configuration', type: 'managerConfig', continueOnError: false, outputName: 'manager-config' }] }), 'UTC', 3);
|
||||
const runId = Number(db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (1, 'queued', 'manual')").run().lastInsertRowid);
|
||||
const run = db.prepare('SELECT * FROM runs WHERE id = ?').get(runId) as RunRecord;
|
||||
const config: AppConfig = { port: 0, dataDir: path.join(directory, 'data'), backupDir, adminPassword: 'not-exported', masterKey: Buffer.alloc(32), secureCookie: false };
|
||||
|
||||
await new BackupExecutor(db, config).execute(run);
|
||||
|
||||
const artifact = db.prepare('SELECT name, path FROM artifacts WHERE run_id = ?').get(runId) as { name: string; path: string };
|
||||
const artifactPath = path.join(backupDir, artifact.path);
|
||||
expect(db.prepare('SELECT status FROM runs WHERE id = ?').get(runId)).toEqual({ status: 'succeeded' });
|
||||
expect(artifact.name).toBe('manager-config.json');
|
||||
expect(JSON.parse(fs.readFileSync(artifactPath, 'utf8'))).toMatchObject({ format: 'backup-script-manager/configuration', version: 1 });
|
||||
expect(fs.statSync(artifactPath).mode & 0o777).toBe(0o600);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('removes published files when artifact metadata cannot be committed', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'backup-manager-local-failure-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const backupDir = path.join(directory, 'backups');
|
||||
fs.mkdirSync(backupDir);
|
||||
const db = openDatabase(path.join(directory, 'data'));
|
||||
db.prepare('INSERT INTO hosts (id, name, hostname, port, username, fingerprint, auth_type, secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'unused-host', 'unreachable.invalid', 22, 'backup', `SHA256:${'A'.repeat(43)}`, 'password', 'not-an-encrypted-envelope');
|
||||
db.prepare('INSERT INTO jobs (id, name, host_id, config, timezone, retention_count) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(1, 'manager-config', 1, JSON.stringify({ steps: [{ id: 'config', name: 'Manager configuration', type: 'managerConfig', continueOnError: false, outputName: 'manager-config' }] }), 'UTC', 3);
|
||||
const runId = Number(db.prepare("INSERT INTO runs (job_id, status, trigger) VALUES (1, 'queued', 'manual')").run().lastInsertRowid);
|
||||
const run = db.prepare('SELECT * FROM runs WHERE id = ?').get(runId) as RunRecord;
|
||||
db.exec("CREATE TRIGGER reject_artifact BEFORE INSERT ON artifacts BEGIN SELECT RAISE(FAIL, 'metadata failure'); END");
|
||||
const config: AppConfig = { port: 0, dataDir: path.join(directory, 'data'), backupDir, adminPassword: 'not-exported', masterKey: Buffer.alloc(32), secureCookie: false };
|
||||
|
||||
await expect(new BackupExecutor(db, config).execute(run)).rejects.toThrow('metadata failure');
|
||||
|
||||
expect(fs.existsSync(path.join(backupDir, '1', String(runId)))).toBe(false);
|
||||
expect(db.prepare('SELECT status FROM runs WHERE id = ?').get(runId)).toEqual({ status: 'failed' });
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
123
src/executor.ts
123
src/executor.ts
@@ -2,17 +2,18 @@ import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { Client } from 'ssh2';
|
||||
import type { Client, SFTPWrapper } from 'ssh2';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { createConfigurationExport } from './configuration-export.js';
|
||||
import { decryptJson } from './crypto.js';
|
||||
import type { HostRecord, JobRecord, RunRecord } from './db.js';
|
||||
import { normalizeStepSecrets } from './job-credentials.js';
|
||||
import { normalizeJobConfig, type JobConfig, type JobStep } from './schemas.js';
|
||||
import { connect, download, exec, getSftp, shellQuote, uploadText, type HostSecret } from './ssh.js';
|
||||
|
||||
interface RemoteArtifact {
|
||||
remotePath: string;
|
||||
name: string;
|
||||
}
|
||||
type PendingArtifact =
|
||||
| { source: 'remote'; remotePath: string; name: string }
|
||||
| { source: 'local'; localPath: string; name: string };
|
||||
|
||||
export class BackupExecutor {
|
||||
constructor(private db: Database.Database, private appConfig: AppConfig) {}
|
||||
@@ -23,27 +24,42 @@ export class BackupExecutor {
|
||||
const host = this.db.prepare('SELECT * FROM hosts WHERE id = ?').get(job.host_id) as HostRecord | undefined;
|
||||
if (!host) throw new Error('Host no longer exists');
|
||||
const jobConfig = normalizeJobConfig(JSON.parse(job.config), String(job.id));
|
||||
const hostSecret = decryptJson<HostSecret>(host.secret, this.appConfig.masterKey);
|
||||
const needsRemote = requiresRemoteConnection(jobConfig);
|
||||
const hostSecret = needsRemote ? decryptJson<HostSecret>(host.secret, this.appConfig.masterKey) : {};
|
||||
const stepSecrets = this.readStepSecrets(job, jobConfig);
|
||||
const redactions = [hostSecret.password, hostSecret.passphrase, ...Object.values(stepSecrets)].filter((value): value is string => Boolean(value));
|
||||
const staging = path.join(this.appConfig.backupDir, '.staging', String(run.id));
|
||||
|
||||
this.updateRun(run.id, "status = 'running', started_at = CURRENT_TIMESTAMP");
|
||||
this.log(run.id, `Connecting to ${host.name} (${host.hostname}:${host.port})`);
|
||||
let client: Client | undefined;
|
||||
let remoteDir: string | undefined;
|
||||
const ensureRemote = async (): Promise<{ client: Client; remoteDir: string }> => {
|
||||
if (client && remoteDir) return { client, remoteDir };
|
||||
this.log(run.id, `Connecting to ${host.name} (${host.hostname}:${host.port})`);
|
||||
const connected = await connect(host, hostSecret);
|
||||
try {
|
||||
const created = await exec(connected, `umask 077 && mktemp -d ${shellQuote(`/tmp/backup-manager-${run.id}-XXXXXX`)}`, 30);
|
||||
const createdDir = created.stdout.trim();
|
||||
if (!new RegExp(`^/tmp/backup-manager-${run.id}-[A-Za-z0-9]+$`).test(createdDir)) throw new Error('Remote host returned an invalid staging path');
|
||||
client = connected;
|
||||
remoteDir = createdDir;
|
||||
return { client, remoteDir };
|
||||
} catch (error) {
|
||||
connected.end();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
try {
|
||||
client = await connect(host, hostSecret);
|
||||
const created = await exec(client, `umask 077 && mktemp -d ${shellQuote(`/tmp/backup-manager-${run.id}-XXXXXX`)}`, 30);
|
||||
remoteDir = created.stdout.trim();
|
||||
if (!new RegExp(`^/tmp/backup-manager-${run.id}-[A-Za-z0-9]+$`).test(remoteDir)) throw new Error('Remote host returned an invalid staging path');
|
||||
const { artifacts, warningCount } = await this.runSteps(client, jobConfig, stepSecrets, remoteDir, (message) => this.log(run.id, redact(message, redactions)));
|
||||
await this.collectArtifacts(client, job, run, artifacts);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
fs.mkdirSync(staging, { recursive: true, mode: 0o700 });
|
||||
const { artifacts, warningCount } = await this.runSteps(jobConfig, stepSecrets, staging, ensureRemote, (message) => this.log(run.id, redact(message, redactions)));
|
||||
await this.collectArtifacts(client, job, run, artifacts, staging);
|
||||
const status = warningCount ? 'succeeded_with_warnings' : 'succeeded';
|
||||
this.updateRun(run.id, 'status = ?, finished_at = CURRENT_TIMESTAMP, error = NULL', status);
|
||||
this.log(run.id, `Completed with ${artifacts.length} artifact(s)${warningCount ? ` and ${warningCount} warning(s)` : ''}`);
|
||||
this.applyRetention(job);
|
||||
} catch (error) {
|
||||
fs.rmSync(path.join(this.appConfig.backupDir, '.staging', String(run.id)), { recursive: true, force: true });
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
const message = redact(error instanceof Error ? error.message : String(error), redactions);
|
||||
this.updateRun(run.id, "status = 'failed', finished_at = CURRENT_TIMESTAMP, error = ?", message);
|
||||
this.log(run.id, `Failed: ${message}`);
|
||||
@@ -56,15 +72,30 @@ export class BackupExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private async runSteps(client: Client, config: JobConfig, secrets: Record<string, string>, remoteDir: string, log: (message: string) => void): Promise<{ artifacts: RemoteArtifact[]; warningCount: number }> {
|
||||
private async runSteps(config: JobConfig, secrets: Record<string, string>, staging: string, ensureRemote: () => Promise<{ client: Client; remoteDir: string }>, log: (message: string) => void): Promise<{ artifacts: PendingArtifact[]; warningCount: number }> {
|
||||
const result = await executeSequentialSteps(config.steps, async (step, index) => {
|
||||
log(`Step ${index + 1}/${config.steps.length}: ${step.name}`);
|
||||
return this.runStep(client, step, secrets[step.id], remoteDir, index, log);
|
||||
if (step.type === 'managerConfig') return this.runStep(undefined, step, secrets[step.id], undefined, staging, index, log);
|
||||
const remote = await ensureRemote();
|
||||
return this.runStep(remote.client, step, secrets[step.id], remote.remoteDir, staging, index, log);
|
||||
}, (error) => log(`Warning: ${error instanceof Error ? error.message : String(error)}`));
|
||||
return { artifacts: result.values.flat(), warningCount: result.warningCount };
|
||||
}
|
||||
|
||||
private async runStep(client: Client, config: JobStep, password: string | undefined, remoteDir: string, stepIndex: number, log: (message: string) => void): Promise<RemoteArtifact[]> {
|
||||
private async runStep(client: Client | undefined, config: JobStep, password: string | undefined, remoteDir: string | undefined, staging: string, stepIndex: number, log: (message: string) => void): Promise<PendingArtifact[]> {
|
||||
if (config.type === 'managerConfig') {
|
||||
const name = config.outputName.endsWith('.json') ? config.outputName : `${config.outputName}.json`;
|
||||
const localPath = path.join(staging, name);
|
||||
log('Exporting Backup Manager configuration');
|
||||
try {
|
||||
fs.writeFileSync(localPath, `${JSON.stringify(createConfigurationExport(this.db, this.appConfig.masterKey), null, 2)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
} catch (error) {
|
||||
fs.rmSync(localPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
return [{ source: 'local', localPath, name }];
|
||||
}
|
||||
if (!client || !remoteDir) throw new Error('Remote connection is unavailable');
|
||||
switch (config.type) {
|
||||
case 'dockerCommand': {
|
||||
log(`Running Docker command in ${config.container}`);
|
||||
@@ -74,7 +105,7 @@ export class BackupExecutor {
|
||||
if (result.stdout.trim()) log(result.stdout.trim());
|
||||
if (result.stderr.trim()) log(result.stderr.trim());
|
||||
|
||||
const artifacts: RemoteArtifact[] = [];
|
||||
const artifacts: PendingArtifact[] = [];
|
||||
for (const [index, output] of config.outputs.entries()) {
|
||||
const copiedPath = `${remoteDir}/step-${stepIndex}-output-${index}`;
|
||||
await exec(client, `docker cp ${shellQuote(`${config.container}:${output.path}`)} ${shellQuote(copiedPath)}`, config.timeoutSeconds);
|
||||
@@ -82,10 +113,10 @@ export class BackupExecutor {
|
||||
const archiveName = output.name.endsWith('.tar.gz') ? output.name : `${output.name}.tar.gz`;
|
||||
const archivePath = `${remoteDir}/${archiveName}`;
|
||||
await exec(client, `tar -czf ${shellQuote(archivePath)} -C ${shellQuote(remoteDir)} -- ${shellQuote(`step-${stepIndex}-output-${index}`)}`, config.timeoutSeconds);
|
||||
artifacts.push({ remotePath: archivePath, name: archiveName });
|
||||
artifacts.push({ source: 'remote', remotePath: archivePath, name: archiveName });
|
||||
} else {
|
||||
await exec(client, `test -f ${shellQuote(copiedPath)}`, 30);
|
||||
artifacts.push({ remotePath: copiedPath, name: output.name });
|
||||
artifacts.push({ source: 'remote', remotePath: copiedPath, name: output.name });
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
@@ -97,17 +128,17 @@ export class BackupExecutor {
|
||||
const result = await exec(client, command, config.timeoutSeconds);
|
||||
if (result.stdout.trim()) log(result.stdout.trim());
|
||||
if (result.stderr.trim()) log(result.stderr.trim());
|
||||
const artifacts: RemoteArtifact[] = [];
|
||||
const artifacts: PendingArtifact[] = [];
|
||||
for (const [index, output] of config.outputs.entries()) {
|
||||
if (output.archive) {
|
||||
const archiveName = output.name.endsWith('.tar.gz') ? output.name : `${output.name}.tar.gz`;
|
||||
const archivePath = `${remoteDir}/${archiveName}`;
|
||||
await exec(client, `tar -czf ${shellQuote(archivePath)} -C ${shellQuote(path.posix.dirname(output.path))} -- ${shellQuote(path.posix.basename(output.path))}`, config.timeoutSeconds);
|
||||
artifacts.push({ remotePath: archivePath, name: archiveName });
|
||||
artifacts.push({ source: 'remote', remotePath: archivePath, name: archiveName });
|
||||
} else {
|
||||
const copiedPath = `${remoteDir}/step-${stepIndex}-output-${index}`;
|
||||
await exec(client, `test -f ${shellQuote(output.path)} && cp -- ${shellQuote(output.path)} ${shellQuote(copiedPath)}`, config.timeoutSeconds);
|
||||
artifacts.push({ remotePath: copiedPath, name: output.name });
|
||||
artifacts.push({ source: 'remote', remotePath: copiedPath, name: output.name });
|
||||
}
|
||||
}
|
||||
return artifacts;
|
||||
@@ -117,7 +148,7 @@ export class BackupExecutor {
|
||||
const name = config.outputName.endsWith('.tar.gz') ? config.outputName : `${config.outputName}.tar.gz`;
|
||||
const target = `${remoteDir}/${name}`;
|
||||
await exec(client, `tar -czf ${shellQuote(target)} -C ${shellQuote(path.posix.dirname(config.path))} -- ${shellQuote(path.posix.basename(config.path))}`, config.timeoutSeconds);
|
||||
return [{ remotePath: target, name }];
|
||||
return [{ source: 'remote', remotePath: target, name }];
|
||||
}
|
||||
case 'postgres': {
|
||||
if (!password) throw new Error('PostgreSQL password is missing');
|
||||
@@ -128,7 +159,7 @@ export class BackupExecutor {
|
||||
const raw = `${remoteDir}/dump.sql`;
|
||||
const command = `PGPASSFILE=${shellQuote(`${remoteDir}/pgpass`)} pg_dump --host=${shellQuote(config.databaseHost)} --port=${shellQuote(String(config.databasePort))} --username=${shellQuote(config.username)} --no-password --dbname=${shellQuote(config.database)} > ${shellQuote(raw)} && gzip -c ${shellQuote(raw)} > ${shellQuote(target)} && rm ${shellQuote(raw)}`;
|
||||
await exec(client, command, config.timeoutSeconds);
|
||||
return [{ remotePath: target, name }];
|
||||
return [{ source: 'remote', remotePath: target, name }];
|
||||
}
|
||||
case 'mysql': {
|
||||
if (!password) throw new Error('MySQL password is missing');
|
||||
@@ -139,23 +170,27 @@ export class BackupExecutor {
|
||||
const raw = `${remoteDir}/dump.sql`;
|
||||
const command = `mysqldump --defaults-extra-file=${shellQuote(`${remoteDir}/my.cnf`)} --host=${shellQuote(config.databaseHost)} --port=${shellQuote(String(config.databasePort))} --user=${shellQuote(config.username)} --databases ${shellQuote(config.database)} > ${shellQuote(raw)} && gzip -c ${shellQuote(raw)} > ${shellQuote(target)} && rm ${shellQuote(raw)}`;
|
||||
await exec(client, command, config.timeoutSeconds);
|
||||
return [{ remotePath: target, name }];
|
||||
return [{ source: 'remote', remotePath: target, name }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async collectArtifacts(client: Client, job: JobRecord, run: RunRecord, artifacts: RemoteArtifact[]): Promise<void> {
|
||||
const staging = path.join(this.appConfig.backupDir, '.staging', String(run.id));
|
||||
private async collectArtifacts(client: Client | undefined, job: JobRecord, run: RunRecord, artifacts: PendingArtifact[], staging: string): Promise<void> {
|
||||
const finalDir = path.join(this.appConfig.backupDir, String(job.id), String(run.id));
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
fs.mkdirSync(staging, { recursive: true, mode: 0o700 });
|
||||
const sftp = await getSftp(client);
|
||||
const records: Array<{ name: string; relativePath: string; size: number; checksum: string }> = [];
|
||||
let sftp: SFTPWrapper | undefined;
|
||||
try {
|
||||
for (const artifact of artifacts) {
|
||||
this.log(run.id, `Downloading ${artifact.name}`);
|
||||
const localPath = path.join(staging, artifact.name);
|
||||
await download(sftp, artifact.remotePath, localPath);
|
||||
let localPath: string;
|
||||
if (artifact.source === 'remote') {
|
||||
if (!client) throw new Error('Remote connection is unavailable');
|
||||
sftp ??= await getSftp(client);
|
||||
this.log(run.id, `Downloading ${artifact.name}`);
|
||||
localPath = path.join(staging, artifact.name);
|
||||
await download(sftp, artifact.remotePath, localPath);
|
||||
} else {
|
||||
localPath = artifact.localPath;
|
||||
}
|
||||
const { size, checksum } = await hashFile(localPath);
|
||||
records.push({
|
||||
name: artifact.name,
|
||||
@@ -165,12 +200,17 @@ export class BackupExecutor {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
sftp.end();
|
||||
sftp?.end();
|
||||
}
|
||||
fs.mkdirSync(path.dirname(finalDir), { recursive: true, mode: 0o700 });
|
||||
fs.renameSync(staging, finalDir);
|
||||
const insert = this.db.prepare('INSERT INTO artifacts (run_id, name, path, size, checksum) VALUES (?, ?, ?, ?, ?)');
|
||||
this.db.transaction(() => records.forEach((record) => insert.run(run.id, record.name, record.relativePath, record.size, record.checksum)))();
|
||||
try {
|
||||
this.db.transaction(() => records.forEach((record) => insert.run(run.id, record.name, record.relativePath, record.size, record.checksum)))();
|
||||
} catch (error) {
|
||||
fs.rmSync(finalDir, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private applyRetention(job: JobRecord): void {
|
||||
@@ -188,12 +228,7 @@ export class BackupExecutor {
|
||||
|
||||
private readStepSecrets(job: JobRecord, config: JobConfig): Record<string, string> {
|
||||
if (!job.secret) return {};
|
||||
const decrypted = decryptJson<Record<string, string>>(job.secret, this.appConfig.masterKey);
|
||||
if (typeof decrypted.password === 'string') {
|
||||
const databaseStep = config.steps.find((step) => step.type === 'postgres' || step.type === 'mysql');
|
||||
return databaseStep ? { [databaseStep.id]: decrypted.password } : {};
|
||||
}
|
||||
return decrypted;
|
||||
return normalizeStepSecrets(decryptJson<Record<string, string>>(job.secret, this.appConfig.masterKey), config);
|
||||
}
|
||||
|
||||
private log(runId: number, message: string): void {
|
||||
@@ -206,6 +241,10 @@ export class BackupExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
export function requiresRemoteConnection(config: JobConfig): boolean {
|
||||
return config.steps.some((step) => step.type !== 'managerConfig');
|
||||
}
|
||||
|
||||
async function hashFile(filePath: string): Promise<{ size: number; checksum: string }> {
|
||||
const hash = createHash('sha256');
|
||||
let size = 0;
|
||||
|
||||
29
src/job-credentials.test.ts
Normal file
29
src/job-credentials.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeStepSecrets, resolveUpdatedStepSecrets } from './job-credentials.js';
|
||||
import type { JobUpdateInput } from './schemas.js';
|
||||
|
||||
const databaseStep = { id: 'database-1', name: 'Database', continueOnError: false, type: 'postgres' as const, database: 'app', username: 'postgres', databaseHost: 'localhost', databasePort: 5432, outputName: 'app', timeoutSeconds: 300 };
|
||||
const base: JobUpdateInput = { name: 'backup', hostId: 1, config: { steps: [databaseStep] }, stepSecrets: {}, timezone: 'UTC', retentionCount: 10 };
|
||||
|
||||
describe('job credential updates', () => {
|
||||
it('maps a legacy database password to its normalized step ID', () => {
|
||||
expect(normalizeStepSecrets({ password: 'existing' }, base.config)).toEqual({ 'database-1': 'existing' });
|
||||
});
|
||||
|
||||
it('preserves an existing database password', () => {
|
||||
expect(resolveUpdatedStepSecrets({ 'database-1': 'existing' }, base)).toEqual({ 'database-1': 'existing' });
|
||||
});
|
||||
|
||||
it('replaces a database password when supplied', () => {
|
||||
expect(resolveUpdatedStepSecrets({ 'database-1': 'existing' }, { ...base, stepSecrets: { 'database-1': 'replacement' } })).toEqual({ 'database-1': 'replacement' });
|
||||
});
|
||||
|
||||
it('requires a password for a newly added database step', () => {
|
||||
expect(() => resolveUpdatedStepSecrets({}, base)).toThrow('Password is required for step 1');
|
||||
});
|
||||
|
||||
it('drops passwords for removed database steps', () => {
|
||||
const directory = { id: 'directory-1', name: 'Files', continueOnError: false, type: 'directory' as const, path: '/srv/files', outputName: 'files', timeoutSeconds: 300 };
|
||||
expect(resolveUpdatedStepSecrets({ 'database-1': 'existing' }, { ...base, config: { steps: [directory] } })).toEqual({});
|
||||
});
|
||||
});
|
||||
18
src/job-credentials.ts
Normal file
18
src/job-credentials.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { JobConfig, JobUpdateInput } from './schemas.js';
|
||||
|
||||
export function normalizeStepSecrets(secrets: Record<string, string>, config: JobConfig): Record<string, string> {
|
||||
if (typeof secrets.password !== 'string') return secrets;
|
||||
const databaseStep = config.steps.find((step) => step.type === 'postgres' || step.type === 'mysql');
|
||||
return databaseStep ? { [databaseStep.id]: secrets.password } : {};
|
||||
}
|
||||
|
||||
export function resolveUpdatedStepSecrets(currentSecrets: Record<string, string>, input: JobUpdateInput): Record<string, string> {
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const [index, step] of input.config.steps.entries()) {
|
||||
if (step.type !== 'postgres' && step.type !== 'mysql') continue;
|
||||
const secret = input.stepSecrets[step.id] || currentSecrets[step.id];
|
||||
if (!secret) throw new Error(`Password is required for step ${index + 1}`);
|
||||
secrets[step.id] = secret;
|
||||
}
|
||||
return secrets;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
|
||||
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, jobUpdateInputSchema, normalizeJobConfig } from './schemas.js';
|
||||
|
||||
describe('host validation', () => {
|
||||
it('requires the selected authentication credential', () => {
|
||||
@@ -38,6 +38,13 @@ describe('job validation', () => {
|
||||
expect(jobInputSchema.parse(input).config.steps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('accepts a Backup Manager configuration export as an artifact-producing step', () => {
|
||||
const input = { ...dockerJob, config: { steps: [
|
||||
{ id: 'manager-config', name: 'Manager configuration', continueOnError: false, type: 'managerConfig', outputName: 'backup-manager-config' },
|
||||
] } };
|
||||
expect(jobInputSchema.parse(input).config.steps[0]).toMatchObject({ type: 'managerConfig', outputName: 'backup-manager-config' });
|
||||
});
|
||||
|
||||
it('rejects invalid cron expressions', () => {
|
||||
expect(jobInputSchema.safeParse({ ...dockerJob, schedule: 'not a cron' }).success).toBe(false);
|
||||
});
|
||||
@@ -58,6 +65,14 @@ describe('job validation', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows an update to preserve a stored database password', () => {
|
||||
const result = jobUpdateInputSchema.safeParse({
|
||||
...dockerJob,
|
||||
config: { steps: [{ id: 'database-1', name: 'Database', continueOnError: false, type: 'postgres', database: 'app', username: 'postgres', databaseHost: 'localhost', databasePort: 5432, outputName: 'app', timeoutSeconds: 300 }] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects jobs with only commands that produce no artifacts', () => {
|
||||
const result = jobInputSchema.safeParse({ ...dockerJob, config: { steps: [{ ...dockerStep, outputs: [] }] } });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
@@ -96,7 +96,13 @@ export const mysqlStepSchema = z.object({
|
||||
timeoutSeconds: z.number().int().min(1).max(86400).default(3600),
|
||||
});
|
||||
|
||||
export const jobStepSchema = z.discriminatedUnion('type', [dockerStepSchema, remoteCommandStepSchema, directoryStepSchema, postgresStepSchema, mysqlStepSchema]);
|
||||
export const managerConfigStepSchema = z.object({
|
||||
...stepFields,
|
||||
type: z.literal('managerConfig'),
|
||||
outputName: safeName,
|
||||
});
|
||||
|
||||
export const jobStepSchema = z.discriminatedUnion('type', [dockerStepSchema, remoteCommandStepSchema, directoryStepSchema, postgresStepSchema, mysqlStepSchema, managerConfigStepSchema]);
|
||||
export type JobStep = z.infer<typeof jobStepSchema>;
|
||||
|
||||
export const jobConfigSchema = z.object({
|
||||
@@ -147,16 +153,36 @@ export const jobInputSchema = z.object({
|
||||
}
|
||||
});
|
||||
|
||||
export const jobUpdateInputSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
hostId: z.number().int().positive(),
|
||||
config: jobConfigSchema,
|
||||
stepSecrets: z.record(z.string(), z.string()).default({}),
|
||||
schedule: z.string().trim().optional(),
|
||||
timezone: z.string().trim().default('UTC'),
|
||||
retentionCount: z.number().int().min(1).max(1000).default(10),
|
||||
}).superRefine((value, context) => {
|
||||
if (value.schedule) {
|
||||
try {
|
||||
CronExpressionParser.parse(value.schedule, { tz: value.timezone });
|
||||
} catch {
|
||||
context.addIssue({ code: 'custom', path: ['schedule'], message: 'Invalid cron expression or timezone' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type HostInput = z.infer<typeof hostInputSchema>;
|
||||
export type HostUpdateInput = z.infer<typeof hostUpdateInputSchema>;
|
||||
export type JobInput = z.infer<typeof jobInputSchema>;
|
||||
export type JobUpdateInput = z.infer<typeof jobUpdateInputSchema>;
|
||||
|
||||
function finalArtifactNames(step: JobStep): string[] {
|
||||
if (step.type === 'managerConfig') return [step.outputName.endsWith('.json') ? step.outputName : `${step.outputName}.json`];
|
||||
if (step.type === 'directory') return [step.outputName.endsWith('.tar.gz') ? step.outputName : `${step.outputName}.tar.gz`];
|
||||
if (step.type === 'postgres' || step.type === 'mysql') return [step.outputName.endsWith('.sql.gz') ? step.outputName : `${step.outputName}.sql.gz`];
|
||||
return step.outputs.map((output) => output.archive && !output.name.endsWith('.tar.gz') ? `${output.name}.tar.gz` : output.name);
|
||||
}
|
||||
|
||||
function defaultStepName(type: string): string {
|
||||
return { dockerCommand: 'Docker command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump' }[type] ?? 'Backup step';
|
||||
return { dockerCommand: 'Docker command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump', managerConfig: 'Backup Manager configuration' }[type] ?? 'Backup step';
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import fastifyStatic from '@fastify/static';
|
||||
import Fastify from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { loadConfig } from './config.js';
|
||||
import { restoreConfiguration } from './configuration-restore.js';
|
||||
import { createSession, decryptJson, encryptJson, secureEqual, verifySession } from './crypto.js';
|
||||
import { openDatabase, type HostRecord, type JobRecord } from './db.js';
|
||||
import { resolveUpdatedHostSecret } from './host-credentials.js';
|
||||
import { normalizeStepSecrets, resolveUpdatedStepSecrets } from './job-credentials.js';
|
||||
import { type NotificationSettings } from './notifications.js';
|
||||
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, normalizeJobConfig } from './schemas.js';
|
||||
import { hostInputSchema, hostUpdateInputSchema, jobInputSchema, jobUpdateInputSchema, normalizeJobConfig } from './schemas.js';
|
||||
import { BackupService, nextRun } from './service.js';
|
||||
import { connect, probeFingerprint, type HostSecret } from './ssh.js';
|
||||
|
||||
@@ -112,11 +114,15 @@ app.delete('/api/hosts/:id', async (request, reply) => {
|
||||
|
||||
app.get('/api/jobs', async () => {
|
||||
const rows = db.prepare('SELECT jobs.*, hosts.name AS host_name FROM jobs JOIN hosts ON hosts.id = jobs.host_id ORDER BY jobs.name').all() as Array<JobRecord & { host_name: string }>;
|
||||
return rows.map((row) => ({
|
||||
id: row.id, name: row.name, hostId: row.host_id, hostName: row.host_name, config: normalizeJobConfig(JSON.parse(row.config), String(row.id)),
|
||||
schedule: row.schedule, timezone: row.timezone, enabled: Boolean(row.enabled), retentionCount: row.retention_count,
|
||||
nextRunAt: row.next_run_at, hasDatabasePassword: Boolean(row.secret), createdAt: row.created_at,
|
||||
}));
|
||||
return rows.map((row) => {
|
||||
const jobConfig = normalizeJobConfig(JSON.parse(row.config), String(row.id));
|
||||
const stepSecrets = row.secret ? normalizeStepSecrets(decryptJson<Record<string, string>>(row.secret, config.masterKey), jobConfig) : {};
|
||||
return {
|
||||
id: row.id, name: row.name, hostId: row.host_id, hostName: row.host_name, config: jobConfig,
|
||||
schedule: row.schedule, timezone: row.timezone, enabled: Boolean(row.enabled), retentionCount: row.retention_count,
|
||||
nextRunAt: row.next_run_at, credentialStepIds: Object.keys(stepSecrets), createdAt: row.created_at,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/jobs', async (request, reply) => {
|
||||
@@ -127,6 +133,27 @@ app.post('/api/jobs', async (request, reply) => {
|
||||
return reply.code(201).send({ id: Number(result.lastInsertRowid) });
|
||||
});
|
||||
|
||||
app.put('/api/jobs/:id', async (request, reply) => {
|
||||
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
|
||||
const input = jobUpdateInputSchema.parse(request.body);
|
||||
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(id) as JobRecord | undefined;
|
||||
if (!job) return reply.code(404).send({ error: 'Job not found' });
|
||||
const active = db.prepare("SELECT id FROM runs WHERE job_id = ? AND status IN ('queued', 'running')").get(id);
|
||||
if (active) return reply.code(409).send({ error: 'Cannot edit an active job' });
|
||||
const currentConfig = normalizeJobConfig(JSON.parse(job.config), String(job.id));
|
||||
const currentSecrets = job.secret ? normalizeStepSecrets(decryptJson<Record<string, string>>(job.secret, config.masterKey), currentConfig) : {};
|
||||
let stepSecrets: Record<string, string>;
|
||||
try {
|
||||
stepSecrets = resolveUpdatedStepSecrets(currentSecrets, input);
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
const secret = Object.keys(stepSecrets).length ? encryptJson(stepSecrets, config.masterKey) : null;
|
||||
db.prepare('UPDATE jobs SET name = ?, host_id = ?, config = ?, secret = ?, schedule = ?, timezone = ?, retention_count = ?, next_run_at = ? WHERE id = ?')
|
||||
.run(input.name, input.hostId, JSON.stringify(input.config), secret, input.schedule || null, input.timezone, input.retentionCount, nextRun(input.schedule || null, input.timezone), id);
|
||||
return { updated: true };
|
||||
});
|
||||
|
||||
app.post('/api/jobs/:id/run', async (request, reply) => {
|
||||
const { id } = z.object({ id: z.coerce.number().int().positive() }).parse(request.params);
|
||||
if (!db.prepare('SELECT id FROM jobs WHERE id = ?').get(id)) return reply.code(404).send({ error: 'Job not found' });
|
||||
@@ -201,6 +228,11 @@ app.put('/api/settings/notifications', async (request) => {
|
||||
return { saved: true };
|
||||
});
|
||||
|
||||
app.put('/api/settings/configuration', { bodyLimit: 10 * 1024 * 1024 }, async (request) => ({
|
||||
restored: true,
|
||||
...restoreConfiguration(db, request.body, config.masterKey, nextRun),
|
||||
}));
|
||||
|
||||
app.setErrorHandler((error: Error & { code?: string; statusCode?: number }, _request, reply) => {
|
||||
if (error instanceof z.ZodError) return reply.code(400).send({ error: 'Validation failed', details: z.flattenError(error).fieldErrors });
|
||||
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') return reply.code(409).send({ error: 'A record with that name already exists' });
|
||||
|
||||
132
web/src/main.tsx
132
web/src/main.tsx
@@ -5,14 +5,20 @@ import './styles.css';
|
||||
|
||||
type Tab = 'overview' | 'hosts' | 'jobs' | 'settings';
|
||||
type Host = { id: number; name: string; hostname: string; port: number; username: string; fingerprint: string; authType: 'password' | 'privateKey' };
|
||||
type StepType = 'dockerCommand' | 'remoteCommand' | 'directory' | 'postgres' | 'mysql';
|
||||
type StepType = 'dockerCommand' | 'remoteCommand' | 'directory' | 'postgres' | 'mysql' | 'managerConfig';
|
||||
type StepOutput = { path: string; name: string; archive: boolean };
|
||||
type PersistedStep = {
|
||||
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds?: number;
|
||||
executable?: string; arguments?: string[]; workingDirectory?: string; container?: string; user?: string; outputs?: StepOutput[];
|
||||
path?: string; outputName?: string; database?: string; username?: string; databaseHost?: string; databasePort?: number;
|
||||
};
|
||||
type StepDraft = {
|
||||
id: string; name: string; type: StepType; continueOnError: boolean; timeoutSeconds: string;
|
||||
executable: string; arguments: string; workingDirectory: string; container: string; dockerUser: string;
|
||||
collectOutput: boolean; outputPath: string; outputName: string; archive: boolean; directoryPath: string;
|
||||
database: string; databaseUsername: string; databaseHost: string; databasePort: string; databasePassword: string;
|
||||
additionalOutputs: StepOutput[]; database: string; databaseUsername: string; databaseHost: string; databasePort: string; databasePassword: string; hasDatabasePassword: boolean;
|
||||
};
|
||||
type Job = { id: number; name: string; hostName: string; config: { steps: Array<{ type: string }> }; schedule?: string; nextRunAt?: string; retentionCount: number };
|
||||
type Job = { id: number; name: string; hostId: number; hostName: string; config: { steps: PersistedStep[] }; schedule?: string | null; timezone: string; nextRunAt?: string; retentionCount: number; credentialStepIds: string[] };
|
||||
type Run = { id: number; jobName: string; status: string; trigger: string; createdAt: string; startedAt?: string; finishedAt?: string; error?: string; artifactCount: number };
|
||||
type RunDetail = { run: { id: number; status: string; log: string; error?: string }; artifacts: Array<{ id: number; name: string; size: number; checksum: string }> };
|
||||
|
||||
@@ -163,6 +169,7 @@ function Jobs() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [addType, setAddType] = useState<StepType>('dockerCommand');
|
||||
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||
const [editingJob, setEditingJob] = useState<Job>();
|
||||
const [message, setMessage] = useState('');
|
||||
async function load() { const [jobData, hostData] = await Promise.all([api<Job[]>('/api/jobs'), api<Host[]>('/api/hosts')]); setJobs(jobData); setHosts(hostData); }
|
||||
useEffect(() => { void load(); }, []);
|
||||
@@ -179,33 +186,44 @@ function Jobs() {
|
||||
const stepSecrets: Record<string, string> = {};
|
||||
const configuredSteps = steps.map((step) => {
|
||||
const base = { id: step.id, name: step.name, type: step.type, continueOnError: step.continueOnError, timeoutSeconds: Number(step.timeoutSeconds) };
|
||||
const outputs = step.collectOutput ? [{ path: step.outputPath, name: step.outputName, archive: step.archive }] : [];
|
||||
const outputs = step.collectOutput ? [{ path: step.outputPath, name: step.outputName, archive: step.archive }, ...step.additionalOutputs] : [];
|
||||
if (step.type === 'dockerCommand') return { ...base, container: step.container, executable: step.executable, arguments: parseArguments(step.arguments), user: step.dockerUser || undefined, workingDirectory: step.workingDirectory || undefined, outputs };
|
||||
if (step.type === 'remoteCommand') return { ...base, executable: step.executable, arguments: parseArguments(step.arguments), workingDirectory: step.workingDirectory || undefined, outputs };
|
||||
if (step.type === 'managerConfig') return { ...base, outputName: step.outputName };
|
||||
if (step.type === 'directory') return { ...base, path: step.directoryPath, outputName: step.outputName };
|
||||
if (step.databasePassword) stepSecrets[step.id] = step.databasePassword;
|
||||
return { ...base, database: step.database, username: step.databaseUsername, databaseHost: step.databaseHost, databasePort: Number(step.databasePort), outputName: step.outputName };
|
||||
});
|
||||
try {
|
||||
await api('/api/jobs', { method: 'POST', body: JSON.stringify({
|
||||
await api(editingJob ? `/api/jobs/${editingJob.id}` : '/api/jobs', { method: editingJob ? 'PUT' : 'POST', body: JSON.stringify({
|
||||
name: data.get('name'), hostId: Number(data.get('hostId')), config: { steps: configuredSteps }, stepSecrets,
|
||||
schedule: data.get('schedule') || undefined,
|
||||
timezone: data.get('timezone'), enabled: true, retentionCount: Number(data.get('retentionCount')),
|
||||
}) });
|
||||
form.reset(); setSteps([]); setMessage('Job created.'); await load();
|
||||
form.reset(); setSteps([]); setEditingJob(undefined); setMessage(editingJob ? 'Job updated.' : 'Job created.'); await load();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
function edit(job: Job) {
|
||||
setEditingJob(job);
|
||||
setSteps(job.config.steps.map((step) => draftFromStep(step, job.credentialStepIds.includes(step.id))));
|
||||
setMessage(`Editing ${job.name}. Stored database passwords remain hidden.`);
|
||||
}
|
||||
function cancelEdit() {
|
||||
setEditingJob(undefined);
|
||||
setSteps([]);
|
||||
setMessage('');
|
||||
}
|
||||
return <>
|
||||
<PageTitle eyebrow="AUTOMATION / JOBS" title="Backup definitions" detail="Ordered remote operations with explicit outputs and failure policy." />
|
||||
<div className="split jobs-split"><section className="panel form-panel"><div className="panel-head"><h2>New job</h2><span>runs remotely</span></div>
|
||||
{hosts.length === 0 ? <Empty text="Add an SSH host before creating a job." /> : <form onSubmit={submit}>
|
||||
<div className="field-row"><label>Job name<input name="name" required placeholder="immich-db" /></label><label>Remote host<select name="hostId" required>{hosts.map((host) => <option value={host.id} key={host.id}>{host.name}</option>)}</select></label></div>
|
||||
<div className="split jobs-split"><section className="panel form-panel"><div className="panel-head"><h2>{editingJob ? `Edit ${editingJob.name}` : 'New job'}</h2><span>{editingJob ? 'run history preserved' : 'runs remotely'}</span></div>
|
||||
{hosts.length === 0 ? <Empty text="Add an SSH host before creating a job." /> : <form key={editingJob?.id ?? 'new'} onSubmit={submit}>
|
||||
<div className="field-row"><label>Job name<input name="name" required placeholder="immich-db" defaultValue={editingJob?.name} /></label><label>Remote host<select name="hostId" required defaultValue={editingJob?.hostId}>{hosts.map((host) => <option value={host.id} key={host.id}>{host.name}</option>)}</select></label></div>
|
||||
<div className="step-list">{steps.map((step, index) => <StepEditor step={step} index={index} count={steps.length} update={updateStep} move={moveStep} remove={(id) => setSteps((current) => current.filter((item) => item.id !== id))} key={step.id} />)}{steps.length === 0 && <div className="empty step-empty">Select a type below to add the first step.</div>}</div>
|
||||
<div className="add-step"><select value={addType} onChange={(event) => setAddType(event.target.value as StepType)}><option value="dockerCommand">Docker command</option><option value="remoteCommand">Remote command</option><option value="directory">Directory archive</option><option value="postgres">PostgreSQL dump</option><option value="mysql">MySQL dump</option></select><button type="button" onClick={() => setSteps((current) => [...current, createStep(addType)])}>+ Add step</button></div>
|
||||
<fieldset><legend>Job policy</legend><div className="field-row"><label>Cron schedule <small>optional</small><input name="schedule" placeholder="0 2 * * *" /></label><label>Timezone<input name="timezone" required defaultValue="UTC" /></label></div><label>Successful runs to retain<input name="retentionCount" type="number" min="1" defaultValue="10" required /></label></fieldset>
|
||||
{message && <p className="form-message">{message}</p>}<button type="submit" disabled={steps.length === 0}>Create backup job</button>
|
||||
<div className="add-step"><select value={addType} onChange={(event) => setAddType(event.target.value as StepType)}><option value="dockerCommand">Docker command</option><option value="remoteCommand">Remote command</option><option value="directory">Directory archive</option><option value="postgres">PostgreSQL dump</option><option value="mysql">MySQL dump</option><option value="managerConfig">Backup Manager configuration</option></select><button type="button" onClick={() => setSteps((current) => [...current, createStep(addType)])}>+ Add step</button></div>
|
||||
<fieldset><legend>Job policy</legend><div className="field-row"><label>Cron schedule <small>optional</small><input name="schedule" placeholder="0 2 * * *" defaultValue={editingJob?.schedule ?? ''} /></label><label>Timezone<input name="timezone" required defaultValue={editingJob?.timezone ?? 'UTC'} /></label></div><label>Successful runs to retain<input name="retentionCount" type="number" min="1" defaultValue={editingJob?.retentionCount ?? 10} required /></label></fieldset>
|
||||
{message && <p className="form-message">{message}</p>}<div className="form-actions"><button type="submit" disabled={steps.length === 0}>{editingJob ? 'Update job' : 'Create backup job'}</button>{editingJob && <button type="button" className="secondary" onClick={cancelEdit}>Cancel editing</button>}</div>
|
||||
</form>}
|
||||
</section><section className="panel"><div className="panel-head"><h2>Configured jobs</h2><span>{jobs.length} total</span></div>{jobs.map((job) => <article className="job-card" key={job.id}><div><span className="job-type">{job.config.steps.length} step{job.config.steps.length === 1 ? '' : 's'}</span><h3>{job.name}</h3><p>{job.hostName} · keep {job.retentionCount}</p><small>{job.schedule ? `${job.schedule} · next ${formatDate(job.nextRunAt)}` : 'MANUAL ONLY'}</small></div><div className="card-actions"><button className="run" onClick={() => void api<{ runId: number }>(`/api/jobs/${job.id}/run`, { method: 'POST' }).then((value) => setMessage(`Run #${value.runId} queued`)).catch((error) => setMessage(error.message))}>Run now</button><button className="danger" onClick={() => confirm(`Delete ${job.name} and its run history?`) && void api(`/api/jobs/${job.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{jobs.length === 0 && <Empty text="No backup jobs defined." />}</section></div>
|
||||
</section><section className="panel"><div className="panel-head"><h2>Configured jobs</h2><span>{jobs.length} total</span></div>{jobs.map((job) => <article className="job-card" key={job.id}><div><span className="job-type">{job.config.steps.length} step{job.config.steps.length === 1 ? '' : 's'}</span><h3>{job.name}</h3><p>{job.hostName} · keep {job.retentionCount}</p><small>{job.schedule ? `${job.schedule} · next ${formatDate(job.nextRunAt)}` : 'MANUAL ONLY'}</small></div><div className="card-actions"><button onClick={() => edit(job)}>Edit</button><button className="run" onClick={() => void api<{ runId: number }>(`/api/jobs/${job.id}/run`, { method: 'POST' }).then((value) => setMessage(`Run #${value.runId} queued`)).catch((error) => setMessage(error.message))}>Run now</button><button className="danger" onClick={() => confirm(`Delete ${job.name} and its run history?`) && void api(`/api/jobs/${job.id}`, { method: 'DELETE' }).then(load).catch((error) => setMessage(error.message))}>Delete</button></div></article>)}{jobs.length === 0 && <Empty text="No backup jobs defined." />}</section></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -216,47 +234,89 @@ function StepEditor({ step, index, count, update, move, remove }: { step: StepDr
|
||||
{step.type === 'dockerCommand' && <><div className="field-row"><label>Container<input value={step.container} onChange={(event) => set({ container: event.target.value })} required placeholder="immich_server" /></label><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/app/backup" /></label></div><div className="field-row"><label>Container user <small>optional</small><input value={step.dockerUser} onChange={(event) => set({ dockerUser: event.target.value })} /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/app" /></label></div></>}
|
||||
{step.type === 'remoteCommand' && <><label>Executable<input value={step.executable} onChange={(event) => set({ executable: event.target.value })} required placeholder="/usr/local/bin/prepare-backup" /></label><label>Working directory <small>optional</small><input value={step.workingDirectory} onChange={(event) => set({ workingDirectory: event.target.value })} placeholder="/srv/app" /></label></>}
|
||||
{command && <><label>Arguments <small>one argument per line</small><textarea value={step.arguments} onChange={(event) => set({ arguments: event.target.value })} rows={3} placeholder={'--output\n/tmp/export'} /></label><label className="check"><input type="checkbox" checked={step.collectOutput} onChange={(event) => set({ collectOutput: event.target.checked })} /> Collect an output from this command</label>{step.collectOutput && <><div className="field-row"><label>{step.type === 'dockerCommand' ? 'Container' : 'Remote'} output path<input value={step.outputPath} onChange={(event) => set({ outputPath: event.target.value })} required placeholder="/tmp/export" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="export" /></label></div><label className="check"><input type="checkbox" checked={step.archive} onChange={(event) => set({ archive: event.target.checked })} /> Archive output as tar.gz</label></>}</>}
|
||||
{step.type === 'managerConfig' && <label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="backup-manager-config" /><small>Includes encrypted credential envelopes. Keep the current MASTER_KEY separately.</small></label>}
|
||||
{step.type === 'directory' && <div className="field-row"><label>Absolute remote path<input value={step.directoryPath} onChange={(event) => set({ directoryPath: event.target.value })} required placeholder="/srv/documents" /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="documents" /></label></div>}
|
||||
{(step.type === 'postgres' || step.type === 'mysql') && <><div className="field-row"><label>Database host<input value={step.databaseHost} onChange={(event) => set({ databaseHost: event.target.value })} required /></label><label>Port<input value={step.databasePort} onChange={(event) => set({ databasePort: event.target.value })} type="number" required /></label></div><div className="field-row"><label>Database<input value={step.database} onChange={(event) => set({ database: event.target.value })} required /></label><label>Username<input value={step.databaseUsername} onChange={(event) => set({ databaseUsername: event.target.value })} required /></label></div><div className="field-row"><label>Password<input value={step.databasePassword} onChange={(event) => set({ databasePassword: event.target.value })} type="password" required /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="database" /></label></div></>}
|
||||
<div className="field-row step-policy"><label>Timeout (seconds)<input value={step.timeoutSeconds} onChange={(event) => set({ timeoutSeconds: event.target.value })} type="number" min="1" required /></label><label className="check"><input type="checkbox" checked={step.continueOnError} onChange={(event) => set({ continueOnError: event.target.checked })} /> Continue if this step fails</label></div>
|
||||
{(step.type === 'postgres' || step.type === 'mysql') && <><div className="field-row"><label>Database host<input value={step.databaseHost} onChange={(event) => set({ databaseHost: event.target.value })} required /></label><label>Port<input value={step.databasePort} onChange={(event) => set({ databasePort: event.target.value })} type="number" required /></label></div><div className="field-row"><label>Database<input value={step.database} onChange={(event) => set({ database: event.target.value })} required /></label><label>Username<input value={step.databaseUsername} onChange={(event) => set({ databaseUsername: event.target.value })} required /></label></div><div className="field-row"><label>Password {step.hasDatabasePassword && <small>leave blank to keep current password</small>}<input value={step.databasePassword} onChange={(event) => set({ databasePassword: event.target.value })} type="password" required={!step.hasDatabasePassword} /></label><label>Artifact name<input value={step.outputName} onChange={(event) => set({ outputName: event.target.value })} required placeholder="database" /></label></div></>}
|
||||
{step.type === 'managerConfig' ? <label className="check"><input type="checkbox" checked={step.continueOnError} onChange={(event) => set({ continueOnError: event.target.checked })} /> Continue if this step fails</label> : <div className="field-row step-policy"><label>Timeout (seconds)<input value={step.timeoutSeconds} onChange={(event) => set({ timeoutSeconds: event.target.value })} type="number" min="1" required /></label><label className="check"><input type="checkbox" checked={step.continueOnError} onChange={(event) => set({ continueOnError: event.target.checked })} /> Continue if this step fails</label></div>}
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
function createStep(type: StepType): StepDraft {
|
||||
const id = crypto.randomUUID();
|
||||
return { id, type, name: stepTypeLabel(type), continueOnError: false, timeoutSeconds: '3600', executable: '', arguments: '', workingDirectory: '', container: '', dockerUser: '', collectOutput: type === 'dockerCommand', outputPath: '', outputName: '', archive: true, directoryPath: '', database: '', databaseUsername: '', databaseHost: 'localhost', databasePort: type === 'mysql' ? '3306' : '5432', databasePassword: '' };
|
||||
return { id, type, name: stepTypeLabel(type), continueOnError: false, timeoutSeconds: '3600', executable: '', arguments: '', workingDirectory: '', container: '', dockerUser: '', collectOutput: type === 'dockerCommand', outputPath: '', outputName: '', archive: true, directoryPath: '', additionalOutputs: [], database: '', databaseUsername: '', databaseHost: 'localhost', databasePort: type === 'mysql' ? '3306' : '5432', databasePassword: '', hasDatabasePassword: false };
|
||||
}
|
||||
|
||||
function stepTypeLabel(type: StepType): string { return { dockerCommand: 'Docker command', remoteCommand: 'Remote command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump' }[type]; }
|
||||
function draftFromStep(step: PersistedStep, hasDatabasePassword: boolean): StepDraft {
|
||||
const output = step.outputs?.[0];
|
||||
return {
|
||||
...createStep(step.type), id: step.id, name: step.name, continueOnError: step.continueOnError, timeoutSeconds: String(step.timeoutSeconds ?? 3600),
|
||||
executable: step.executable ?? '', arguments: step.arguments?.join('\n') ?? '', workingDirectory: step.workingDirectory ?? '', container: step.container ?? '', dockerUser: step.user ?? '',
|
||||
collectOutput: Boolean(output), outputPath: output?.path ?? '', outputName: step.outputName ?? output?.name ?? '', archive: output?.archive ?? true, directoryPath: step.path ?? '', additionalOutputs: step.outputs?.slice(1) ?? [],
|
||||
database: step.database ?? '', databaseUsername: step.username ?? '', databaseHost: step.databaseHost ?? 'localhost', databasePort: String(step.databasePort ?? (step.type === 'mysql' ? 3306 : 5432)), databasePassword: '', hasDatabasePassword,
|
||||
};
|
||||
}
|
||||
|
||||
function stepTypeLabel(type: StepType): string { return { dockerCommand: 'Docker command', remoteCommand: 'Remote command', directory: 'Directory archive', postgres: 'PostgreSQL dump', mysql: 'MySQL dump', managerConfig: 'Backup Manager configuration' }[type]; }
|
||||
function parseArguments(value: string): string[] { return value.split('\n').map((argument) => argument.trim()).filter(Boolean); }
|
||||
|
||||
function populateNotificationForm(data: Record<string, unknown>) {
|
||||
const form = document.querySelector<HTMLFormElement>('#notification-form');
|
||||
if (!form) return;
|
||||
const setField = (name: string, value: unknown) => {
|
||||
const element = form.elements.namedItem(name) as HTMLInputElement | null;
|
||||
if (!element) return;
|
||||
if (element.type === 'checkbox') element.checked = Boolean(value);
|
||||
else element.value = String(value ?? '');
|
||||
};
|
||||
setField('webhookUrl', data.webhookUrl);
|
||||
setField('notifySuccess', data.notifySuccess);
|
||||
setField('notifyFailure', data.notifyFailure);
|
||||
const smtp = data.smtp as Record<string, unknown> | undefined;
|
||||
setField('smtpEnabled', Boolean(smtp));
|
||||
setField('smtpHost', '');
|
||||
setField('smtpPort', 587);
|
||||
setField('smtpSecure', false);
|
||||
setField('smtpUsername', '');
|
||||
setField('smtpPassword', '');
|
||||
setField('smtpFrom', '');
|
||||
setField('smtpTo', '');
|
||||
if (smtp) for (const [key, value] of Object.entries(smtp)) setField(`smtp${key[0]!.toUpperCase()}${key.slice(1)}`, value);
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const [message, setMessage] = useState('');
|
||||
useEffect(() => { void api<Record<string, unknown>>('/api/settings/notifications').then((data) => {
|
||||
const form = document.querySelector<HTMLFormElement>('#notification-form');
|
||||
if (!form) return;
|
||||
const setField = (name: string, value: unknown) => {
|
||||
const element = form.elements.namedItem(name) as HTMLInputElement | null;
|
||||
if (!element) return;
|
||||
if (element.type === 'checkbox') element.checked = Boolean(value);
|
||||
else element.value = String(value ?? '');
|
||||
};
|
||||
setField('webhookUrl', data.webhookUrl);
|
||||
setField('notifySuccess', data.notifySuccess);
|
||||
setField('notifyFailure', data.notifyFailure);
|
||||
const smtp = data.smtp as Record<string, unknown> | undefined;
|
||||
setField('smtpEnabled', Boolean(smtp));
|
||||
if (smtp) for (const [key, value] of Object.entries(smtp)) setField(`smtp${key[0]!.toUpperCase()}${key.slice(1)}`, value);
|
||||
}); }, []);
|
||||
const [notificationMessage, setNotificationMessage] = useState('');
|
||||
const [restoreMessage, setRestoreMessage] = useState('');
|
||||
const [restoreFile, setRestoreFile] = useState<{ name: string; configuration: unknown; exportedAt: string; hosts: number; jobs: number; settings: number }>();
|
||||
useEffect(() => { void api<Record<string, unknown>>('/api/settings/notifications').then(populateNotificationForm); }, []);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const data = new FormData(event.currentTarget);
|
||||
const smtpEnabled = data.get('smtpEnabled') === 'on';
|
||||
try { await api('/api/settings/notifications', { method: 'PUT', body: JSON.stringify({
|
||||
webhookUrl: data.get('webhookUrl') || '', notifySuccess: data.get('notifySuccess') === 'on', notifyFailure: data.get('notifyFailure') === 'on',
|
||||
smtp: smtpEnabled ? { host: data.get('smtpHost'), port: Number(data.get('smtpPort')), secure: data.get('smtpSecure') === 'on', username: data.get('smtpUsername') || undefined, password: data.get('smtpPassword') || undefined, from: data.get('smtpFrom'), to: data.get('smtpTo') } : undefined,
|
||||
}) }); setMessage('Notification settings saved.'); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
}) }); setNotificationMessage('Notification settings saved.'); } catch (error) { setNotificationMessage(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
return <><PageTitle eyebrow="SYSTEM / DELIVERY" title="Notifications" detail="Send a concise result after remote jobs complete." /><section className="panel form-panel settings-panel"><form id="notification-form" onSubmit={submit}><fieldset><legend>Events</legend><label className="check"><input name="notifyFailure" type="checkbox" defaultChecked /> Notify when a backup fails</label><label className="check"><input name="notifySuccess" type="checkbox" /> Notify when a backup succeeds</label></fieldset><fieldset><legend>Webhook</legend><label>Endpoint URL<input name="webhookUrl" type="url" placeholder="https://hooks.example/backup" /></label></fieldset><fieldset><legend>SMTP email</legend><label className="check"><input name="smtpEnabled" type="checkbox" /> Enable SMTP delivery</label><div className="field-row wide"><label>SMTP host<input name="smtpHost" /></label><label className="port">Port<input name="smtpPort" type="number" defaultValue="587" /></label></div><label className="check"><input name="smtpSecure" type="checkbox" /> Use implicit TLS</label><div className="field-row"><label>Username<input name="smtpUsername" /></label><label>Password<input name="smtpPassword" type="password" /></label></div><div className="field-row"><label>From<input name="smtpFrom" type="email" /></label><label>To<input name="smtpTo" type="email" /></label></div></fieldset>{message && <p className="form-message">{message}</p>}<button type="submit">Save settings</button></form></section></>;
|
||||
async function selectRestoreFile(file: File | undefined) {
|
||||
setRestoreFile(undefined);
|
||||
setRestoreMessage('');
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) { setRestoreMessage('Configuration backups must be 10 MB or smaller.'); return; }
|
||||
try {
|
||||
const configuration = JSON.parse(await file.text()) as Record<string, unknown>;
|
||||
if (configuration.format !== 'backup-script-manager/configuration' || configuration.version !== 1 || typeof configuration.sourceInstanceId !== 'string' || typeof configuration.integrity !== 'string' || !Array.isArray(configuration.hosts) || !Array.isArray(configuration.jobs) || !Array.isArray(configuration.settings) || typeof configuration.exportedAt !== 'string' || Number.isNaN(Date.parse(configuration.exportedAt))) throw new Error('Not a supported Backup Manager configuration export');
|
||||
setRestoreFile({ name: file.name, configuration, exportedAt: configuration.exportedAt, hosts: configuration.hosts.length, jobs: configuration.jobs.length, settings: configuration.settings.length });
|
||||
} catch (error) { setRestoreMessage(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
async function restore(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!restoreFile || !confirm(`Restore ${restoreFile.name}? Matching records will be replaced with the backup configuration.`)) return;
|
||||
try {
|
||||
const result = await api<{ hosts: { added: number; updated: number }; jobs: { added: number; updated: number }; settings: { added: number; updated: number } }>('/api/settings/configuration', { method: 'PUT', body: JSON.stringify(restoreFile.configuration) });
|
||||
setRestoreMessage(`Restored ${result.hosts.added + result.hosts.updated} hosts, ${result.jobs.added + result.jobs.updated} jobs, and ${result.settings.added + result.settings.updated} settings.`);
|
||||
void api<Record<string, unknown>>('/api/settings/notifications').then(populateNotificationForm).catch(() => undefined);
|
||||
} catch (error) { setRestoreMessage(error instanceof Error ? error.message : String(error)); }
|
||||
}
|
||||
return <><PageTitle eyebrow="SYSTEM / CONFIGURATION" title="Settings" detail="Manage delivery and restore encrypted Backup Manager configuration." /><div className="settings-stack"><section className="panel form-panel settings-panel"><div className="panel-head"><h2>Notifications</h2><span>delivery</span></div><form id="notification-form" onSubmit={submit}><fieldset><legend>Events</legend><label className="check"><input name="notifyFailure" type="checkbox" defaultChecked /> Notify when a backup fails</label><label className="check"><input name="notifySuccess" type="checkbox" /> Notify when a backup succeeds</label></fieldset><fieldset><legend>Webhook</legend><label>Endpoint URL<input name="webhookUrl" type="url" placeholder="https://hooks.example/backup" /></label></fieldset><fieldset><legend>SMTP email</legend><label className="check"><input name="smtpEnabled" type="checkbox" /> Enable SMTP delivery</label><div className="field-row wide"><label>SMTP host<input name="smtpHost" /></label><label className="port">Port<input name="smtpPort" type="number" defaultValue="587" /></label></div><label className="check"><input name="smtpSecure" type="checkbox" /> Use implicit TLS</label><div className="field-row"><label>Username<input name="smtpUsername" /></label><label>Password<input name="smtpPassword" type="password" /></label></div><div className="field-row"><label>From<input name="smtpFrom" type="email" /></label><label>To<input name="smtpTo" type="email" /></label></div></fieldset>{notificationMessage && <p className="form-message">{notificationMessage}</p>}<button type="submit">Save settings</button></form></section><section className="panel form-panel settings-panel restore-panel"><div className="panel-head"><h2>Restore configuration</h2><span>safe merge</span></div><form onSubmit={restore}><p className="muted">Matching hosts, jobs, and settings are replaced. Unrelated records and run history remain untouched.</p><label>Configuration backup<input type="file" accept="application/json,.json" onChange={(event) => void selectRestoreFile(event.target.files?.[0])} /></label>{restoreFile && <div className="restore-preview"><strong>{restoreFile.name}</strong><span>Exported {formatDate(restoreFile.exportedAt)}</span><span>{restoreFile.hosts} hosts · {restoreFile.jobs} jobs · {restoreFile.settings} settings</span><small>Encrypted credentials require this manager to use the original MASTER_KEY.</small></div>}{restoreMessage && <p className="form-message">{restoreMessage}</p>}<button className="restore-button" type="submit" disabled={!restoreFile}>Restore configuration</button></form></section></div></>;
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) { return <div className="empty">{text}</div>; }
|
||||
|
||||
@@ -49,6 +49,7 @@ nav button.active span { color: var(--acid); }
|
||||
.split { display: grid; grid-template-columns: minmax(380px, .9fr) minmax(440px, 1.1fr); gap: 20px; align-items: start; }.jobs-split { grid-template-columns: minmax(440px, 1fr) minmax(400px, .9fr); }.form-panel form { padding: 22px; }.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.field-row.wide { grid-template-columns: 1fr 100px; }.input-action { display: flex; }.input-action button { white-space: nowrap; }.segmented { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); border: 1px solid var(--line); }.segmented button { background: #121814; color: var(--muted); }.segmented button.selected { background: #263126; color: var(--acid); }
|
||||
.form-message { color: #cfdbc9; border-left: 2px solid var(--acid); padding-left: 10px; font-size: 13px; }.empty { color: var(--muted); padding: 50px 25px; text-align: center; font-size: 13px; }
|
||||
.form-actions { display: flex; gap: 8px; }.form-actions .secondary { background: transparent; border: 1px solid var(--line); color: var(--muted); }
|
||||
.settings-stack { display: grid; gap: 20px; max-width: 700px; }.settings-stack .settings-panel { max-width: none; }.restore-panel form > p { line-height: 1.6; margin-bottom: 0; }.restore-preview { display: grid; gap: 6px; padding: 15px; border: 1px solid #435047; background: #121814; font: 11px DM Mono; }.restore-preview span, .restore-preview small { color: var(--muted); }.restore-button { background: var(--orange); }
|
||||
.host-card, .job-card { padding: 19px 21px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 15px; }.host-card:last-child, .job-card:last-child { border: 0; }.host-card p, .job-card p { color: var(--muted); margin-bottom: 5px; font-size: 12px; }.host-card small, .job-card small { color: #718078; font: 9px DM Mono; overflow-wrap: anywhere; }.host-icon { width: 43px; height: 43px; flex: 0 0 43px; border: 1px solid #435047; display: grid; place-items: center; color: var(--acid); font: 11px DM Mono; }.card-actions { display: flex; gap: 6px; margin-left: auto; }.card-actions button { padding: 8px 10px; font-size: 11px; background: #2b382f; color: #dbe4de; }.card-actions .run { background: var(--acid); color: #111713; }.card-actions .danger { background: transparent; color: #c6866b; border: 1px solid #513429; }
|
||||
fieldset { border: 1px solid var(--line); padding: 18px; display: grid; gap: 16px; } legend { color: var(--acid); padding: 0 8px; font: 10px DM Mono; letter-spacing: .08em; text-transform: uppercase; }.check { display: flex; grid-template-columns: 18px 1fr; align-items: center; }.check input { width: 16px; height: 16px; accent-color: var(--acid); }.job-card { align-items: flex-start; }.job-type { display: inline-block; color: var(--acid); border: 1px solid #3d4d3f; padding: 3px 6px; margin-bottom: 10px; font: 9px DM Mono; text-transform: uppercase; }.settings-panel { max-width: 700px; }.settings-panel form { padding: 24px; }
|
||||
.step-list { display: grid; gap: 14px; }.step-card { background: #121814; border-color: #354139; }.step-head { display: grid; grid-template-columns: minmax(120px, 1fr) auto auto; gap: 10px; align-items: center; }.step-head input { font-weight: 700; }.step-head .job-type { margin: 0; }.step-actions { display: flex; gap: 3px; }.step-actions button { padding: 8px 10px; background: #28332c; color: var(--ink); }.step-actions button:disabled { opacity: .25; cursor: default; }.add-step { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border: 1px dashed #435047; }.step-policy { align-items: end; padding-top: 4px; border-top: 1px solid var(--line); }
|
||||
|
||||
Reference in New Issue
Block a user