rewrite with commander

This commit is contained in:
2020-11-06 23:51:23 +01:00
parent e9a7e03af7
commit 60d7e0b561
27 changed files with 702 additions and 766 deletions

View File

@@ -1,63 +0,0 @@
import 'colors'
import minimist from 'minimist'
import { init } from './config'
import handlers, { error, help } from './handlers'
import { Config } from './types'
import { readLock, writeLock, unlock } from './lock'
process.on('uncaughtException', (err) => {
console.log(err.message)
unlock()
process.exit(1)
})
export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
alias: {
c: 'config',
v: 'version',
h: 'help',
a: 'all',
l: 'location',
b: 'backend',
d: 'dry-run',
},
boolean: ['a', 'd'],
string: ['l', 'b'],
})
export const VERSION = '0.20'
export const INSTALL_DIR = '/usr/local/bin'
export const VERBOSE = flags.verbose
export let config: Config
async function main() {
config = init()
// Don't let 2 instances run on the same config
const lock = readLock()
if (lock.running) {
console.log('An instance of autorestic is already running for this config file'.red)
return
}
writeLock({
...lock,
running: true,
})
// For dev
// return await handlers['cron']([], { ...flags, all: true })
if (commands.length < 1 || commands[0] === 'help') return help()
const command: string = commands[0]
const args: string[] = commands.slice(1)
const fn = handlers[command] || error
await fn(args, flags)
}
main()
.catch((e: Error) => console.error(e.message))
.finally(unlock)

View File

@@ -1,74 +1,66 @@
import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic'
import { config, VERBOSE } from './'
import { Backend, Backends, Locations } from './types'
import { exec, pathRelativeToConfigFile, filterObjectByKey } from './utils'
const ALREADY_EXISTS = /(?=.*already)(?=.*config).*/
export const getPathFromBackend = (backend: Backend): string => {
switch (backend.type) {
case 'local':
return pathRelativeToConfigFile(backend.path)
case 'b2':
case 'azure':
case 'gs':
case 's3':
case 'sftp':
case 'rest':
return `${backend.type}:${backend.path}`
default:
throw new Error(`Unknown backend type.`)
}
switch (backend.type) {
case 'local':
return pathRelativeToConfigFile(backend.path)
case 'b2':
case 'azure':
case 'gs':
case 's3':
case 'sftp':
case 'rest':
return `${backend.type}:${backend.path}`
default:
throw new Error(`Unknown backend type.`)
}
}
export const getEnvFromBackend = (backend: Backend) => {
const { type, path, key, ...rest } = backend
return {
RESTIC_PASSWORD: key,
RESTIC_REPOSITORY: getPathFromBackend(backend),
...rest,
}
const { type, path, key, ...rest } = backend
return {
RESTIC_PASSWORD: key,
RESTIC_REPOSITORY: getPathFromBackend(backend),
...rest,
}
}
export const getBackendsFromLocations = (locations: Locations): string[] => {
const backends = new Set<string>()
for (const to of Object.values(locations).map(location => location.to))
Array.isArray(to) ? to.forEach(t => backends.add(t)) : backends.add(to)
return Array.from(backends)
const backends = new Set<string>()
for (const to of Object.values(locations).map((location) => location.to)) Array.isArray(to) ? to.forEach((t) => backends.add(t)) : backends.add(to)
return Array.from(backends)
}
export const checkAndConfigureBackend = (name: string, backend: Backend) => {
const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳')
try {
const env = getEnvFromBackend(backend)
const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳')
try {
const env = getEnvFromBackend(backend)
const { out, err } = exec('restic', ['init'], { env })
const { out, err } = exec('restic', ['init'], { env })
if (err.length > 0 && !ALREADY_EXISTS.test(err))
throw new Error(`Could not load the backend "${name}": ${err}`)
if (err.length > 0 && !ALREADY_EXISTS.test(err)) throw new Error(`Could not load the backend "${name}": ${err}`)
if (VERBOSE && out.length > 0) console.log(out)
if (VERBOSE && out.length > 0) console.log(out)
writer.done(name.blue + ' : ' + 'Done ✓'.green)
} catch (e) {
writer.done(name.blue + ' : ' + 'Error ⚠️ ' + e.message.red)
}
writer.done(name.blue + ' : ' + 'Done ✓'.green)
} catch (e) {
writer.done(name.blue + ' : ' + 'Error ⚠️ ' + e.message.red)
}
}
export const checkAndConfigureBackends = (backends?: Backends) => {
if (!backends)
backends = config.backends
if (!backends) backends = config.backends
console.log('\nConfiguring Backends'.grey.underline)
for (const [name, backend] of Object.entries(backends))
checkAndConfigureBackend(name, backend)
console.log('\nConfiguring Backends'.grey.underline)
for (const [name, backend] of Object.entries(backends)) checkAndConfigureBackend(name, backend)
}
export const checkAndConfigureBackendsForLocations = (locations: Locations) => {
checkAndConfigureBackends(
filterObjectByKey(config.backends, getBackendsFromLocations(locations)),
)
checkAndConfigureBackends(filterObjectByKey(config.backends, getBackendsFromLocations(locations)))
}

