diff --git a/.gitignore b/.gitignore index d0f9288..5a9b0d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,19 @@ +# Editors +.idea + +# Node node_modules/ package-lock.json -.idea yarn.lock +# Build & Runtime bin lib data +restore +docker +Dockerfile -.autorestic.yml \ No newline at end of file +# Config +.autorestic.yml +.docker.yml \ No newline at end of file diff --git a/src/autorestic.ts b/src/autorestic.ts index 6f132c1..7ee4bef 100644 --- a/src/autorestic.ts +++ b/src/autorestic.ts @@ -25,7 +25,7 @@ export const { _: commands, ...flags } = minimist(process.argv.slice(2), { string: ['l', 'b'], }) -export const VERSION = '0.12' +export const VERSION = '0.13' export const INSTALL_DIR = '/usr/local/bin' export const VERBOSE = flags.verbose diff --git a/src/backend.ts b/src/backend.ts index 9c69075..605e7fc 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -2,7 +2,7 @@ import { Writer } from 'clitastic' import { config, VERBOSE } from './autorestic' import { Backend, Backends, Locations } from './types' -import { exec, ConfigError } from './utils' +import { exec, ConfigError, pathRelativeToConfigFile } from './utils' @@ -11,7 +11,7 @@ const ALREADY_EXISTS = /(?=.*already)(?=.*config).*/ export const getPathFromBackend = (backend: Backend): string => { switch (backend.type) { case 'local': - return backend.path + return pathRelativeToConfigFile(backend.path) case 'b2': case 'azure': case 'gs': diff --git a/src/backup.ts b/src/backup.ts index 5cc1c14..6c7ada0 100644 --- a/src/backup.ts +++ b/src/backup.ts @@ -1,8 +1,10 @@ import { Writer } from 'clitastic' +import { mkdirSync } from 'fs' import { config, VERBOSE } from './autorestic' import { getEnvFromBackend } from './backend' -import { Locations, Location } from './types' +import { LocationFromPrefixes } from './config' +import { Locations, Location, Backend } from './types' import { exec, ConfigError, @@ -10,27 +12,67 @@ import { getFlagsFromLocation, makeArrayIfIsNot, execPlain, - MeasureDuration, fill, + MeasureDuration, + fill, + decodeLocationFromPrefix, + hash, checkIfDockerVolumeExistsOrFail, } from './utils' +export const backupFromFilesystem = (from: string, location: Location, backend: Backend, tags?: string[]) => { + const path = pathRelativeToConfigFile(from) + + const cmd = exec( + 'restic', + ['backup', '.', ...getFlagsFromLocation(location, 'backup')], + { env: getEnvFromBackend(backend), cwd: path }, + ) + + if (VERBOSE) console.log(cmd.out, cmd.err) +} + +export const backupFromVolume = (volume: string, location: Location, backend: Backend) => { + const tmp = pathRelativeToConfigFile(hash(volume)) + try { + mkdirSync(tmp) + checkIfDockerVolumeExistsOrFail(volume) + + // For incremental backups. Unfortunately due to how the docker mounts work the permissions get lost. + // execPlain(`docker run --rm -v ${volume}:/data -v ${tmp}:/backup alpine cp -aT /data /backup`) + execPlain(`docker run --rm -v ${volume}:/data -v ${tmp}:/backup alpine tar cf /backup/archive.tar -C /data .`) + + backupFromFilesystem(tmp, location, backend) + } finally { + execPlain(`rm -rf ${tmp}`) + } +} + export const backupSingle = (name: string, to: string, location: Location) => { if (!config) throw ConfigError const delta = new MeasureDuration() const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳') - const backend = config.backends[to] - const path = pathRelativeToConfigFile(location.from) + try { + const backend = config.backends[to] + const [type, value] = decodeLocationFromPrefix(location.from) - const cmd = exec( - 'restic', - ['backup', path, ...getFlagsFromLocation(location, 'backup')], - { env: getEnvFromBackend(backend) }, - ) + switch (type) { - if (VERBOSE) console.log(cmd.out, cmd.err) - writer.done(`${name}${to.blue} : ${'Done ✓'.green} (${delta.finished(true)})`) + case LocationFromPrefixes.Filesystem: + backupFromFilesystem(value, location, backend) + break + + case LocationFromPrefixes.DockerVolume: + backupFromVolume(value, location, backend) + break + + } + + writer.done(`${name}${to.blue} : ${'Done ✓'.green} (${delta.finished(true)})`) + } catch (e) { + writer.done(`${name}${to.blue} : ${'Failed!'.red} (${delta.finished(true)}) ${e.message}`) + } } export const backupLocation = (name: string, location: Location) => { @@ -40,8 +82,8 @@ export const backupLocation = (name: string, location: Location) => { if (location.hooks && location.hooks.before) for (const command of makeArrayIfIsNot(location.hooks.before)) { - const cmd = execPlain(command) - if (cmd) console.log(cmd.out, cmd.err) + const cmd = execPlain(command, {}) + console.log(cmd.out, cmd.err) } for (const t of makeArrayIfIsNot(location.to)) { @@ -52,7 +94,7 @@ export const backupLocation = (name: string, location: Location) => { if (location.hooks && location.hooks.after) for (const command of makeArrayIfIsNot(location.hooks.after)) { const cmd = execPlain(command) - if (cmd) console.log(cmd.out, cmd.err) + console.log(cmd.out, cmd.err) } } diff --git a/src/config.ts b/src/config.ts index 255c7f2..577e344 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,12 @@ import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils' +export enum LocationFromPrefixes { + Filesystem, + DockerVolume +} + + export const normalizeAndCheckBackends = (config: Config) => { config.backends = makeObjectKeysLowercase(config.backends) diff --git a/src/handlers.ts b/src/handlers.ts index c6ccf22..36e05a0 100644 --- a/src/handlers.ts +++ b/src/handlers.ts @@ -10,6 +10,7 @@ import { checkAndConfigureBackends, getBackendsFromLocations, getEnvFromBackend import { backupAll } from './backup' import { forgetAll } from './forget' import showAll from './info' +import { restoreSingle } from './restore' import { Backends, Flags, Locations } from './types' import { checkIfCommandIsAvailable, @@ -86,36 +87,12 @@ const handlers: Handlers = { if (!config) throw ConfigError checkIfResticIsAvailable() - if (!flags.to) { - console.log(`You need to specify the restore path with --to`.red) - return - } - const locations = parseLocations(flags) - for (const [name, location] of Object.entries(locations)) { - const baseText = name.green + '\t\t' - const w = new Writer(baseText + `Starting...`) + const keys = Object.keys(locations) + if (keys.length < 1) throw new Error(`You need to specify the location to restore with --location`.red) + if (keys.length > 2) throw new Error(`Only one location is supported at a time when restoring`.red) - let backend: string = Array.isArray(location.to) ? location.to[0] : location.to - if (flags.from) { - if (!location.to.includes(flags.from)) { - w.done(baseText + `Backend ${flags.from} is not a valid location for ${name}`.red) - continue - } - backend = flags.from - w.replaceLn(baseText + `Restoring from ${backend.blue}...`) - } else if (Array.isArray(location.to) && location.to.length > 1) { - w.replaceLn(baseText + `Restoring from ${backend.blue}...\tTo select a specific backend pass the ${'--from'.blue} flag`) - } - const env = getEnvFromBackend(config.backends[backend]) - - exec( - 'restic', - ['restore', 'latest', '--path', resolve(location.from), '--target', flags.to], - { env }, - ) - w.done(name.green + '\t\tDone 🎉') - } + restoreSingle(keys[0], flags.from, flags.to) }, forget(args, flags) { if (!config) throw ConfigError @@ -140,7 +117,7 @@ const handlers: Handlers = { console.log(out, err) } }, - async info() { + info() { showAll() }, async install() { @@ -224,7 +201,7 @@ const handlers: Handlers = { w.replaceLn('Downloading binary... 🌎') await downloadFile(dl.browser_download_url, to) - exec('chmod', ['+x', to]) + chmodSync(to, 0o755) } w.done('All up to date! 🚀') diff --git a/src/restore.ts b/src/restore.ts new file mode 100644 index 0000000..9c33e9e --- /dev/null +++ b/src/restore.ts @@ -0,0 +1,82 @@ +import { Writer } from 'clitastic' +import { resolve } from 'path' + +import { config } from './autorestic' +import { getEnvFromBackend } from './backend' +import { LocationFromPrefixes } from './config' +import { Backend } from './types' +import { + checkIfDockerVolumeExistsOrFail, + ConfigError, + decodeLocationFromPrefix, + exec, execPlain, + hash, + pathRelativeToConfigFile, +} from './utils' + + + +export const restoreToFilesystem = (from: string, to: string, backend: Backend) => { + exec( + 'restic', + ['restore', 'latest', '--path', resolve(from), '--target', to], + { env: getEnvFromBackend(backend) }, + ) +} + +export const restoreToVolume = (volume: string, backend: Backend) => { + const tmp = pathRelativeToConfigFile(hash(volume)) + + try { + restoreToFilesystem(tmp, tmp, backend) + try { + checkIfDockerVolumeExistsOrFail(volume) + } catch { + execPlain(`docker volume create ${volume}`) + } + + // For incremental backups. Unfortunately due to how the docker mounts work the permissions get lost. + // execPlain(`docker run --rm -v ${volume}:/data -v ${tmp}:/backup alpine cp -aT /backup /data`) + execPlain(`docker run --rm -v ${volume}:/data -v ${tmp}:/backup alpine tar xf /backup/archive.tar -C /data`) + } finally { + execPlain(`rm -rf ${tmp}`) + } +} + +export const restoreSingle = (locationName: string, from: string, to?: string) => { + if (!config) throw ConfigError + + const location = config.locations[locationName] + + const baseText = locationName.green + '\t\t' + const w = new Writer(baseText + `Starting...`) + + let backendName: string = Array.isArray(location.to) ? location.to[0] : location.to + if (from) { + if (!location.to.includes(from)) { + w.done(baseText + `Backend ${from} is not a valid location for ${locationName}`.red) + return + } + backendName = from + w.replaceLn(baseText + `Restoring from ${backendName.blue}...`) + } else if (Array.isArray(location.to) && location.to.length > 1) { + w.replaceLn(baseText + `Restoring from ${backendName.blue}...\tTo select a specific backend pass the ${'--from'.blue} flag`) + } + const backend = config.backends[backendName] + + const [type, value] = decodeLocationFromPrefix(location.from) + switch (type) { + + case LocationFromPrefixes.Filesystem: + if (!to) throw new Error(`You need to specify the restore path with --to`.red) + restoreToFilesystem(value, to, backend) + break + + case LocationFromPrefixes.DockerVolume: + restoreToVolume(value, backend) + break + + } + w.done(locationName.green + '\t\tDone 🎉') +} + diff --git a/src/utils.ts b/src/utils.ts index 8d3b7bb..97edea2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,23 +1,18 @@ -import { spawnSync, SpawnSyncOptions } from 'child_process' -import { randomBytes } from 'crypto' -import { createWriteStream, unlinkSync, renameSync } from 'fs' -import { dirname, isAbsolute, join, resolve } from 'path' -import { homedir, tmpdir } from 'os' - import axios from 'axios' +import { spawnSync, SpawnSyncOptions } from 'child_process' +import { createHash, randomBytes } from 'crypto' +import { createWriteStream, renameSync, unlinkSync } from 'fs' +import { homedir, tmpdir } from 'os' +import { dirname, isAbsolute, join, resolve } from 'path' import { Duration, Humanizer } from 'uhrwerk' -import { CONFIG_FILE } from './config' +import { CONFIG_FILE, LocationFromPrefixes } from './config' import { Location } from './types' -export const exec = ( - command: string, - args: string[], - { env, ...rest }: SpawnSyncOptions = {}, -) => { - const cmd = spawnSync(command, args, { +export const exec = (command: string, args: string[], { env, ...rest }: SpawnSyncOptions = {}) => { + const { stdout, stderr, status } = spawnSync(command, args, { ...rest, env: { ...process.env, @@ -25,18 +20,15 @@ export const exec = ( }, }) - const out = cmd.stdout && cmd.stdout.toString().trim() - const err = cmd.stderr && cmd.stderr.toString().trim() + const out = stdout && stdout.toString().trim() + const err = stderr && stderr.toString().trim() - return { out, err } + return { out, err, status } } export const execPlain = (command: string, opt: SpawnSyncOptions = {}) => { const split = command.split(' ') - if (split.length < 1) { - console.log(`The command ${command} is not valid`.red) - return - } + if (split.length < 1) throw new Error(`The command ${command} is not valid`.red) return exec(split[0], split.slice(1), opt) } @@ -175,3 +167,31 @@ export class MeasureDuration { } } + + +export const decodeLocationFromPrefix = (from: string): [LocationFromPrefixes, string] => { + const firstDelimiter = from.indexOf(':') + if (firstDelimiter === -1) return [LocationFromPrefixes.Filesystem, from] + + const type = from.substr(0, firstDelimiter) + const value = from.substr(firstDelimiter + 1) + + switch (type.toLowerCase()) { + case 'volume': + return [LocationFromPrefixes.DockerVolume, value] + case 'path': + return [LocationFromPrefixes.Filesystem, value] + default: + throw new Error(`Could not decode the location from: ${from}`.red) + } +} + +export const hash = (plain: string): string => createHash('sha1').update(plain).digest().toString('hex') + +export const checkIfDockerVolumeExistsOrFail = (volume: string) => { + const cmd = exec('docker', [ + 'volume', 'inspect', volume, + ]) + if (cmd.err.length > 0) + throw new Error('Volume not found') +} \ No newline at end of file