Fix StartOS 0.4 TypeScript packaging to match SDK API
This commit is contained in:
+183
@@ -0,0 +1,183 @@
|
||||
import * as T from '../../../base/lib/types';
|
||||
import { Affine } from '../util';
|
||||
import { InitKind, InitScript } from '../../../base/lib/inits';
|
||||
/** A password value, or a function that returns one. Functions are resolved lazily (only during restore). */
|
||||
export type LazyPassword = string | (() => string | Promise<string>) | null;
|
||||
/** Configuration for PostgreSQL dump-based backup */
|
||||
export type PgDumpConfig<M extends T.SDKManifest> = {
|
||||
/** Image ID of the PostgreSQL container (e.g. 'postgres') */
|
||||
imageId: keyof M['images'] & T.ImageId;
|
||||
/** Volume ID containing the PostgreSQL data directory */
|
||||
dbVolume: M['volumes'][number];
|
||||
/** Volume mountpoint (e.g. '/var/lib/postgresql') */
|
||||
mountpoint: string;
|
||||
/** Subpath from mountpoint to PGDATA (e.g. '/data', '/18/docker') */
|
||||
pgdataPath: string;
|
||||
/** PostgreSQL database name to dump */
|
||||
database: string;
|
||||
/** PostgreSQL user */
|
||||
user: string;
|
||||
/** PostgreSQL password (for restore). Can be a string, a function that returns one (resolved lazily after volumes are restored), or null for trust auth. */
|
||||
password: LazyPassword;
|
||||
/** Additional initdb arguments (e.g. ['--data-checksums']) */
|
||||
initdbArgs?: string[];
|
||||
/** Additional options passed to `pg_ctl start -o` (e.g. '-c shared_preload_libraries=vectorchord'). Appended after `-c listen_addresses=`. */
|
||||
pgOptions?: string;
|
||||
};
|
||||
/** Configuration for MySQL/MariaDB dump-based backup */
|
||||
export type MysqlDumpConfig<M extends T.SDKManifest> = {
|
||||
/** Image ID of the MySQL/MariaDB container (e.g. 'mysql', 'mariadb') */
|
||||
imageId: keyof M['images'] & T.ImageId;
|
||||
/** Volume ID containing the MySQL data directory */
|
||||
dbVolume: M['volumes'][number];
|
||||
/** Path to MySQL data directory within the container (typically '/var/lib/mysql') */
|
||||
datadir: string;
|
||||
/** MySQL database name to dump */
|
||||
database: string;
|
||||
/** MySQL user for dump operations */
|
||||
user: string;
|
||||
/** MySQL password. Can be a string or a function that returns one — functions are resolved lazily after volumes are restored. */
|
||||
password: LazyPassword;
|
||||
/** Database engine: 'mysql' uses --initialize-insecure, 'mariadb' uses mysql_install_db */
|
||||
engine: 'mysql' | 'mariadb';
|
||||
/** Custom readiness check command (default: ['mysqladmin', 'ping', ...]) */
|
||||
readyCommand?: string[];
|
||||
/** Additional options passed to `mysqld` on startup (e.g. '--innodb-buffer-pool-size=256M'). Appended after `--bind-address=127.0.0.1`. */
|
||||
mysqldOptions?: string[];
|
||||
};
|
||||
/** Default rsync options used for backup and restore operations */
|
||||
export declare const DEFAULT_OPTIONS: T.SyncOptions;
|
||||
/** A single source-to-destination sync pair for backup and restore */
|
||||
export type BackupSync<Volumes extends string> = {
|
||||
dataPath: `/media/startos/volumes/${Volumes}/${string}`;
|
||||
backupPath: `/media/startos/backup/${string}`;
|
||||
options?: Partial<T.SyncOptions>;
|
||||
backupOptions?: Partial<T.SyncOptions>;
|
||||
restoreOptions?: Partial<T.SyncOptions>;
|
||||
};
|
||||
/** Effects type narrowed for backup/restore contexts, preventing reuse outside that scope */
|
||||
export type BackupEffects = T.Effects & Affine<'Backups'>;
|
||||
/**
|
||||
* Configures backup and restore operations using rsync.
|
||||
*
|
||||
* Supports syncing entire volumes or custom path pairs, with optional pre/post hooks
|
||||
* for both backup and restore phases. Implements {@link InitScript} so it can be used
|
||||
* as a restore-init step in `setupInit`.
|
||||
*
|
||||
* @typeParam M - The service manifest type
|
||||
*/
|
||||
export declare class Backups<M extends T.SDKManifest> implements InitScript {
|
||||
private options;
|
||||
private restoreOptions;
|
||||
private backupOptions;
|
||||
private backupSet;
|
||||
private preBackup;
|
||||
private postBackup;
|
||||
private preRestore;
|
||||
private postRestore;
|
||||
private constructor();
|
||||
/**
|
||||
* Create a Backups configuration that backs up entire volumes by name.
|
||||
* Each volume is synced to a corresponding directory under `/media/startos/backup/volumes/`.
|
||||
* @param volumeNames - One or more volume IDs from the manifest
|
||||
*/
|
||||
static ofVolumes<M extends T.SDKManifest = never>(...volumeNames: Array<M['volumes'][number]>): Backups<M>;
|
||||
/**
|
||||
* Create a Backups configuration from explicit source/destination sync pairs.
|
||||
* @param syncs - Array of `{ dataPath, backupPath }` objects with optional per-sync options
|
||||
*/
|
||||
static ofSyncs<M extends T.SDKManifest = never>(...syncs: BackupSync<M['volumes'][number]>[]): Backups<M>;
|
||||
/**
|
||||
* Create an empty Backups configuration with custom default rsync options.
|
||||
* Chain `.addVolume()` or `.addSync()` to add sync targets.
|
||||
* @param options - Partial rsync options to override defaults (e.g. `{ exclude: ['cache'] }`)
|
||||
*/
|
||||
static withOptions<M extends T.SDKManifest = never>(options?: Partial<T.SyncOptions>): Backups<M>;
|
||||
/**
|
||||
* Configure PostgreSQL dump-based backup for a volume.
|
||||
*
|
||||
* Instead of rsyncing the raw PostgreSQL data directory (which is slow and error-prone),
|
||||
* this uses `pg_dump` to create a logical dump before backup and `pg_restore` to rebuild
|
||||
* the database after restore.
|
||||
*
|
||||
* The dump file is written directly to the backup target — no data duplication on disk.
|
||||
*
|
||||
* @returns A configured Backups instance with pre/post hooks. Chain `.addVolume()` or
|
||||
* `.addSync()` to include additional volumes/paths in the backup.
|
||||
*/
|
||||
static withPgDump<M extends T.SDKManifest = never>(config: PgDumpConfig<M>): Backups<M>;
|
||||
/**
|
||||
* Configure MySQL/MariaDB dump-based backup for a volume.
|
||||
*
|
||||
* Instead of rsyncing the raw MySQL data directory (which is slow and error-prone),
|
||||
* this uses `mysqldump` to create a logical dump before backup and `mysql` to restore
|
||||
* the database after restore.
|
||||
*
|
||||
* The dump file is stored temporarily in `dumpVolume` during backup and cleaned up afterward.
|
||||
*
|
||||
* @returns A configured Backups instance with pre/post hooks. Chain `.addVolume()` or
|
||||
* `.addSync()` to include additional volumes/paths in the backup.
|
||||
*/
|
||||
static withMysqlDump<M extends T.SDKManifest = never>(config: MysqlDumpConfig<M>): Backups<M>;
|
||||
/**
|
||||
* Override the default rsync options for both backup and restore.
|
||||
* @param options - Partial rsync options to merge with current defaults
|
||||
*/
|
||||
setOptions(options?: Partial<T.SyncOptions>): this;
|
||||
/**
|
||||
* Override rsync options used only during backup (not restore).
|
||||
* @param options - Partial rsync options for the backup phase
|
||||
*/
|
||||
setBackupOptions(options?: Partial<T.SyncOptions>): this;
|
||||
/**
|
||||
* Override rsync options used only during restore (not backup).
|
||||
* @param options - Partial rsync options for the restore phase
|
||||
*/
|
||||
setRestoreOptions(options?: Partial<T.SyncOptions>): this;
|
||||
/**
|
||||
* Register a hook to run before backup rsync begins (e.g. dump a database).
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPreBackup(fn: (effects: BackupEffects) => Promise<void>): this;
|
||||
/**
|
||||
* Register a hook to run after backup rsync completes.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPostBackup(fn: (effects: BackupEffects) => Promise<void>): this;
|
||||
/**
|
||||
* Register a hook to run before restore rsync begins.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPreRestore(fn: (effects: BackupEffects) => Promise<void>): this;
|
||||
/**
|
||||
* Register a hook to run after restore rsync completes.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPostRestore(fn: (effects: BackupEffects) => Promise<void>): this;
|
||||
/**
|
||||
* Add a volume to the backup set by its ID.
|
||||
* @param volume - The volume ID from the manifest
|
||||
* @param options - Optional per-volume rsync overrides
|
||||
*/
|
||||
addVolume(volume: M['volumes'][number], options?: Partial<{
|
||||
options: T.SyncOptions;
|
||||
backupOptions: T.SyncOptions;
|
||||
restoreOptions: T.SyncOptions;
|
||||
}>): this;
|
||||
/**
|
||||
* Add a custom sync pair to the backup set.
|
||||
* @param sync - A `{ dataPath, backupPath }` object with optional per-sync rsync options
|
||||
*/
|
||||
addSync(sync: BackupSync<M['volumes'][0]>): this;
|
||||
/**
|
||||
* Execute the backup: runs pre-hook, rsyncs all configured paths, saves the data version, then runs post-hook.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
createBackup(effects: T.Effects): Promise<void>;
|
||||
init(effects: T.Effects, kind: InitKind): Promise<void>;
|
||||
/**
|
||||
* Execute the restore: runs pre-hook, rsyncs all configured paths from backup to data, restores the data version, then runs post-hook.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
restoreBackup(effects: T.Effects): Promise<void>;
|
||||
}
|
||||
+575
@@ -0,0 +1,575 @@
|
||||
"use strict";
|
||||
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||
}
|
||||
Object.defineProperty(o, k2, desc);
|
||||
}) : (function(o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k;
|
||||
o[k2] = m[k];
|
||||
}));
|
||||
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||
}) : function(o, v) {
|
||||
o["default"] = v;
|
||||
});
|
||||
var __importStar = (this && this.__importStar) || (function () {
|
||||
var ownKeys = function(o) {
|
||||
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||
var ar = [];
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||
return ar;
|
||||
};
|
||||
return ownKeys(o);
|
||||
};
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod;
|
||||
var result = {};
|
||||
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||
__setModuleDefault(result, mod);
|
||||
return result;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.Backups = exports.DEFAULT_OPTIONS = void 0;
|
||||
const child_process = __importStar(require("child_process"));
|
||||
const fs = __importStar(require("fs/promises"));
|
||||
const util_1 = require("../util");
|
||||
const SubContainer_1 = require("../util/SubContainer");
|
||||
const Mounts_1 = require("../mainFn/Mounts");
|
||||
const BACKUP_HOST_PATH = '/media/startos/backup';
|
||||
const BACKUP_CONTAINER_MOUNT = '/backup-target';
|
||||
async function resolvePassword(pw) {
|
||||
if (pw === null)
|
||||
return null;
|
||||
return typeof pw === 'function' ? pw() : pw;
|
||||
}
|
||||
/** Bind-mount the backup target into a SubContainer's rootfs */
|
||||
async function mountBackupTarget(rootfs) {
|
||||
const target = `${rootfs}${BACKUP_CONTAINER_MOUNT}`;
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await (0, SubContainer_1.execFile)('mount', ['--rbind', BACKUP_HOST_PATH, target]);
|
||||
}
|
||||
/** Default rsync options used for backup and restore operations */
|
||||
exports.DEFAULT_OPTIONS = {
|
||||
delete: true,
|
||||
exclude: [],
|
||||
};
|
||||
/**
|
||||
* Configures backup and restore operations using rsync.
|
||||
*
|
||||
* Supports syncing entire volumes or custom path pairs, with optional pre/post hooks
|
||||
* for both backup and restore phases. Implements {@link InitScript} so it can be used
|
||||
* as a restore-init step in `setupInit`.
|
||||
*
|
||||
* @typeParam M - The service manifest type
|
||||
*/
|
||||
class Backups {
|
||||
constructor(options = exports.DEFAULT_OPTIONS, restoreOptions = {}, backupOptions = {}, backupSet = [], preBackup = async (effects) => { }, postBackup = async (effects) => { }, preRestore = async (effects) => { }, postRestore = async (effects) => { }) {
|
||||
this.options = options;
|
||||
this.restoreOptions = restoreOptions;
|
||||
this.backupOptions = backupOptions;
|
||||
this.backupSet = backupSet;
|
||||
this.preBackup = preBackup;
|
||||
this.postBackup = postBackup;
|
||||
this.preRestore = preRestore;
|
||||
this.postRestore = postRestore;
|
||||
}
|
||||
/**
|
||||
* Create a Backups configuration that backs up entire volumes by name.
|
||||
* Each volume is synced to a corresponding directory under `/media/startos/backup/volumes/`.
|
||||
* @param volumeNames - One or more volume IDs from the manifest
|
||||
*/
|
||||
static ofVolumes(...volumeNames) {
|
||||
return Backups.ofSyncs(...volumeNames.map((srcVolume) => ({
|
||||
dataPath: `/media/startos/volumes/${srcVolume}/`,
|
||||
backupPath: `/media/startos/backup/volumes/${srcVolume}/`,
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Create a Backups configuration from explicit source/destination sync pairs.
|
||||
* @param syncs - Array of `{ dataPath, backupPath }` objects with optional per-sync options
|
||||
*/
|
||||
static ofSyncs(...syncs) {
|
||||
return syncs.reduce((acc, x) => acc.addSync(x), new Backups());
|
||||
}
|
||||
/**
|
||||
* Create an empty Backups configuration with custom default rsync options.
|
||||
* Chain `.addVolume()` or `.addSync()` to add sync targets.
|
||||
* @param options - Partial rsync options to override defaults (e.g. `{ exclude: ['cache'] }`)
|
||||
*/
|
||||
static withOptions(options) {
|
||||
return new Backups({ ...exports.DEFAULT_OPTIONS, ...options });
|
||||
}
|
||||
/**
|
||||
* Configure PostgreSQL dump-based backup for a volume.
|
||||
*
|
||||
* Instead of rsyncing the raw PostgreSQL data directory (which is slow and error-prone),
|
||||
* this uses `pg_dump` to create a logical dump before backup and `pg_restore` to rebuild
|
||||
* the database after restore.
|
||||
*
|
||||
* The dump file is written directly to the backup target — no data duplication on disk.
|
||||
*
|
||||
* @returns A configured Backups instance with pre/post hooks. Chain `.addVolume()` or
|
||||
* `.addSync()` to include additional volumes/paths in the backup.
|
||||
*/
|
||||
static withPgDump(config) {
|
||||
const { imageId, dbVolume, mountpoint, pgdataPath, database, user, password, initdbArgs = [], pgOptions, } = config;
|
||||
const pgdata = `${mountpoint}${pgdataPath}`;
|
||||
const dumpFile = `${BACKUP_CONTAINER_MOUNT}/${database}-db.dump`;
|
||||
function dbMounts() {
|
||||
return Mounts_1.Mounts.of().mountVolume({
|
||||
volumeId: dbVolume,
|
||||
mountpoint: mountpoint,
|
||||
readonly: false,
|
||||
subpath: null,
|
||||
});
|
||||
}
|
||||
async function startPg(sub, label) {
|
||||
await sub.exec(['rm', '-f', `${pgdata}/postmaster.pid`], {
|
||||
user: 'postgres',
|
||||
});
|
||||
await sub.exec(['mkdir', '-p', '/var/run/postgresql'], {
|
||||
user: 'root',
|
||||
});
|
||||
await sub.exec(['chown', 'postgres:postgres', '/var/run/postgresql'], {
|
||||
user: 'root',
|
||||
});
|
||||
console.log(`[${label}] starting postgres`);
|
||||
const pgStartOpts = pgOptions
|
||||
? `-c listen_addresses= ${pgOptions}`
|
||||
: '-c listen_addresses=';
|
||||
await sub.execFail(['pg_ctl', 'start', '-D', pgdata, '-o', pgStartOpts], {
|
||||
user: 'postgres',
|
||||
});
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const { exitCode } = await sub.exec(['pg_isready', '-U', user], {
|
||||
user: 'postgres',
|
||||
});
|
||||
if (exitCode === 0) {
|
||||
console.log(`[${label}] postgres is ready`);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
throw new Error('PostgreSQL failed to become ready within 60 seconds');
|
||||
}
|
||||
return new Backups()
|
||||
.setPreBackup(async (effects) => {
|
||||
await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'pg-dump', async (sub) => {
|
||||
console.log('[pg-dump] mounting backup target');
|
||||
await mountBackupTarget(sub.rootfs);
|
||||
await sub.exec(['touch', dumpFile], { user: 'root' });
|
||||
await sub.exec(['chown', 'postgres:postgres', dumpFile], {
|
||||
user: 'root',
|
||||
});
|
||||
await startPg(sub, 'pg-dump');
|
||||
console.log('[pg-dump] dumping database');
|
||||
await sub.execFail(['pg_dump', '-U', user, '-Fc', '-f', dumpFile, database], { user: 'postgres' }, null);
|
||||
console.log('[pg-dump] stopping postgres');
|
||||
await sub.execFail(['pg_ctl', 'stop', '-D', pgdata, '-w'], {
|
||||
user: 'postgres',
|
||||
});
|
||||
console.log('[pg-dump] complete');
|
||||
});
|
||||
})
|
||||
.setPostRestore(async (effects) => {
|
||||
const resolvedPassword = await resolvePassword(password);
|
||||
await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'pg-restore', async (sub) => {
|
||||
await mountBackupTarget(sub.rootfs);
|
||||
await sub.execFail(['chown', '-R', 'postgres:postgres', mountpoint], { user: 'root' });
|
||||
await sub.execFail(['initdb', '-D', pgdata, '-U', user, ...initdbArgs], { user: 'postgres' });
|
||||
await startPg(sub, 'pg-restore');
|
||||
await sub.execFail(['createdb', '-U', user, database], {
|
||||
user: 'postgres',
|
||||
});
|
||||
await sub.execFail([
|
||||
'pg_restore',
|
||||
'-U',
|
||||
user,
|
||||
'-d',
|
||||
database,
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
dumpFile,
|
||||
], { user: 'postgres' }, null);
|
||||
if (resolvedPassword !== null) {
|
||||
await sub.execFail([
|
||||
'psql',
|
||||
'-U',
|
||||
user,
|
||||
'-d',
|
||||
database,
|
||||
'-c',
|
||||
`ALTER USER ${user} WITH PASSWORD '${resolvedPassword}'`,
|
||||
], { user: 'postgres' });
|
||||
}
|
||||
await sub.execFail(['pg_ctl', 'stop', '-D', pgdata, '-w'], {
|
||||
user: 'postgres',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Configure MySQL/MariaDB dump-based backup for a volume.
|
||||
*
|
||||
* Instead of rsyncing the raw MySQL data directory (which is slow and error-prone),
|
||||
* this uses `mysqldump` to create a logical dump before backup and `mysql` to restore
|
||||
* the database after restore.
|
||||
*
|
||||
* The dump file is stored temporarily in `dumpVolume` during backup and cleaned up afterward.
|
||||
*
|
||||
* @returns A configured Backups instance with pre/post hooks. Chain `.addVolume()` or
|
||||
* `.addSync()` to include additional volumes/paths in the backup.
|
||||
*/
|
||||
static withMysqlDump(config) {
|
||||
const { imageId, dbVolume, datadir, database, user, password, engine, readyCommand, mysqldOptions = [], } = config;
|
||||
const dumpFile = `${BACKUP_CONTAINER_MOUNT}/${database}-db.dump`;
|
||||
function dbMounts() {
|
||||
return Mounts_1.Mounts.of().mountVolume({
|
||||
volumeId: dbVolume,
|
||||
mountpoint: datadir,
|
||||
readonly: false,
|
||||
subpath: null,
|
||||
});
|
||||
}
|
||||
async function waitForMysql(sub, cmd) {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const { exitCode } = await sub.exec(cmd);
|
||||
if (exitCode === 0)
|
||||
return;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
throw new Error('MySQL/MariaDB failed to become ready within 30 seconds');
|
||||
}
|
||||
async function startMysql(sub) {
|
||||
if (engine === 'mariadb') {
|
||||
// MariaDB doesn't support --daemonize; fire-and-forget the exec
|
||||
sub
|
||||
.exec([
|
||||
'mysqld',
|
||||
'--user=mysql',
|
||||
`--datadir=${datadir}`,
|
||||
'--bind-address=127.0.0.1',
|
||||
...mysqldOptions,
|
||||
], { user: 'root' })
|
||||
.catch((e) => console.error('[mysql-backup] mysqld exited unexpectedly:', e));
|
||||
}
|
||||
else {
|
||||
await sub.execFail([
|
||||
'mysqld',
|
||||
'--user=mysql',
|
||||
`--datadir=${datadir}`,
|
||||
'--bind-address=127.0.0.1',
|
||||
'--daemonize',
|
||||
...mysqldOptions,
|
||||
], { user: 'root' }, null);
|
||||
}
|
||||
}
|
||||
return new Backups()
|
||||
.setPreBackup(async (effects) => {
|
||||
const pw = await resolvePassword(password);
|
||||
const readyCmd = readyCommand || [
|
||||
'mysqladmin',
|
||||
'ping',
|
||||
'-u',
|
||||
user,
|
||||
...(pw !== null ? [`-p${pw}`] : []),
|
||||
'--silent',
|
||||
];
|
||||
await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'mysql-dump', async (sub) => {
|
||||
await mountBackupTarget(sub.rootfs);
|
||||
await sub.exec(['mkdir', '-p', '/var/run/mysqld'], {
|
||||
user: 'root',
|
||||
});
|
||||
await sub.exec(['chown', 'mysql:mysql', '/var/run/mysqld'], {
|
||||
user: 'root',
|
||||
});
|
||||
if (engine === 'mysql') {
|
||||
await sub.execFail(['chown', '-R', 'mysql:mysql', datadir], {
|
||||
user: 'root',
|
||||
});
|
||||
}
|
||||
await startMysql(sub);
|
||||
await waitForMysql(sub, readyCmd);
|
||||
await sub.execFail([
|
||||
'mysqldump',
|
||||
'-u',
|
||||
user,
|
||||
...(pw !== null ? [`-p${pw}`] : []),
|
||||
'--single-transaction',
|
||||
`--result-file=${dumpFile}`,
|
||||
database,
|
||||
], { user: 'root' }, null);
|
||||
// Graceful shutdown via SIGTERM; wait for exit
|
||||
await sub.execFail([
|
||||
'sh',
|
||||
'-c',
|
||||
'PID=$(cat /var/run/mysqld/mysqld.pid) && kill $PID && tail --pid=$PID -f /dev/null',
|
||||
], { user: 'root' }, null);
|
||||
});
|
||||
})
|
||||
.setPostRestore(async (effects) => {
|
||||
const pw = await resolvePassword(password);
|
||||
await SubContainer_1.SubContainerRc.withTemp(effects, { imageId }, dbMounts(), 'mysql-restore', async (sub) => {
|
||||
await mountBackupTarget(sub.rootfs);
|
||||
await sub.exec(['mkdir', '-p', '/var/run/mysqld'], {
|
||||
user: 'root',
|
||||
});
|
||||
await sub.exec(['chown', 'mysql:mysql', '/var/run/mysqld'], {
|
||||
user: 'root',
|
||||
});
|
||||
// Initialize fresh data directory
|
||||
if (engine === 'mariadb') {
|
||||
await sub.execFail(['mysql_install_db', '--user=mysql', `--datadir=${datadir}`], { user: 'root' });
|
||||
}
|
||||
else {
|
||||
await sub.execFail([
|
||||
'mysqld',
|
||||
'--initialize-insecure',
|
||||
'--user=mysql',
|
||||
`--datadir=${datadir}`,
|
||||
], { user: 'root' });
|
||||
}
|
||||
await startMysql(sub);
|
||||
// After fresh init, root has no password
|
||||
await waitForMysql(sub, [
|
||||
'mysqladmin',
|
||||
'ping',
|
||||
'-u',
|
||||
'root',
|
||||
'--silent',
|
||||
]);
|
||||
// Create database, user, and set password
|
||||
const grantSql = pw !== null
|
||||
? `CREATE DATABASE IF NOT EXISTS \`${database}\`; CREATE USER IF NOT EXISTS '${user}'@'localhost' IDENTIFIED BY '${pw}'; GRANT ALL ON \`${database}\`.* TO '${user}'@'localhost'; ALTER USER 'root'@'localhost' IDENTIFIED BY '${pw}'; FLUSH PRIVILEGES;`
|
||||
: `CREATE DATABASE IF NOT EXISTS \`${database}\`; CREATE USER IF NOT EXISTS '${user}'@'localhost'; GRANT ALL ON \`${database}\`.* TO '${user}'@'localhost'; FLUSH PRIVILEGES;`;
|
||||
await sub.execFail(['mysql', '-u', 'root', '-e', grantSql], {
|
||||
user: 'root',
|
||||
});
|
||||
// Restore from dump
|
||||
await sub.execFail([
|
||||
'sh',
|
||||
'-c',
|
||||
`mysql -u root ${pw !== null ? `-p'${pw}'` : ''} ${database} < ${dumpFile}`,
|
||||
], { user: 'root' }, null);
|
||||
// Graceful shutdown via SIGTERM; wait for exit
|
||||
await sub.execFail([
|
||||
'sh',
|
||||
'-c',
|
||||
'PID=$(cat /var/run/mysqld/mysqld.pid) && kill $PID && tail --pid=$PID -f /dev/null',
|
||||
], { user: 'root' }, null);
|
||||
});
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Override the default rsync options for both backup and restore.
|
||||
* @param options - Partial rsync options to merge with current defaults
|
||||
*/
|
||||
setOptions(options) {
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Override rsync options used only during backup (not restore).
|
||||
* @param options - Partial rsync options for the backup phase
|
||||
*/
|
||||
setBackupOptions(options) {
|
||||
this.backupOptions = {
|
||||
...this.backupOptions,
|
||||
...options,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Override rsync options used only during restore (not backup).
|
||||
* @param options - Partial rsync options for the restore phase
|
||||
*/
|
||||
setRestoreOptions(options) {
|
||||
this.restoreOptions = {
|
||||
...this.restoreOptions,
|
||||
...options,
|
||||
};
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Register a hook to run before backup rsync begins (e.g. dump a database).
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPreBackup(fn) {
|
||||
this.preBackup = fn;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Register a hook to run after backup rsync completes.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPostBackup(fn) {
|
||||
this.postBackup = fn;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Register a hook to run before restore rsync begins.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPreRestore(fn) {
|
||||
this.preRestore = fn;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Register a hook to run after restore rsync completes.
|
||||
* @param fn - Async function receiving backup-scoped effects
|
||||
*/
|
||||
setPostRestore(fn) {
|
||||
this.postRestore = fn;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Add a volume to the backup set by its ID.
|
||||
* @param volume - The volume ID from the manifest
|
||||
* @param options - Optional per-volume rsync overrides
|
||||
*/
|
||||
addVolume(volume, options) {
|
||||
return this.addSync({
|
||||
dataPath: `/media/startos/volumes/${volume}/`,
|
||||
backupPath: `/media/startos/backup/volumes/${volume}/`,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Add a custom sync pair to the backup set.
|
||||
* @param sync - A `{ dataPath, backupPath }` object with optional per-sync rsync options
|
||||
*/
|
||||
addSync(sync) {
|
||||
this.backupSet.push(sync);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Execute the backup: runs pre-hook, rsyncs all configured paths, saves the data version, then runs post-hook.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
async createBackup(effects) {
|
||||
await this.preBackup(effects);
|
||||
for (const item of this.backupSet) {
|
||||
const rsyncResults = await runRsync({
|
||||
srcPath: item.dataPath,
|
||||
dstPath: item.backupPath,
|
||||
options: {
|
||||
...this.options,
|
||||
...this.backupOptions,
|
||||
...item.options,
|
||||
...item.backupOptions,
|
||||
},
|
||||
});
|
||||
await rsyncResults.wait();
|
||||
}
|
||||
const dataVersion = await effects.getDataVersion();
|
||||
if (dataVersion)
|
||||
await fs.writeFile('/media/startos/backup/dataVersion.txt', dataVersion, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
await this.postBackup(effects);
|
||||
return;
|
||||
}
|
||||
async init(effects, kind) {
|
||||
if (kind === 'restore') {
|
||||
await this.restoreBackup(effects);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Execute the restore: runs pre-hook, rsyncs all configured paths from backup to data, restores the data version, then runs post-hook.
|
||||
* @param effects - The effects context
|
||||
*/
|
||||
async restoreBackup(effects) {
|
||||
await this.preRestore(effects);
|
||||
for (const item of this.backupSet) {
|
||||
const rsyncResults = await runRsync({
|
||||
srcPath: item.backupPath,
|
||||
dstPath: item.dataPath,
|
||||
options: {
|
||||
...this.options,
|
||||
...this.restoreOptions,
|
||||
...item.options,
|
||||
...item.restoreOptions,
|
||||
},
|
||||
});
|
||||
await rsyncResults.wait();
|
||||
}
|
||||
const dataVersion = await fs
|
||||
.readFile('/media/startos/backup/dataVersion.txt', {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
.catch((_) => null);
|
||||
if (dataVersion)
|
||||
await effects.setDataVersion({ version: dataVersion });
|
||||
await this.postRestore(effects);
|
||||
return;
|
||||
}
|
||||
}
|
||||
exports.Backups = Backups;
|
||||
async function runRsync(rsyncOptions) {
|
||||
const { srcPath, dstPath, options } = rsyncOptions;
|
||||
await fs.mkdir(dstPath, { recursive: true });
|
||||
const command = 'rsync';
|
||||
const args = [];
|
||||
if (options.delete) {
|
||||
args.push('--delete');
|
||||
}
|
||||
for (const exclude of options.exclude) {
|
||||
args.push(`--exclude=${exclude}`);
|
||||
}
|
||||
args.push('-rlptgoAXH');
|
||||
args.push('--partial');
|
||||
args.push('--inplace');
|
||||
args.push('--timeout=300');
|
||||
args.push('--info=progress2');
|
||||
args.push('--no-inc-recursive');
|
||||
args.push(srcPath);
|
||||
args.push(dstPath);
|
||||
const spawned = child_process.spawn(command, args, { detached: true });
|
||||
let percentage = 0.0;
|
||||
spawned.stdout.on('data', (data) => {
|
||||
const lines = String(data).replace(/\r/g, '\n').split('\n');
|
||||
for (const line of lines) {
|
||||
const parsed = /([0-9.]+)%/.exec(line)?.[1];
|
||||
if (!parsed) {
|
||||
if (line)
|
||||
console.log(line);
|
||||
continue;
|
||||
}
|
||||
percentage = Number.parseFloat(parsed);
|
||||
}
|
||||
});
|
||||
let stderr = '';
|
||||
spawned.stderr.on('data', (data) => {
|
||||
const errString = data.toString('utf-8');
|
||||
stderr += errString;
|
||||
console.error(`Backups.runAsync`, (0, util_1.asError)(errString));
|
||||
});
|
||||
const id = async () => {
|
||||
const pid = spawned.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('rsync process has no pid');
|
||||
}
|
||||
return String(pid);
|
||||
};
|
||||
const waitPromise = new Promise((resolve, reject) => {
|
||||
spawned.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(null);
|
||||
}
|
||||
else {
|
||||
reject(new Error(`rsync exited with code ${code}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
const wait = () => waitPromise;
|
||||
const progress = () => Promise.resolve(percentage);
|
||||
return { id, wait, progress };
|
||||
}
|
||||
//# sourceMappingURL=Backups.js.map
|
||||
+1
File diff suppressed because one or more lines are too long
+2
@@ -0,0 +1,2 @@
|
||||
import './Backups';
|
||||
import './setupBackups';
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
require("./Backups");
|
||||
require("./setupBackups");
|
||||
//# sourceMappingURL=index.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../package/lib/backup/index.ts"],"names":[],"mappings":";;AAAA,qBAAkB;AAClB,0BAAuB"}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Backups } from './Backups';
|
||||
import * as T from '../../../base/lib/types';
|
||||
import { InitScript } from '../../../base/lib/inits';
|
||||
/**
|
||||
* Parameters for `setupBackups`. Either:
|
||||
* - An array of volume IDs to back up entirely, or
|
||||
* - An async factory function that returns a fully configured {@link Backups} instance
|
||||
*/
|
||||
export type SetupBackupsParams<M extends T.SDKManifest> = M['volumes'][number][] | ((_: {
|
||||
effects: T.Effects;
|
||||
}) => Promise<Backups<M>>);
|
||||
type SetupBackupsRes = {
|
||||
createBackup: T.ExpectedExports.createBackup;
|
||||
restoreInit: InitScript;
|
||||
};
|
||||
/**
|
||||
* Set up backup and restore exports for the service.
|
||||
*
|
||||
* Returns `{ createBackup, restoreInit }` which should be exported and wired into
|
||||
* the service's init and backup entry points.
|
||||
*
|
||||
* @param options - Either an array of volume IDs or an async factory returning a Backups instance
|
||||
* @returns An object with `createBackup` (the backup export) and `restoreInit` (an InitScript for restore)
|
||||
*/
|
||||
export declare function setupBackups<M extends T.SDKManifest>(options: SetupBackupsParams<M>): SetupBackupsRes;
|
||||
export {};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.setupBackups = setupBackups;
|
||||
const Backups_1 = require("./Backups");
|
||||
/**
|
||||
* Set up backup and restore exports for the service.
|
||||
*
|
||||
* Returns `{ createBackup, restoreInit }` which should be exported and wired into
|
||||
* the service's init and backup entry points.
|
||||
*
|
||||
* @param options - Either an array of volume IDs or an async factory returning a Backups instance
|
||||
* @returns An object with `createBackup` (the backup export) and `restoreInit` (an InitScript for restore)
|
||||
*/
|
||||
function setupBackups(options) {
|
||||
let backupsFactory;
|
||||
if (options instanceof Function) {
|
||||
backupsFactory = options;
|
||||
}
|
||||
else {
|
||||
backupsFactory = async () => Backups_1.Backups.ofVolumes(...options);
|
||||
}
|
||||
const answer = {
|
||||
get createBackup() {
|
||||
return (async (options) => {
|
||||
return (await backupsFactory(options)).createBackup(options.effects);
|
||||
});
|
||||
},
|
||||
get restoreInit() {
|
||||
return {
|
||||
init: async (effects, kind) => {
|
||||
return (await backupsFactory({ effects })).init(effects, kind);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return answer;
|
||||
}
|
||||
//# sourceMappingURL=setupBackups.js.map
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"setupBackups.js","sourceRoot":"","sources":["../../../../package/lib/backup/setupBackups.ts"],"names":[],"mappings":";;AA4BA,oCAwBC;AApDD,uCAAmC;AAmBnC;;;;;;;;GAQG;AACH,SAAgB,YAAY,CAC1B,OAA8B;IAE9B,IAAI,cAAkE,CAAA;IACtE,IAAI,OAAO,YAAY,QAAQ,EAAE,CAAC;QAChC,cAAc,GAAG,OAAO,CAAA;IAC1B,CAAC;SAAM,CAAC;QACN,cAAc,GAAG,KAAK,IAAI,EAAE,CAAC,iBAAO,CAAC,SAAS,CAAC,GAAG,OAAO,CAAC,CAAA;IAC5D,CAAC;IACD,MAAM,MAAM,GAAoB;QAC9B,IAAI,YAAY;YACd,OAAO,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE;gBACxB,OAAO,CAAC,MAAM,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;YACtE,CAAC,CAAmC,CAAA;QACtC,CAAC;QACD,IAAI,WAAW;YACb,OAAO;gBACL,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,EAAE;oBAC5B,OAAO,CAAC,MAAM,cAAc,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;gBAChE,CAAC;aACF,CAAA;QACH,CAAC;KACF,CAAA;IACD,OAAO,MAAM,CAAA;AACf,CAAC"}
|
||||
Reference in New Issue
Block a user