View File

@@ -1,111 +1,103 @@
import { Writer } from 'clitastic'
import { mkdirSync } from 'fs'
import { config, VERBOSE } from './autorestic'
import { config, VERBOSE } from './'
import { getEnvFromBackend } from './backend'
import { LocationFromPrefixes } from './config'
import { Locations, Location, Backend } from './types'
import {
exec,
pathRelativeToConfigFile,
getFlagsFromLocation,
makeArrayIfIsNot,
execPlain,
MeasureDuration,
fill,
decodeLocationFromPrefix,
checkIfDockerVolumeExistsOrFail,
getPathFromVolume,
exec,
pathRelativeToConfigFile,
getFlagsFromLocation,
makeArrayIfIsNot,
execPlain,
MeasureDuration,
fill,
decodeLocationFromPrefix,
checkIfDockerVolumeExistsOrFail,
getPathFromVolume,
} from './utils'
export const backupFromFilesystem = (from: string, location: Location, backend: Backend, tags?: string[]) => {
const path = pathRelativeToConfigFile(from)
const path = pathRelativeToConfigFile(from)
const { out, err, status } = exec(
'restic',
['backup', '.', ...getFlagsFromLocation(location, 'backup')],
{ env: getEnvFromBackend(backend), cwd: path },
)
const { out, err, status } = exec('restic', ['backup', '.', ...getFlagsFromLocation(location, 'backup')], {
env: getEnvFromBackend(backend),
cwd: path,
})
if (VERBOSE) console.log(out, err)
if (status != 0 || err.length > 0)
throw new Error(err)
if (VERBOSE) console.log(out, err)
if (status != 0 || err.length > 0) throw new Error(err)
}
export const backupFromVolume = (volume: string, location: Location, backend: Backend) => {
const tmp = getPathFromVolume(volume)
try {
mkdirSync(tmp)
checkIfDockerVolumeExistsOrFail(volume)
const tmp = getPathFromVolume(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 .`)
// 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)
} catch (e) {
throw e
} finally {
execPlain(`rm -rf ${tmp}`)
}
backupFromFilesystem(tmp, location, backend)
} catch (e) {
throw e
} finally {
execPlain(`rm -rf ${tmp}`)
}
}
export const backupSingle = (name: string, to: string, location: Location) => {
const delta = new MeasureDuration()
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
const delta = new MeasureDuration()
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
try {
const backend = config.backends[to]
const [type, value] = decodeLocationFromPrefix(location.from)
try {
const backend = config.backends[to]
const [type, value] = decodeLocationFromPrefix(location.from)
switch (type) {
switch (type) {
case LocationFromPrefixes.Filesystem:
backupFromFilesystem(value, location, backend)
break
case LocationFromPrefixes.Filesystem:
backupFromFilesystem(value, location, backend)
break
case LocationFromPrefixes.DockerVolume:
backupFromVolume(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}`)
}
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) => {
const display = name.yellow + ' ▶ '
const filler = fill(name.length + 3)
let first = true
const display = name.yellow + ' ▶ '
const filler = fill(name.length + 3)
let first = true
if (location.hooks && location.hooks.before)
for (const command of makeArrayIfIsNot(location.hooks.before)) {
const cmd = execPlain(command, {})
console.log(cmd.out, cmd.err)
}
if (location.hooks && location.hooks.before)
for (const command of makeArrayIfIsNot(location.hooks.before)) {
const cmd = execPlain(command, {})
console.log(cmd.out, cmd.err)
}
for (const t of makeArrayIfIsNot(location.to)) {
backupSingle(first ? display : filler, t, location)
if (first) first = false
}
for (const t of makeArrayIfIsNot(location.to)) {
backupSingle(first ? display : filler, t, location)
if (first) first = false
}
if (location.hooks && location.hooks.after)
for (const command of makeArrayIfIsNot(location.hooks.after)) {
const cmd = execPlain(command)
console.log(cmd.out, cmd.err)
}
if (location.hooks && location.hooks.after)
for (const command of makeArrayIfIsNot(location.hooks.after)) {
const cmd = execPlain(command)
console.log(cmd.out, cmd.err)
}
}
export const backupAll = (locations?: Locations) => {
if (!locations)
locations = config.locations
if (!locations) locations = config.locations
console.log('\nBacking Up'.underline.grey)
for (const [name, location] of Object.entries(locations))
backupLocation(name, location)
console.log('\nBacking Up'.underline.grey)
for (const [name, location] of Object.entries(locations)) backupLocation(name, location)
}

View File

