Compare commits

..

24 Commits
0.11 ... 0.15

Author SHA1 Message Date
7aa937dd41 automatic signing 2020-01-23 11:09:57 +01:00
cupcakearmy
37361727ba Merge remote-tracking branch 'origin/master' 2020-01-08 00:48:13 +01:00
f1874438e5 Update README.md 2020-01-08 00:46:36 +01:00
cupcakearmy
066342a7b7 changelog 2020-01-08 00:45:39 +01:00
cupcakearmy
f620bb1764 version bump and help command in addition to flag 2020-01-08 00:34:36 +01:00
cupcakearmy
e3506e44b5 enable sftp 2020-01-08 00:32:33 +01:00
cupcakearmy
f65a83991b Merge remote-tracking branch 'origin/master' 2020-01-08 00:30:16 +01:00
f10b8c7990 Update README.md 2020-01-08 00:29:12 +01:00
cupcakearmy
a8af085d9c dont' get stuck if backend is not supported 2020-01-08 00:22:49 +01:00
fa89d2941f Update README.md 2019-12-24 19:05:26 +01:00
cupcakearmy
bcabd467c9 changelog 2019-12-24 18:48:18 +01:00
cupcakearmy
005072b90f Merge remote-tracking branch 'origin/master' 2019-12-24 18:42:18 +01:00
cupcakearmy
d13d4f7cf1 if there is an error while backing up, show it to the user 2019-12-24 18:42:09 +01:00
330e3254f7 Update README.md 2019-12-24 17:51:03 +01:00
38763ed919 Update README.md 2019-12-24 17:50:44 +01:00
cupcakearmy
886b6362cd remove duplicated code and make the forget function compatible with the new docker mounts options 2019-12-24 17:31:44 +01:00
cupcakearmy
9ece1d867d typo 2019-12-24 16:54:36 +01:00
cupcakearmy
485ada6599 CHANGELOG 2019-12-24 16:53:32 +01:00
cupcakearmy
e80db74af4 ordered gitignore 2019-12-24 16:52:27 +01:00
cupcakearmy
2fd9e2dd22 typo 2019-12-24 16:52:01 +01:00
0c654eacf1 Update README.md 2019-12-24 00:11:41 +01:00
cupcakearmy
8fdf5188ff cleaner error handling & version bump 2019-12-22 14:26:27 +01:00
cupcakearmy
22d93f0b9c fix self update in Debian systems 2019-12-22 14:25:52 +01:00
cupcakearmy
f940f23338 tidy up imports 2019-12-22 14:25:22 +01:00
13 changed files with 465 additions and 107 deletions

25
.drone.yml Normal file
View File

@@ -0,0 +1,25 @@
kind: pipeline
name: default
steps:
- name: build
image: node
pull: always
commands:
- yarn
- yarn run bin
when:
event: tag
- name: publish
image: plugins/github-release
pull: always
settings:
api_key:
from_secret: github
files: bin/*
checksum:
- sha512
note: CHANGELOG.md
when:
event: tag

13
.gitignore vendored
View File

@@ -1,10 +1,19 @@
# Editors
.idea
# Node
node_modules/ node_modules/
package-lock.json package-lock.json
.idea
yarn.lock yarn.lock
# Build & Runtime
bin bin
lib lib
data data
restore
docker
Dockerfile
.autorestic.yml # Config
.autorestic.yml
.docker.yml

58
CHANGELOG.md Normal file
View File

@@ -0,0 +1,58 @@
# Changelog
## 0.14
- Fixed #17 enable sftp
- Fixed #18 help command
## 0.13
- Restored files are now without the prefix path.
- Support for making backups of docker volumes and restoring them (not incremental).
- Show error to user during backup
## 0.12
- fix self update on linux (Fix #15)
## 0.11
- tilde in arguments (Fix #14)
## 0.10
- Show elapsed time (Fix #12)
- Remove some code duplication
- New info command to quickly show an overview of your config (Fix #11)
## 0.9
- Hooks
- Cleanup
## 0.8
- Support for native flags in the backup and forget commands.
- Forget cleanup
## 0.7
- Cleanup
- Support for excluding files
- Ability to prune keeping the last x snapshots according to restic policy rules
## 0.6
- support for absolute paths
## 0.5
- config optional if not required for current operation
## 0.4
- show version number
## 0.3
- test autoupdate function

118
README.md
View File

@@ -13,6 +13,7 @@ Autorestic is a wrapper around the amazing [restic](https://restic.net/). While
- Snapshot policies and pruning - Snapshot policies and pruning
- Simple interface - Simple interface
- Fully encrypted - Fully encrypted
- Backup & Restore docker volumes
### 📒 Docs ### 📒 Docs
@@ -20,8 +21,10 @@ Autorestic is a wrapper around the amazing [restic](https://restic.net/). While
* [Pruning & Deleting old files](#pruning-and-snapshot-policies) * [Pruning & Deleting old files](#pruning-and-snapshot-policies)
* [Excluding files](#excluding-filesfolders) * [Excluding files](#excluding-filesfolders)
* [Hooks](#before--after-hooks) * [Hooks](#before--after-hooks)
* [Docker volumes](#-Docker-volumes)
* [Backends](#-backends) * [Backends](#-backends)
* [Commands](#-commands) * [Commands](#-commands)
* [Examples](#-examples)
## 🛳 Installation ## 🛳 Installation
@@ -189,6 +192,64 @@ locations:
- echo "kthxbye" - echo "kthxbye"
``` ```
#### 🐳 Docker volumes
Since version 0.13 autorestic supports docker volumes directly, without needing them to be mounted to the host filesystem.
Let see an example.
###### docker-compose.yml
```yaml
version: '3.7'
volumes:
data:
name: my-data
services:
api:
image: alpine
volumes:
- data:/foo/bar
```
###### .autorestic.yml
```yaml
locations:
hello:
from: 'volume:my-data'
to:
- remote
options:
forget:
keep-last: 2
backends:
remote:
...
```
Now you can backup and restore as always.
```sh
autorestic -l hello backup
```
```sh
autorestic -l hello restore
```
If the volume does not exist on restore, autorestic will create it for you and then fill it with the data.
### Limitations
Unfortunately there are some limitations when backing up directly from a docker volume without mounting the volume to the host. If you are curious or have ideas how to improve this, please [read more here](https://github.com/cupcakearmy/autorestic/issues/4#issuecomment-568771951). Any help is welcomed 🙂
1. Incremental updates are not possible right now due to how the current docker mounting works.
2. Exclude patterns and files also do not work as restic only sees a compressed tarball as source and not the actual data.
## 💽 Backends ## 💽 Backends
Backends are the place where you data will be saved. Backups are incremental and encrypted. Backends are the place where you data will be saved. Backups are incremental and encrypted.
@@ -214,7 +275,16 @@ For each backend you need to specify the right variables as shown in the example
The path on the remote server. The path on the remote server.
For object storages as For object storages as
##### Example ##### Example Local
```yaml
backends:
name-of-backend:
type: local
path: /data/my/backups
```
##### Example Backblaze
```yaml ```yaml
backends: backends:
@@ -225,6 +295,30 @@ backends:
B2_ACCOUNT_KEY: backblaze_account_key B2_ACCOUNT_KEY: backblaze_account_key
``` ```
##### Example S3 / Minio
```yaml
backends:
name-of-backend:
type: s3
path: s3.amazonaws.com/bucket_name
# Minio
# path: http://localhost:9000/bucket_name
AWS_ACCESS_KEY_ID: my_key
AWS_SECRET_ACCESS_KEY: my_secret
```
##### Example SFTP
For SFTP to work you need to use configure your host inside of `~/.ssh/config` as password prompt is not supported. For more information on this topic please see the [official docs on the matter](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#sftp).
```yaml
backends:
name-of-backend:
type: sftp
path: my-host:/remote/path/on/the/server
```
## 👉 Commands ## 👉 Commands
* [info](#info) * [info](#info)
@@ -281,12 +375,6 @@ autorestic restore -l home --from hdd --to /path/where/to/restore
This will restore the location `home` to the `/path/where/to/restore` folder and taking the data from the backend `hdd` This will restore the location `home` to the `/path/where/to/restore` folder and taking the data from the backend `hdd`
```
autorestic restore
```
Performes a backup of all locations if the `-a` flag is passed. To only backup some locations pass one or more `-l` or `--location` flags.
### Forget ### Forget
@@ -323,6 +411,22 @@ Uninstall both restic and autorestic
Upgrades both restic and autorestic automagically Upgrades both restic and autorestic automagically
## 🐣 Examples
### List all the snapshots for all the backends
```
autorestic -a exec snapshots
```
### Unlock a locked repository
⚠️ Only do this if you know what you are doing. E.g. if you accidentally cancelled a running operation
```
autorestic -b my-backend exec unlock
```
## Contributors ## Contributors
This amazing people helped the project! This amazing people helped the project!

View File

@@ -4,7 +4,8 @@
"build": "tsc", "build": "tsc",
"build:watch": "tsc -w", "build:watch": "tsc -w",
"dev": "tsnd --no-notify --respawn ./src/autorestic.ts", "dev": "tsnd --no-notify --respawn ./src/autorestic.ts",
"bin": "yarn run build && pkg lib/autorestic.js --targets latest-macos-x64,latest-linux-x64 --out-path bin" "move": "mv bin/autorestic-linux bin/autorestic_linux_x64 && mv bin/autorestic-macos bin/autorestic_macos_x64",
"bin": "yarn run build && pkg lib/autorestic.js --targets latest-macos-x64,latest-linux-x64 --out-path bin && yarn run move"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^3.12.1", "@types/js-yaml": "^3.12.1",
@@ -22,4 +23,4 @@
"minimist": "^1.2.0", "minimist": "^1.2.0",
"uhrwerk": "^1.0.0" "uhrwerk": "^1.0.0"
} }
} }

View File

@@ -25,20 +25,22 @@ export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
string: ['l', 'b'], string: ['l', 'b'],
}) })
export const VERSION = '0.11' export const VERSION = '0.14'
export const INSTALL_DIR = '/usr/local/bin' export const INSTALL_DIR = '/usr/local/bin'
export const VERBOSE = flags.verbose export const VERBOSE = flags.verbose
export const config = init() export const config = init()
function main() { async function main() {
if (commands.length < 1) return help() if (commands.length < 1 || commands[0] === 'help') return help()
const command: string = commands[0] const command: string = commands[0]
const args: string[] = commands.slice(1) const args: string[] = commands.slice(1)
;(handlers[command] || error)(args, flags)
const fn = handlers[command] || error
await fn(args, flags)
} }
main() main().catch((e: Error) => console.error(e.message))

View File

@@ -2,7 +2,7 @@ import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic' import { config, VERBOSE } from './autorestic'
import { Backend, Backends, Locations } from './types' import { Backend, Backends, Locations } from './types'
import { exec, ConfigError } from './utils' import { exec, ConfigError, pathRelativeToConfigFile } from './utils'
@@ -11,13 +11,13 @@ const ALREADY_EXISTS = /(?=.*already)(?=.*config).*/
export const getPathFromBackend = (backend: Backend): string => { export const getPathFromBackend = (backend: Backend): string => {
switch (backend.type) { switch (backend.type) {
case 'local': case 'local':
return backend.path return pathRelativeToConfigFile(backend.path)
case 'b2': case 'b2':
case 'azure': case 'azure':
case 'gs': case 'gs':
case 's3': case 's3':
return `${backend.type}:${backend.path}`
case 'sftp': case 'sftp':
return `${backend.type}:${backend.path}`
case 'rest': case 'rest':
throw new Error(`Unsupported backend type: "${backend.type}"`) throw new Error(`Unsupported backend type: "${backend.type}"`)
default: default:
@@ -43,16 +43,20 @@ export const getBackendsFromLocations = (locations: Locations): string[] => {
export const checkAndConfigureBackend = (name: string, backend: Backend) => { export const checkAndConfigureBackend = (name: string, backend: Backend) => {
const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳') const writer = new Writer(name.blue + ' : ' + 'Configuring... ⏳')
const env = getEnvFromBackend(backend) 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)) if (err.length > 0 && !ALREADY_EXISTS.test(err))
throw new Error(`Could not load the backend "${name}": ${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) writer.done(name.blue + ' : ' + 'Done ✓'.green)
} catch (e) {
writer.done(name.blue + ' : ' + 'Error ⚠️ ' + e.message.red)
}
} }
export const checkAndConfigureBackends = (backends?: Backends) => { export const checkAndConfigureBackends = (backends?: Backends) => {

View File

@@ -1,8 +1,10 @@
import { Writer } from 'clitastic' import { Writer } from 'clitastic'
import { mkdirSync } from 'fs'
import { config, VERBOSE } from './autorestic' import { config, VERBOSE } from './autorestic'
import { getEnvFromBackend } from './backend' import { getEnvFromBackend } from './backend'
import { Locations, Location } from './types' import { LocationFromPrefixes } from './config'
import { Locations, Location, Backend } from './types'
import { import {
exec, exec,
ConfigError, ConfigError,
@@ -10,27 +12,72 @@ import {
getFlagsFromLocation, getFlagsFromLocation,
makeArrayIfIsNot, makeArrayIfIsNot,
execPlain, execPlain,
MeasureDuration, fill, MeasureDuration,
fill,
decodeLocationFromPrefix,
checkIfDockerVolumeExistsOrFail,
getPathFromVolume,
} from './utils' } from './utils'
export const backupFromFilesystem = (from: string, location: Location, backend: Backend, tags?: string[]) => {
const path = pathRelativeToConfigFile(from)
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)
}
export const backupFromVolume = (volume: string, location: Location, backend: Backend) => {
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 .`)
backupFromFilesystem(tmp, location, backend)
} catch (e) {
throw e
} finally {
execPlain(`rm -rf ${tmp}`)
}
}
export const backupSingle = (name: string, to: string, location: Location) => { export const backupSingle = (name: string, to: string, location: Location) => {
if (!config) throw ConfigError if (!config) throw ConfigError
const delta = new MeasureDuration() const delta = new MeasureDuration()
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳') const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
const backend = config.backends[to] try {
const path = pathRelativeToConfigFile(location.from) const backend = config.backends[to]
const [type, value] = decodeLocationFromPrefix(location.from)
const cmd = exec( switch (type) {
'restic',
['backup', path, ...getFlagsFromLocation(location, 'backup')],
{ env: getEnvFromBackend(backend) },
)
if (VERBOSE) console.log(cmd.out, cmd.err) case LocationFromPrefixes.Filesystem:
writer.done(`${name}${to.blue} : ${'Done ✓'.green} (${delta.finished(true)})`) 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) => { export const backupLocation = (name: string, location: Location) => {
@@ -40,8 +87,8 @@ export const backupLocation = (name: string, location: Location) => {
if (location.hooks && location.hooks.before) if (location.hooks && location.hooks.before)
for (const command of makeArrayIfIsNot(location.hooks.before)) { for (const command of makeArrayIfIsNot(location.hooks.before)) {
const cmd = execPlain(command) const cmd = execPlain(command, {})
if (cmd) console.log(cmd.out, cmd.err) console.log(cmd.out, cmd.err)
} }
for (const t of makeArrayIfIsNot(location.to)) { for (const t of makeArrayIfIsNot(location.to)) {
@@ -52,7 +99,7 @@ export const backupLocation = (name: string, location: Location) => {
if (location.hooks && location.hooks.after) if (location.hooks && location.hooks.after)
for (const command of makeArrayIfIsNot(location.hooks.after)) { for (const command of makeArrayIfIsNot(location.hooks.after)) {
const cmd = execPlain(command) const cmd = execPlain(command)
if (cmd) console.log(cmd.out, cmd.err) console.log(cmd.out, cmd.err)
} }
} }

View File

@@ -1,13 +1,21 @@
import { readFileSync, writeFileSync, statSync } from 'fs' import { readFileSync, writeFileSync, statSync } from 'fs'
import { resolve } from 'path' import { resolve } from 'path'
import { homedir } from 'os'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { flags } from './autorestic' import { flags } from './autorestic'
import { Backend, Config } from './types' import { Backend, Config } from './types'
import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils' import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils'
import { homedir } from 'os'
export enum LocationFromPrefixes {
Filesystem,
DockerVolume
}
export const normalizeAndCheckBackends = (config: Config) => { export const normalizeAndCheckBackends = (config: Config) => {
config.backends = makeObjectKeysLowercase(config.backends) config.backends = makeObjectKeysLowercase(config.backends)

View File

@@ -2,6 +2,7 @@ import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic' import { config, VERBOSE } from './autorestic'
import { getEnvFromBackend } from './backend' import { getEnvFromBackend } from './backend'
import { LocationFromPrefixes } from './config'
import { Locations, Location, Flags } from './types' import { Locations, Location, Flags } from './types'
import { import {
exec, exec,
@@ -9,7 +10,7 @@ import {
pathRelativeToConfigFile, pathRelativeToConfigFile,
getFlagsFromLocation, getFlagsFromLocation,
makeArrayIfIsNot, makeArrayIfIsNot,
fill, fill, decodeLocationFromPrefix, getPathFromVolume,
} from './utils' } from './utils'
@@ -20,11 +21,21 @@ export const forgetSingle = (name: string, to: string, location: Location, dryRu
const writer = new Writer(base + 'Removing old snapshots… ⏳') const writer = new Writer(base + 'Removing old snapshots… ⏳')
const backend = config.backends[to] const backend = config.backends[to]
const path = pathRelativeToConfigFile(location.from)
const flags = getFlagsFromLocation(location, 'forget') 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
}
if (flags.length == 0) { if (flags.length == 0) {
writer.done(base + 'skipping, no policy declared') writer.done(base + 'Skipping, no policy declared')
return return
} }
if (dryRun) flags.push('--dry-run') if (dryRun) flags.push('--dry-run')

View File

@@ -1,14 +1,16 @@
import axios from 'axios' import { chmodSync, renameSync, unlinkSync } from 'fs'
import { Writer } from 'clitastic'
import { unlinkSync } from 'fs'
import { tmpdir } from 'os' import { tmpdir } from 'os'
import { join, resolve } from 'path' import { join, resolve } from 'path'
import axios from 'axios'
import { Writer } from 'clitastic'
import { config, INSTALL_DIR, VERSION } from './autorestic' import { config, INSTALL_DIR, VERSION } from './autorestic'
import { checkAndConfigureBackends, getBackendsFromLocations, getEnvFromBackend } from './backend' import { checkAndConfigureBackends, getBackendsFromLocations, getEnvFromBackend } from './backend'
import { backupAll } from './backup' import { backupAll } from './backup'
import { forgetAll } from './forget' import { forgetAll } from './forget'
import showAll from './info' import showAll from './info'
import { restoreSingle } from './restore'
import { Backends, Flags, Locations } from './types' import { Backends, Flags, Locations } from './types'
import { import {
checkIfCommandIsAvailable, checkIfCommandIsAvailable,
@@ -85,36 +87,12 @@ const handlers: Handlers = {
if (!config) throw ConfigError if (!config) throw ConfigError
checkIfResticIsAvailable() checkIfResticIsAvailable()
if (!flags.to) {
console.log(`You need to specify the restore path with --to`.red)
return
}
const locations = parseLocations(flags) const locations = parseLocations(flags)
for (const [name, location] of Object.entries(locations)) { const keys = Object.keys(locations)
const baseText = name.green + '\t\t' if (keys.length < 1) throw new Error(`You need to specify the location to restore with --location`.red)
const w = new Writer(baseText + `Starting...`) 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 restoreSingle(keys[0], flags.from, flags.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 🎉')
}
}, },
forget(args, flags) { forget(args, flags) {
if (!config) throw ConfigError if (!config) throw ConfigError
@@ -139,7 +117,7 @@ const handlers: Handlers = {
console.log(out, err) console.log(out, err)
} }
}, },
async info() { info() {
showAll() showAll()
}, },
async install() { async install() {
@@ -147,7 +125,7 @@ const handlers: Handlers = {
checkIfResticIsAvailable() checkIfResticIsAvailable()
console.log('Restic is already installed') console.log('Restic is already installed')
return return
} catch (e) { } catch {
} }
const w = new Writer('Checking latest version... ⏳') const w = new Writer('Checking latest version... ⏳')
@@ -164,9 +142,7 @@ const handlers: Handlers = {
} }
w.replaceLn('Downloading binary... 🌎') w.replaceLn('Downloading binary... 🌎')
const name = `${json.name.replace(' ', '_')}_${process.platform}_${ const name = `${json.name.replace(' ', '_')}_${process.platform}_${archMap[process.arch]}.bz2`
archMap[process.arch]
}.bz2`
const dl = json.assets.find((asset: any) => asset.name === name) const dl = json.assets.find((asset: any) => asset.name === name)
if (!dl) if (!dl)
return console.log( return console.log(
@@ -184,8 +160,8 @@ const handlers: Handlers = {
unlinkSync(tmp) unlinkSync(tmp)
w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`) w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`)
exec('chmod', ['+x', extracted]) chmodSync(extracted, 0o755)
exec('mv', [extracted, INSTALL_DIR + '/restic']) renameSync(extracted, INSTALL_DIR + '/restic')
w.done( w.done(
`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉', `\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉',
@@ -225,7 +201,7 @@ const handlers: Handlers = {
w.replaceLn('Downloading binary... 🌎') w.replaceLn('Downloading binary... 🌎')
await downloadFile(dl.browser_download_url, to) await downloadFile(dl.browser_download_url, to)
exec('chmod', ['+x', to]) chmodSync(to, 0o755)
} }
w.done('All up to date! 🚀') w.done('All up to date! 🚀')

81
src/restore.ts Normal file
View File

@@ -0,0 +1,81 @@
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,
getPathFromVolume,
} 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 = 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}`)
}
}
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 + `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]
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 🎉')
}

View File

@@ -1,23 +1,18 @@
import { spawnSync, SpawnSyncOptions } from 'child_process'
import { randomBytes } from 'crypto'
import { createWriteStream } from 'fs'
import { dirname, isAbsolute, join, resolve } from 'path'
import { homedir } from 'os'
import axios from 'axios' 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 { Duration, Humanizer } from 'uhrwerk'
import { CONFIG_FILE } from './config' import { CONFIG_FILE, LocationFromPrefixes } from './config'
import { Location } from './types' import { Location } from './types'
export const exec = ( export const exec = (command: string, args: string[], { env, ...rest }: SpawnSyncOptions = {}) => {
command: string, const { stdout, stderr, status } = spawnSync(command, args, {
args: string[],
{ env, ...rest }: SpawnSyncOptions = {},
) => {
const cmd = spawnSync(command, args, {
...rest, ...rest,
env: { env: {
...process.env, ...process.env,
@@ -25,18 +20,15 @@ export const exec = (
}, },
}) })
const out = cmd.stdout && cmd.stdout.toString().trim() const out = stdout && stdout.toString().trim()
const err = cmd.stderr && cmd.stderr.toString().trim() const err = stderr && stderr.toString().trim()
return { out, err } return { out, err, status }
} }
export const execPlain = (command: string, opt: SpawnSyncOptions = {}) => { export const execPlain = (command: string, opt: SpawnSyncOptions = {}) => {
const split = command.split(' ') const split = command.split(' ')
if (split.length < 1) { if (split.length < 1) throw new Error(`The command ${command} is not valid`.red)
console.log(`The command ${command} is not valid`.red)
return
}
return exec(split[0], split.slice(1), opt) return exec(split[0], split.slice(1), opt)
} }
@@ -44,13 +36,14 @@ export const execPlain = (command: string, opt: SpawnSyncOptions = {}) => {
export const checkIfResticIsAvailable = () => export const checkIfResticIsAvailable = () =>
checkIfCommandIsAvailable( checkIfCommandIsAvailable(
'restic', 'restic',
'Restic is not installed'.red + 'restic is not installed'.red +
' https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases', '\nEither run ' + 'autorestic install'.green +
'\nOr go to https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases',
) )
export const checkIfCommandIsAvailable = (cmd: string, errorMsg?: string) => { export const checkIfCommandIsAvailable = (cmd: string, errorMsg?: string) => {
if (require('child_process').spawnSync(cmd).error) if (spawnSync(cmd).error)
throw new Error(errorMsg ? errorMsg : `"${errorMsg}" is not installed`.red) throw new Error(errorMsg ? errorMsg : `"${cmd}" is not installed`.red)
} }
export const makeObjectKeysLowercase = (object: Object): any => export const makeObjectKeysLowercase = (object: Object): any =>
@@ -83,11 +76,19 @@ export const downloadFile = async (url: string, to: string) =>
responseType: 'stream', responseType: 'stream',
}) })
const stream = createWriteStream(to) const tmp = join(tmpdir(), rand(64))
const stream = createWriteStream(tmp)
const writer = file.pipe(stream) const writer = file.pipe(stream)
writer.on('close', () => { writer.on('close', () => {
stream.close() stream.close()
try {
// Delete file if already exists. Needed if the binary wants to replace itself.
// Unix does not allow to overwrite a file that is being executed, but you can remove it and save other one at its place
unlinkSync(to)
} catch {
}
renameSync(tmp, to)
res() res()
}) })
}) })
@@ -166,3 +167,34 @@ 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 getPathFromVolume = (volume: string) => pathRelativeToConfigFile(hash(volume))
export const checkIfDockerVolumeExistsOrFail = (volume: string) => {
const cmd = exec('docker', [
'volume', 'inspect', volume,
])
if (cmd.err.length > 0)
throw new Error('Volume not found')
}