@@ -5,7 +5,6 @@ import { homedir } from 'os'
import yaml from 'js-yaml'
import CronParser from 'cron-parser'
import { flags } from './autorestic'
import { Backend, Config } from './types'
import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils'
@@ -56,9 +55,9 @@ export const normalizeAndCheckLocations = (config: Config) => {
}
}
const findConfigFile = (): string => {
const findConfigFile = (custom: string): string => {
const config = '.autorestic.yml'
const paths = [resolve(flags.config || ''), resolve('./' + config), homedir() + '/' + config]
const paths = [resolve(custom || ''), resolve('./' + config), homedir() + '/' + config]
for (const path of paths) {
try {
const file = statSync(path)
@@ -70,8 +69,8 @@ const findConfigFile = (): string => {
export let CONFIG_FILE: string = ''
export const init = (): Config => {
const file = findConfigFile()
export const init = (custom: string): Config => {
const file = findConfigFile(custom)
CONFIG_FILE = file
const parsed = yaml.safeLoad(readFileSync(CONFIG_FILE).toString())

View File

@@ -1,12 +1,11 @@
import CronParser from 'cron-parser'
import { config } from './autorestic'
import { config } from './'
import { checkAndConfigureBackendsForLocations } from './backend'
import { Location } from './types'
import { backupLocation } from './backup'
import { readLock, writeLock } from './lock'
const runCronForLocation = (name: string, location: Location) => {
const lock = readLock()
const parsed = CronParser.parseExpression(location.cron || '')
@@ -26,8 +25,7 @@ export const runCron = () => {
checkAndConfigureBackendsForLocations(Object.fromEntries(locationsWithCron))
console.log('\nRunning cron jobs'.underline.gray)
for (const [name, location] of locationsWithCron)
runCronForLocation(name, location)
for (const [name, location] of locationsWithCron) runCronForLocation(name, location)
console.log('\nFinished!'.underline + ' 🎉')
}

View File

@@ -1,75 +1,61 @@
import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic'
import { config, VERBOSE } from './'
import { getEnvFromBackend } from './backend'
import { LocationFromPrefixes } from './config'
import { Locations, Location, Flags } from './types'
import {
exec,
pathRelativeToConfigFile,
getFlagsFromLocation,
makeArrayIfIsNot,
fill, decodeLocationFromPrefix, getPathFromVolume,
} from './utils'
import { exec, pathRelativeToConfigFile, getFlagsFromLocation, makeArrayIfIsNot, fill, decodeLocationFromPrefix, getPathFromVolume } from './utils'
export const forgetSingle = (name: string, to: string, location: Location, dryRun: boolean) => {
const base = name + to.blue + ' : '
const writer = new Writer(base + 'Removing old snapshots… ⏳')
const base = name + to.blue + ' : '
const writer = new Writer(base + 'Removing old snapshots… ⏳')
const backend = config.backends[to]
const flags = getFlagsFromLocation(location, 'forget')
const backend = config.backends[to]
const flags = getFlagsFromLocation(location, 'forget')
const [type, value] = decodeLocationFromPrefix(location.from)
let path: string
switch (type) {
case LocationFromPrefixes.Filesystem:
path = pathRelativeToConfigFile(value)
break
case LocationFromPrefixes.DockerVolume:
path = getPathFromVolume(value)
break
}
const [type, value] = decodeLocationFromPrefix(location.from)
let path: string
switch (type) {
case LocationFromPrefixes.Filesystem:
path = pathRelativeToConfigFile(value)
break
case LocationFromPrefixes.DockerVolume:
path = getPathFromVolume(value)
break
}
if (flags.length == 0) {
writer.done(base + 'Skipping, no policy declared')
return
}
if (dryRun) flags.push('--dry-run')
if (flags.length == 0) {
writer.done(base + 'Skipping, no policy declared')
return
}
if (dryRun) flags.push('--dry-run')
writer.replaceLn(base + 'Forgetting old snapshots… ⏳')
const cmd = exec(
'restic',
['forget', '--path', path, '--prune', ...flags],
{ env: getEnvFromBackend(backend) },
)
writer.replaceLn(base + 'Forgetting old snapshots… ⏳')
const cmd = exec('restic', ['forget', '--path', path, '--prune', ...flags], { env: getEnvFromBackend(backend) })
if (VERBOSE) console.log(cmd.out, cmd.err)
writer.done(base + 'Done ✓'.green)
if (VERBOSE) console.log(cmd.out, cmd.err)
writer.done(base + 'Done ✓'.green)
}
export const forgetLocation = (name: string, backup: Location, dryRun: boolean) => {
const display = name.yellow + ' ▶ '
const filler = fill(name.length + 3)
let first = true
const display = name.yellow + ' ▶ '
const filler = fill(name.length + 3)
let first = true
for (const t of makeArrayIfIsNot(backup.to)) {
const nameOrBlankSpaces: string = first ? display : filler
forgetSingle(nameOrBlankSpaces, t, backup, dryRun)
if (first) first = false
}
for (const t of makeArrayIfIsNot(backup.to)) {
const nameOrBlankSpaces: string = first ? display : filler
forgetSingle(nameOrBlankSpaces, t, backup, dryRun)
if (first) first = false
}
}
export const forgetAll = (backups?: Locations, flags?: Flags) => {
if (!backups) {
backups = config.locations
}
export const forgetAll = (backups?: Locations, dryRun = false) => {
if (!backups) {
backups = config.locations
}
console.log('\nRemoving old snapshots according to policy'.underline.grey)
const dryRun = flags ? flags['dry-run'] : false
if (dryRun) console.log('Running in dry-run mode, not touching data\n'.yellow)
console.log('\nRemoving old snapshots according to policy'.underline.grey)
if (dryRun) console.log('Running in dry-run mode, not touching data\n'.yellow)
for (const [name, backup] of Object.entries(backups))
forgetLocation(name, backup, dryRun)
for (const [name, backup] of Object.entries(backups)) forgetLocation(name, backup, dryRun)
}

View File

@@ -1,244 +0,0 @@
import { chmodSync, renameSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import { join, resolve } from 'path'
import axios from 'axios'
import { Writer } from 'clitastic'
import { config, INSTALL_DIR, VERSION } from './autorestic'
import { checkAndConfigureBackends, getEnvFromBackend, checkAndConfigureBackendsForLocations } from './backend'
import { backupAll } from './backup'
import { runCron } from './cron'
import { forgetAll } from './forget'
import showAll from './info'
import { restoreSingle } from './restore'
import { Backends, Flags, Locations } from './types'
import {
checkIfCommandIsAvailable,
checkIfResticIsAvailable,
downloadFile,
exec,
filterObjectByKey,
makeArrayIfIsNot,
} from './utils'
export type Handlers = {
[command: string]: (args: string[], flags: Flags) => void
}
const parseBackend = (flags: Flags): Backends => {
if (!flags.all && !flags.backend)
throw new Error(
'No backends specified.'.red +
'\n--all [-a]\t\t\t\tCheck all.' +
'\n--backend [-b] myBackend\t\tSpecify one or more backend',
)
if (flags.all) return config.backends
else {
const backends = makeArrayIfIsNot<string>(flags.backend)
for (const backend of backends)
if (!config.backends[backend])
throw new Error('Invalid backend: '.red + backend)
return filterObjectByKey(config.backends, backends)
}
}
const parseLocations = (flags: Flags): Locations => {
if (!flags.all && !flags.location)
throw new Error(
'No locations specified.'.red +
'\n--all [-a]\t\t\t\tBackup all.' +
'\n--location [-l] site1\t\t\tSpecify one or more locations',
)
if (flags.all) {
return config.locations
} else {
const locations = makeArrayIfIsNot<string>(flags.location)
for (const location of locations)
if (!config.locations[location])
throw new Error('Invalid location: '.red + location)
return filterObjectByKey(config.locations, locations)
}
}
const handlers: Handlers = {
check(args, flags) {
checkIfResticIsAvailable()
const backends = parseBackend(flags)
checkAndConfigureBackends(backends)
},
backup(args, flags) {
checkIfResticIsAvailable()
const locations: Locations = parseLocations(flags)
checkAndConfigureBackendsForLocations(locations)
backupAll(locations)
console.log('\nFinished!'.underline + ' 🎉')
},
cron(args, flags) {
checkIfResticIsAvailable()
runCron()
},
restore(args, flags) {
checkIfResticIsAvailable()
const locations = parseLocations(flags)
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)
restoreSingle(keys[0], flags.from, flags.to)
},
forget(args, flags) {
checkIfResticIsAvailable()
const locations: Locations = parseLocations(flags)
checkAndConfigureBackendsForLocations(locations)
forgetAll(locations, flags)
console.log('\nFinished!'.underline + ' 🎉')
},
exec(args, flags) {
checkIfResticIsAvailable()
const backends = parseBackend(flags)
for (const [name, backend] of Object.entries(backends)) {
console.log(`\n${name}:\n`.grey.underline)
const env = getEnvFromBackend(backend)
const { out, err } = exec('restic', args, { env })
console.log(out, err)
}
},
info() {
showAll()
},
async install() {
try {
checkIfResticIsAvailable()
console.log('Restic is already installed')
return
} catch {
}
const w = new Writer('Checking latest version... ⏳')
checkIfCommandIsAvailable('bzip2')
const { data: json } = await axios({
method: 'get',
url: 'https://api.github.com/repos/restic/restic/releases/latest',
responseType: 'json',
})
const archMap: { [a: string]: string } = {
x32: '386',
x64: 'amd64',
}
w.replaceLn('Downloading binary... 🌎')
const name = `${json.name.replace(' ', '_')}_${process.platform}_${archMap[process.arch]}.bz2`
const dl = json.assets.find((asset: any) => asset.name === name)
if (!dl)
return console.log(
'Cannot get the right binary.'.red,
'Please see https://bit.ly/2Y1Rzai',
)
const tmp = join(tmpdir(), name)
const extracted = tmp.slice(0, -4) //without the .bz2
await downloadFile(dl.browser_download_url, tmp)
w.replaceLn('Decompressing binary... 📦')
exec('bzip2', ['-dk', tmp])
unlinkSync(tmp)
w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`)
chmodSync(extracted, 0o755)
renameSync(extracted, INSTALL_DIR + '/restic')
w.done(
`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉',
)
},
uninstall() {
for (const bin of ['restic', 'autorestic'])
try {
unlinkSync(INSTALL_DIR + '/' + bin)
console.log(`Finished! ${bin} was uninstalled`)
} catch (e) {
console.log(`${bin} is already uninstalled`.red)
}
},
async update() {
checkIfResticIsAvailable()
const w = new Writer('Checking for latest restic version... ⏳')
exec('restic', ['self-update'])
w.replaceLn('Checking for latest autorestic version... ⏳')
const { data: json } = await axios({
method: 'get',
url:
'https://api.github.com/repos/cupcakearmy/autorestic/releases/latest',
responseType: 'json',
})
if (json.tag_name != VERSION) {
const platformMap: { [key: string]: string } = {
darwin: 'macos',
}
const name = `autorestic_${platformMap[process.platform] || process.platform}_${process.arch}`
const dl = json.assets.find((asset: any) => asset.name === name)
const to = INSTALL_DIR + '/autorestic'
w.replaceLn('Downloading binary... 🌎')
await downloadFile(dl.browser_download_url, to)
chmodSync(to, 0o755)
}
w.done('All up to date! 🚀')
},
version() {
console.log('version'.grey, VERSION)
},
}
export const help = () => {
console.log(
'\nAutorestic'.blue +
` - ${VERSION} - Easy Restic CLI Utility` +
'\n' +
'\nOptions:'.yellow +
`\n -c, --config Specify config file. Default: .autorestic.yml` +
'\n' +
'\nCommands:'.yellow +
'\n info Show all locations and backends' +
'\n check [-b, --backend] [-a, --all] Check backends' +
'\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
'\n forget [-l, --location] [-a, --all] [--dry-run] Forget old snapshots according to declared policies' +
'\n restore [-l, --location] [--from backend] [--to <out dir>] Restore all or specified locations' +
'\n' +
'\n exec [-b, --backend] [-a, --all] <command> -- [native options] Execute native restic command' +
'\n' +
'\n install install restic' +
'\n uninstall uninstall restic' +
'\n update update restic' +
'\n help Show help' +
'\n' +
'\nExamples: '.yellow +
'https://git.io/Jf0x6' +
'\n',
)
}
export const error = () => {
help()
console.log(
`Invalid Command:`.red.underline,
`${process.argv.slice(2).join(' ')}`,
)
}
export default handlers

13
src/handlers/backup.ts Normal file
View File

@@ -0,0 +1,13 @@
import { checkAndConfigureBackendsForLocations } from '../backend'
import { backupAll } from '../backup'
import { Flags, Locations } from '../types'
import { checkIfResticIsAvailable, parseLocations } from '../utils'
export default function backup({ location, all }: Flags) {
checkIfResticIsAvailable()
const locations: Locations = parseLocations(location, all)
checkAndConfigureBackendsForLocations(locations)
backupAll(locations)
console.log('\nFinished!'.underline + ' 🎉')
}

9
src/handlers/check.ts Normal file
View File

@@ -0,0 +1,9 @@
import { checkAndConfigureBackends } from '../backend'
import { Flags } from '../types'
import { checkIfResticIsAvailable, parseBackend } from '../utils'
export default function check({ backend, all }: Flags) {
checkIfResticIsAvailable()
const backends = parseBackend(backend, all)
checkAndConfigureBackends(backends)
}

7
src/handlers/cron.ts Normal file
View File

@@ -0,0 +1,7 @@
import { runCron } from '../cron'
import { checkIfResticIsAvailable } from '../utils'
export function cron() {
checkIfResticIsAvailable()
runCron()
}

14
src/handlers/exec.ts Normal file
View File

@@ -0,0 +1,14 @@
import { getEnvFromBackend } from '../backend'
import { Flags } from '../types'
import { checkIfResticIsAvailable, exec as execCLI, parseBackend } from '../utils'
export default function exec({ backend, all }: Flags, args: string[]) {
checkIfResticIsAvailable()
const backends = parseBackend(backend, all)
for (const [name, backend] of Object.entries(backends)) {
console.log(`\n${name}:\n`.grey.underline)
const env = getEnvFromBackend(backend)
const { out, err } = execCLI('restic', args, { env })
console.log(out, err)
}
}

13
src/handlers/forget.ts Normal file
View File

@@ -0,0 +1,13 @@
import { checkAndConfigureBackendsForLocations } from '../backend'
import { forgetAll } from '../forget'
import { Flags, Locations } from '../types'
import { checkIfResticIsAvailable, parseLocations } from '../utils'
export default function forget({ location, all, dryRun }: Flags) {
checkIfResticIsAvailable()
const locations: Locations = parseLocations(location, all)
checkAndConfigureBackendsForLocations(locations)
forgetAll(locations, dryRun)
console.log('\nFinished!'.underline + ' 🎉')
}

18
src/handlers/info.ts Normal file
View File

@@ -0,0 +1,18 @@
import { config } from '../'
import { fill, treeToString } from '../utils'
const showAll = () => {
console.log('\n\n' + fill(32, '_') + 'LOCATIONS:'.underline)
for (const [key, data] of Object.entries(config.locations)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(data, ['to:', 'from:', 'hooks:', 'options:', 'cron:']))
}
console.log('\n\n' + fill(32, '_') + 'BACKENDS:'.underline)
for (const [key, data] of Object.entries(config.backends)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(data, ['type:', 'path:', 'key:']))
}
}
export default showAll

50
src/handlers/install.ts Normal file
View File

@@ -0,0 +1,50 @@
import { join } from 'path'
import { chmodSync, renameSync, unlinkSync } from 'fs'
import { tmpdir } from 'os'
import axios from 'axios'
import { Writer } from 'clitastic'
import { INSTALL_DIR } from '..'
import { checkIfCommandIsAvailable, checkIfResticIsAvailable, downloadFile, exec } from '../utils'
export default async function install() {
try {
checkIfResticIsAvailable()
console.log('Restic is already installed')
return
} catch {}
const w = new Writer('Checking latest version... ⏳')
checkIfCommandIsAvailable('bzip2')
const { data: json } = await axios({
method: 'get',
url: 'https://api.github.com/repos/restic/restic/releases/latest',
responseType: 'json',
})
const archMap: { [a: string]: string } = {
x32: '386',
x64: 'amd64',
}
w.replaceLn('Downloading binary... 🌎')
const name = `${json.name.replace(' ', '_')}_${process.platform}_${archMap[process.arch]}.bz2`
const dl = json.assets.find((asset: any) => asset.name === name)
if (!dl) return console.log('Cannot get the right binary.'.red, 'Please see https://bit.ly/2Y1Rzai')
const tmp = join(tmpdir(), name)
const extracted = tmp.slice(0, -4) //without the .bz2
await downloadFile(dl.browser_download_url, tmp)
w.replaceLn('Decompressing binary... 📦')
exec('bzip2', ['-dk', tmp])
unlinkSync(tmp)
w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`)
chmodSync(extracted, 0o755)
renameSync(extracted, INSTALL_DIR + '/restic')
w.done(`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉')
}

9
src/handlers/restore.ts Normal file
View File

@@ -0,0 +1,9 @@
import { restoreSingle } from '../restore'
import { Flags } from '../types'
import { checkIfResticIsAvailable, checkIfValidLocation } from '../utils'
export default function restore({ location, to, from }: Flags) {
checkIfResticIsAvailable()
checkIfValidLocation(location)
restoreSingle(location, from, to)
}

13
src/handlers/uninstall.ts Normal file
View File

@@ -0,0 +1,13 @@
import { unlinkSync } from 'fs'
import { INSTALL_DIR } from '..'
export function uninstall() {
for (const bin of ['restic', 'autorestic'])
try {
unlinkSync(INSTALL_DIR + '/' + bin)
console.log(`Finished! ${bin} was uninstalled`)
} catch (e) {
console.log(`${bin} is already uninstalled`.red)
}
}

37
src/handlers/upgrade.ts Normal file
View File

@@ -0,0 +1,37 @@
import { chmodSync } from 'fs'
import axios from 'axios'
import { Writer } from 'clitastic'
import { INSTALL_DIR, VERSION } from '..'
import { checkIfResticIsAvailable, downloadFile, exec } from '../utils'
export async function upgrade() {
checkIfResticIsAvailable()
const w = new Writer('Checking for latest restic version... ⏳')
exec('restic', ['self-update'])
w.replaceLn('Checking for latest autorestic version... ⏳')
const { data: json } = await axios({
method: 'get',
url: 'https://api.github.com/repos/cupcakearmy/autorestic/releases/latest',
responseType: 'json',
})
if (json.tag_name != VERSION) {
const platformMap: { [key: string]: string } = {
darwin: 'macos',
}
const name = `autorestic_${platformMap[process.platform] || process.platform}_${process.arch}`
const dl = json.assets.find((asset: any) => asset.name === name)
const to = INSTALL_DIR + '/autorestic'
w.replaceLn('Downloading binary... 🌎')
await downloadFile(dl.browser_download_url, to)
chmodSync(to, 0o755)
}
w.done('All up to date! 🚀')
}

104
src/index.ts Normal file
View File

@@ -0,0 +1,104 @@
import 'colors'
import { program } from 'commander'
import { unlock, readLock, writeLock } from './lock'
import { Config } from './types'
import { init } from './config'
import info from './handlers/info'
import check from './handlers/check'
import backup from './handlers/backup'
import restore from './handlers/restore'
import forget from './handlers/forget'
import { cron } from './handlers/cron'
import exec from './handlers/exec'
import install from './handlers/install'
import { uninstall } from './handlers/uninstall'
import { upgrade } from './handlers/upgrade'
export const VERSION = '0.20'
export const INSTALL_DIR = '/usr/local/bin'
process.on('uncaughtException', (err) => {
console.log(err.message)
unlock()
process.exit(1)
})
let queue: Function = () => {}
const enqueue = (fn: Function) => (cmd: any) => {
queue = () => fn(cmd.opts())
}
program.storeOptionsAsProperties()
program.name('autorestic').version(VERSION)
program.option('-c, --config <path>', 'Config file').option('-v, --verbose', 'Verbosity', false)
program.command('info').action(enqueue(info))
program
.command('check')
.description('Checks and initializes backend as needed')
.option('-b, --backend <backends...>')
.option('-a, --all')
.action(enqueue(check))
program.command('backup').description('Performs a backup').option('-b, --backend <backends...>').option('-a, --all').action(enqueue(backup))
program
.command('restore')
.description('Restores data to a specified folder from a location')
.requiredOption('-l, --location <location>')
.option('--from <backend>')
.requiredOption('--to <path>', 'Path to save the restored data to')
.action(enqueue(restore))
program
.command('forget')
.description('This will prune and remove data according to your policies')
.option('-l, --location <locations...>')
.option('-a, --all')
.option('--dry-run')
.action(enqueue(forget))
program
.command('cron')
.description('Intended to be triggered by an automated system like systemd or crontab.')
.option('-a, --all')
.action(enqueue(cron))
program
.command('exec')
.description('Run any native restic command on desired backends')
.option('-b, --backend <backends...>')
.option('-a, --all')
.action(({ args, all, backend }) => {
queue = () => exec({ all, backend }, args)
})
program.command('install').description('Installs both restic and autorestic to /usr/local/bin').action(enqueue(install))
program.command('uninstall').description('Uninstalls autorestic from the system').action(enqueue(uninstall))
program.command('upgrade').alias('update').description('Checks and installs new autorestic versions').action(enqueue(upgrade))
const { verbose, config: configFile } = program.parse(process.argv)
export const VERBOSE = verbose
export let config: Config = init(configFile)
try {
const lock = readLock()
if (lock.running) throw new Error('An instance of autorestic is already running for this config file'.red)
writeLock({
...lock,
running: true,
})
queue()
} catch (e) {
console.error(e.message)
} finally {
unlock()
}

View File

@@ -1,26 +1,18 @@
import { config } from './autorestic'
import { config } from './'
import { fill, treeToString } from './utils'
const showAll = () => {
console.log('\n\n' + fill(32, '_') + 'LOCATIONS:'.underline)
for (const [key, data] of Object.entries(config.locations)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(
data,
['to:', 'from:', 'hooks:', 'options:', 'cron:'],
))
}
console.log('\n\n' + fill(32, '_') + 'LOCATIONS:'.underline)
for (const [key, data] of Object.entries(config.locations)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(data, ['to:', 'from:', 'hooks:', 'options:', 'cron:']))
}
console.log('\n\n' + fill(32, '_') + 'BACKENDS:'.underline)
for (const [key, data] of Object.entries(config.backends)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(
data,
['type:', 'path:', 'key:'],
))
}
console.log('\n\n' + fill(32, '_') + 'BACKENDS:'.underline)
for (const [key, data] of Object.entries(config.backends)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(data, ['type:', 'path:', 'key:']))
}
}
export default showAll
export default showAll

View File

@@ -1,7 +1,7 @@
import fs from 'fs'
import { pathRelativeToConfigFile } from "./utils"
import { Lockfile } from "./types"
import { pathRelativeToConfigFile } from './utils'
import { Lockfile } from './types'
export const getLockFileName = () => {
const LOCK_FILE = '.autorestic.lock'
@@ -12,11 +12,11 @@ export const readLock = (): Lockfile => {
const name = getLockFileName()
let lock = {
running: false,
crons: {}
crons: {},
}
try {
lock = JSON.parse(fs.readFileSync(name, { encoding: 'utf-8' }))
} catch { }
} catch {}
return lock
}
export const writeLock = (lock: Lockfile) => {
@@ -29,4 +29,4 @@ export const unlock = () => {
...readLock(),
running: false,
})
}
}

View File

@@ -1,78 +1,63 @@
import { Writer } from 'clitastic'
import { resolve } from 'path'
import { config } from './autorestic'
import { config } from './'
import { getEnvFromBackend } from './backend'
import { LocationFromPrefixes } from './config'
import { Backend } from './types'
import {
checkIfDockerVolumeExistsOrFail,
decodeLocationFromPrefix,
exec,
execPlain,
getPathFromVolume,
} from './utils'
import { checkIfDockerVolumeExistsOrFail, decodeLocationFromPrefix, exec, execPlain, getPathFromVolume } from './utils'
export const restoreToFilesystem = (from: string, to: string, backend: Backend) => {
exec(
'restic',
['restore', 'latest', '--path', resolve(from), '--target', to],
{ env: getEnvFromBackend(backend) },
)
exec('restic', ['restore', 'latest', '--path', resolve(from), '--target', to], { env: getEnvFromBackend(backend) })
}
export const restoreToVolume = (volume: string, backend: Backend) => {
const tmp = getPathFromVolume(volume)
try {
restoreToFilesystem(tmp, tmp, backend)
try {
checkIfDockerVolumeExistsOrFail(volume)
} catch {
execPlain(`docker volume create ${volume}`)
}
const tmp = getPathFromVolume(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}`)
}
// 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) => {
const location = config.locations[locationName]
const location = config.locations[locationName]
const baseText = locationName.green + '\t\t'
const w = new Writer(baseText + `Restoring...`)
const baseText = locationName.green + '\t\t'
const w = new Writer(baseText + `Restoring...`)
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]
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) {
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.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 🎉')
case LocationFromPrefixes.DockerVolume:
restoreToVolume(value, backend)
break
}
w.done(locationName.green + '\t\tDone 🎉')
}

View File

@@ -3,84 +3,77 @@ export type StringOrArray = string | string[]
// BACKENDS
type BackendLocal = {
type: 'local'
key: string
path: string
type: 'local'
key: string
path: string
}
type BackendSFTP = {
type: 'sftp'
key: string
path: string
password?: string
type: 'sftp'
key: string
path: string
password?: string
}
type BackendREST = {
type: 'rest'
key: string
path: string
user?: string
password?: string
type: 'rest'
key: string
path: string
user?: string
password?: string
}
type BackendS3 = {
type: 's3'
key: string
path: string
aws_access_key_id: string
aws_secret_access_key: string
type: 's3'
key: string
path: string
aws_access_key_id: string
aws_secret_access_key: string
}
type BackendB2 = {
type: 'b2'
key: string
path: string
b2_account_id: string
b2_account_key: string
type: 'b2'
key: string
path: string
b2_account_id: string
b2_account_key: string
}
type BackendAzure = {
type: 'azure'
key: string
path: string
azure_account_name: string
azure_account_key: string
type: 'azure'
key: string
path: string
azure_account_name: string
azure_account_key: string
}
type BackendGS = {
type: 'gs'
key: string
path: string
google_project_id: string
google_application_credentials: string
type: 'gs'
key: string
path: string
google_project_id: string
google_application_credentials: string
}
export type Backend =
| BackendAzure
| BackendB2
| BackendGS
| BackendLocal
| BackendREST
| BackendS3
| BackendSFTP
export type Backend = BackendAzure | BackendB2 | BackendGS | BackendLocal | BackendREST | BackendS3 | BackendSFTP
export type Backends = { [name: string]: Backend }
// LOCATIONS
export type Location = {
from: string
to: StringOrArray
cron?: string
hooks?: {
before?: StringOrArray
after?: StringOrArray
}
options?: {
[key: string]: {
[key: string]: StringOrArray
}
}
from: string
to: StringOrArray
cron?: string
hooks?: {
before?: StringOrArray
after?: StringOrArray
}
options?: {
[key: string]: {
[key: string]: StringOrArray
}
}
}
export type Locations = { [name: string]: Location }
@@ -88,17 +81,17 @@ export type Locations = { [name: string]: Location }
// OTHER
export type Config = {
locations: Locations
backends: Backends
locations: Locations
backends: Backends
}
export type Lockfile = {
running: boolean
crons: {
[name: string]: {
lastRun: number
}
}
running: boolean
crons: {
[name: string]: {
lastRun: number
}
}
}
export type Flags = { [arg: string]: any }

View File

@@ -1,13 +1,15 @@
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 axios from 'axios'
import { Duration, Humanizer } from 'uhrwerk'
import { CONFIG_FILE, LocationFromPrefixes } from './config'
import { Location } from './types'
import { Backends, Location, Locations } from './types'
import { config } from '.'
export const exec = (command: string, args: string[], { env, ...rest }: SpawnSyncOptions = {}) => {
const { stdout, stderr, status } = spawnSync(command, args, {
@@ -106,6 +108,35 @@ export const getFlagsFromLocation = (location: Location, command?: string): stri
return flags
}
export function parseBackend(backends: string[] = [], all: boolean = false): Backends {
if (all) return config.backends
if (backends.length) {
for (const backend of backends) if (!config.backends[backend]) throw new Error('Invalid backend: '.red + backend)
return filterObjectByKey(config.backends, backends)
} else {
throw new Error(
'No backends specified.'.red + '\n-a, --all, -a\t\t\tSelect all.' + '\n-b, --backend <backends...>\t\tSpecify one or more backend'
)
}
}
export function checkIfValidLocation(location: string) {
if (!config.locations[location]) throw new Error('Invalid location: '.red + location)
}
export function parseLocations(locations: string[] = [], all: boolean = false): Locations {
if (all) {
return config.locations
}
if (locations.length) {
for (const location of locations) checkIfValidLocation(location)
return filterObjectByKey(config.locations, locations)
}
throw new Error(
'No locations specified.'.red + '\n-a, --all\t\t\tSelect all.' + '\n-l, --location <locations...>\t\t\tSpecify one or more location'
)
}
export const makeArrayIfIsNot = <T>(maybeArray: T | T[]): T[] => (Array.isArray(maybeArray) ? maybeArray : [maybeArray])
export const fill = (length: number, filler = ' '): string => new Array(length).fill(filler).join('')