mirror of
https://github.com/cupcakearmy/autorestic.git
synced 2025-09-06 10:30:39 +00:00
Compare commits
70 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
bcabd467c9 | ||
|
005072b90f | ||
|
d13d4f7cf1 | ||
330e3254f7 | |||
38763ed919 | |||
|
886b6362cd | ||
|
9ece1d867d | ||
|
485ada6599 | ||
|
e80db74af4 | ||
|
2fd9e2dd22 | ||
0c654eacf1 | |||
|
8fdf5188ff | ||
|
22d93f0b9c | ||
|
f940f23338 | ||
|
678aa96c06 | ||
|
e51eacf13c | ||
12d2e010bb | |||
e25e65e052 | |||
4491cfd536 | |||
d0e82b47e1 | |||
|
90f9a998e8 | ||
|
b40adcae1f | ||
|
ad5afab355 | ||
|
5b0011330c | ||
fd2fd91635 | |||
9c09ce1d79 | |||
c2f6f51789 | |||
|
f09cf90653 | ||
|
d352aced37 | ||
|
563d4ffb96 | ||
|
1c6a061dd1 | ||
|
504ad639ab | ||
f7a15c6d86 | |||
|
2f0092befe | ||
|
1026e68b68 | ||
2389c59aa9 | |||
087aeaf578 | |||
3b7062f733 | |||
|
96b63c744b | ||
|
9669b70e20 | ||
|
bcb081234c | ||
|
336f44e9dc | ||
|
d0cda7f1d5 | ||
|
a8f4c23254 | ||
|
1c9f6d7d91 | ||
|
18c3f4a06f | ||
632062a23f | |||
3d1d7ba256 | |||
|
417c54db4d | ||
|
a9696bbc0c | ||
|
45f7506478 | ||
|
d7cdeafe60 | ||
|
cf09cdbb30 | ||
|
88059fe405 | ||
|
cdf18430b6 | ||
|
352754dad9 | ||
|
b68dc75053 | ||
|
6a055d3114 | ||
|
b5daff07eb | ||
b2d01d77d9 | |||
f41c042fce | |||
a81498ac42 | |||
1731ee30b3 | |||
1f4f1a1855 | |||
13cb764067 | |||
8058f37368 | |||
|
57ffa1e3fa | ||
|
671542cd30 | ||
|
322df9f0bd | ||
|
652158d1ed |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
|
53
CHANGELOG.md
Normal file
53
CHANGELOG.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
243
README.md
243
README.md
@@ -1,7 +1,7 @@
|
|||||||
# autorestic
|
# autorestic
|
||||||
High backup level CLI utility for [restic](https://restic.net/).
|
High backup level CLI utility for [restic](https://restic.net/).
|
||||||
|
|
||||||
Autorestic is a wrapper around the amazing [restic](https://restic.net/). While being amazing the restic cli can be a bit overwhelming and difficoult to manage if you habe many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂
|
Autorestic is a wrapper around the amazing [restic](https://restic.net/). While being amazing the restic cli can be a bit overwhelming and difficoult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -10,13 +10,26 @@ Autorestic is a wrapper around the amazing [restic](https://restic.net/). While
|
|||||||
- Config files, no CLI
|
- Config files, no CLI
|
||||||
- Predictable
|
- Predictable
|
||||||
- Backup locations to multiple backends
|
- Backup locations to multiple backends
|
||||||
|
- Snapshot policies and pruning
|
||||||
- Simple interface
|
- Simple interface
|
||||||
- Fully encrypted
|
- Fully encrypted
|
||||||
|
|
||||||
## Installation
|
### 📒 Docs
|
||||||
|
|
||||||
|
* [Locations](#-locations)
|
||||||
|
* [Pruning & Deleting old files](#pruning-and-snapshot-policies)
|
||||||
|
* [Excluding files](#excluding-filesfolders)
|
||||||
|
* [Hooks](#before--after-hooks)
|
||||||
|
* [Backends](#-backends)
|
||||||
|
* [Commands](#-commands)
|
||||||
|
* [Examples](#-examples)
|
||||||
|
|
||||||
|
## 🛳 Installation
|
||||||
|
|
||||||
|
Linux & macOS. Windows is not supported.
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | sh
|
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quickstart
|
## 🚀 Quickstart
|
||||||
@@ -32,7 +45,7 @@ locations:
|
|||||||
home:
|
home:
|
||||||
from: /home/me
|
from: /home/me
|
||||||
to: remote
|
to: remote
|
||||||
|
|
||||||
important:
|
important:
|
||||||
from: /path/to/important/stuff
|
from: /path/to/important/stuff
|
||||||
to:
|
to:
|
||||||
@@ -45,7 +58,7 @@ backends:
|
|||||||
path: 'myBucket:backup/home'
|
path: 'myBucket:backup/home'
|
||||||
B2_ACCOUNT_ID: account_id
|
B2_ACCOUNT_ID: account_id
|
||||||
B2_ACCOUNT_KEY: account_key
|
B2_ACCOUNT_KEY: account_key
|
||||||
|
|
||||||
hdd:
|
hdd:
|
||||||
type: local
|
type: local
|
||||||
path: /mnt/my_external_storage
|
path: /mnt/my_external_storage
|
||||||
@@ -53,24 +66,27 @@ backends:
|
|||||||
|
|
||||||
Then we check if everything is correct by running the `check` command. We will pass the `-a` (or `--all`) to tell autorestic to check all the locations.
|
Then we check if everything is correct by running the `check` command. We will pass the `-a` (or `--all`) to tell autorestic to check all the locations.
|
||||||
|
|
||||||
```
|
If we would check only one location we could run the following: `autorestic check -l home`. Otherwise simpply check all locations with `autorestic check -a`
|
||||||
autorestic check -a
|
|
||||||
```
|
|
||||||
|
|
||||||
If we would check only one location we could run the following: `autorestic -l home check`.
|
##### Note
|
||||||
|
|
||||||
### Backup
|
Note that the data is automatically encrypted on the server. The key will be generated and added to your config file. Every backend will have a separate key. You should keep a copy of the keys somewhere in case your server dies. Otherwise DATA IS LOST!
|
||||||
|
|
||||||
|
### 📦 Backup
|
||||||
|
|
||||||
```
|
```
|
||||||
autorestic backup -a
|
autorestic backup -a
|
||||||
```
|
```
|
||||||
|
|
||||||
### Restore
|
### 📼 Restore
|
||||||
|
|
||||||
```
|
```
|
||||||
autorestic restore -a -- --target /path/where/to/restore
|
autorestic restore -l home --from hdd --to /path/where/to/restore
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 📲 Updates
|
||||||
|
|
||||||
|
Autorestic can update itself! Super handy right? Simply run `autorestic update` and we will check for you if there are updates for restic and autorestic and install them if necessary.
|
||||||
|
|
||||||
## 🗂 Locations
|
## 🗂 Locations
|
||||||
|
|
||||||
@@ -85,6 +101,95 @@ locations:
|
|||||||
- also-backup-to-this-backend
|
- also-backup-to-this-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Pruning and snapshot policies
|
||||||
|
|
||||||
|
Autorestic supports declaring snapshot policies for location to avoid keeping old snapshot around if you don't need them.
|
||||||
|
|
||||||
|
This is based on [Restic's snapshots policies](https://restic.readthedocs.io/en/latest/060_forget.html#removing-snapshots-according-to-a-policy), and can be enabled for each location as shown below:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
locations:
|
||||||
|
etc:
|
||||||
|
from: /etc
|
||||||
|
to: local
|
||||||
|
options:
|
||||||
|
forget:
|
||||||
|
keep-last: 5 # always keep at least 5 snapshots
|
||||||
|
keep-hourly: 3 # keep 3 last hourly shapshots
|
||||||
|
keep-daily: 4 # keep 4 last daily shapshots
|
||||||
|
keep-weekly: 1 # keep 1 last weekly shapshots
|
||||||
|
keep-monthly: 12 # keep 12 last monthly shapshots
|
||||||
|
keep-yearly: 7 # keep 7 last yearly shapshots
|
||||||
|
keep-within: "2w" # keep snapshots from the last 2 weeks
|
||||||
|
```
|
||||||
|
|
||||||
|
Pruning can be triggered using `autorestic forget -a`, for all locations, or selectively with `autorestic forget -l <location>`. **please note that contrary to the restic CLI, `restic forget` will call `restic prune` internally.**
|
||||||
|
|
||||||
|
Run with the `--dry-run` flag to only print information about the process without actually pruning the snapshots. This is especially useful for debugging or testing policies:
|
||||||
|
```
|
||||||
|
$ autorestic forget -a --dry-run --verbose
|
||||||
|
|
||||||
|
Configuring Backends
|
||||||
|
local : Done ✓
|
||||||
|
|
||||||
|
Removing old shapshots according to policy
|
||||||
|
etc ▶ local : Removing old spnapshots… ⏳
|
||||||
|
etc ▶ local : Running in dry-run mode, not touching data
|
||||||
|
etc ▶ local : Forgeting old snapshots… ⏳Applying Policy: all snapshots within 2d of the newest
|
||||||
|
keep 3 snapshots:
|
||||||
|
ID Time Host Tags Reasons Paths
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
531b692a 2019-12-02 12:07:28 computer within 2w /etc
|
||||||
|
51659674 2019-12-02 12:08:46 computer within 2w /etc
|
||||||
|
f8f8f976 2019-12-02 12:11:08 computer within 2w /etc
|
||||||
|
-----------------------------------------------------------------------------
|
||||||
|
3 snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Excluding files/folders
|
||||||
|
|
||||||
|
If you want to exclude certain files or folders it done easily by specifiyng the right flags in the location you desire to filter. The flags are taken straight from the [restic cli exclude rules](https://restic.readthedocs.io/en/latest/040_backup.html#excluding-files).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
locations:
|
||||||
|
my-location:
|
||||||
|
from: /data
|
||||||
|
to:
|
||||||
|
- local
|
||||||
|
- remote
|
||||||
|
options:
|
||||||
|
backup:
|
||||||
|
exclude:
|
||||||
|
- '*.nope'
|
||||||
|
- '*.abc'
|
||||||
|
exclude-file: .gitignore
|
||||||
|
|
||||||
|
backends:
|
||||||
|
local:
|
||||||
|
...
|
||||||
|
remote:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Before / After hooks
|
||||||
|
|
||||||
|
Sometimes you might want to stop an app/db before backing up data and start the service again after the backup has completed. This is what the hooks are made for. Simply add them to your location config. You can have as many commands as you wish.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
locations:
|
||||||
|
my-location:
|
||||||
|
from: /data
|
||||||
|
to:
|
||||||
|
- local
|
||||||
|
- remote
|
||||||
|
hooks:
|
||||||
|
before:
|
||||||
|
- echo "Hello"
|
||||||
|
- echo "Human"
|
||||||
|
after:
|
||||||
|
- echo "kthxbye"
|
||||||
|
```
|
||||||
|
|
||||||
## 💽 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.
|
||||||
@@ -108,7 +213,7 @@ For each backend you need to specify the right variables as shown in the example
|
|||||||
##### `path`
|
##### `path`
|
||||||
|
|
||||||
The path on the remote server.
|
The path on the remote server.
|
||||||
For object storages as
|
For object storages as
|
||||||
|
|
||||||
##### Example
|
##### Example
|
||||||
|
|
||||||
@@ -121,9 +226,117 @@ backends:
|
|||||||
B2_ACCOUNT_KEY: backblaze_account_key
|
B2_ACCOUNT_KEY: backblaze_account_key
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 👉 Commands
|
||||||
|
|
||||||
|
* [info](#info)
|
||||||
|
* [check](#check)
|
||||||
|
* [backup](#backup)
|
||||||
|
* [forget](#forget)
|
||||||
|
* [restore](#restore)
|
||||||
|
* [exec](#exec)
|
||||||
|
* [intall](#install)
|
||||||
|
* [uninstall](#uninstall)
|
||||||
|
* [upgrade](#upgrade)
|
||||||
|
|
||||||
|
|
||||||
##### Note
|
### Info
|
||||||
|
|
||||||
Note that the data is automatically encrypted on the server. The key will be generated and added to your config file. Every backend will have a separate key. You should keep a copy of the keys somewhere in case your server dies. Otherwise DATA IS LOST!
|
```
|
||||||
|
autorestic info
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows all the information in the config file. Usefull for a quick overview of what location backups where.
|
||||||
|
|
||||||
|
Pro tip: if it gets a bit long you can read it more easily with `autorestic info | less` 😉
|
||||||
|
|
||||||
|
### Check
|
||||||
|
|
||||||
|
```
|
||||||
|
autorestic check [-b, --backend] [-a, --all]
|
||||||
|
```
|
||||||
|
|
||||||
|
Checks the backends and configures them if needed. Can be applied to all with the `-a` flag or by specifying one or more backends with the `-b` or `--backend` flag.
|
||||||
|
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
```
|
||||||
|
autorestic backup [-l, --location] [-a, --all]
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```
|
||||||
|
autorestic restore [-l, --location] [--from backend] [--to <out dir>]
|
||||||
|
```
|
||||||
|
|
||||||
|
This will restore all the locations to the selected target. If for one location there are more than one backends specified autorestic will take the first one.
|
||||||
|
|
||||||
|
Lets see a more realistic example (from the config above)
|
||||||
|
```
|
||||||
|
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`
|
||||||
|
|
||||||
|
### Forget
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
autorestic forget [-l, --location] [-a, --all] [--dry-run]
|
||||||
|
```
|
||||||
|
|
||||||
|
This will prune and remove old data form the backends according to the [keep policy you have specified for the location](#pruning-and-snapshot-policies)
|
||||||
|
|
||||||
|
The `--dry-run` flag will do a dry run showing what would have been deleted, but won't touch the actual data.
|
||||||
|
|
||||||
|
|
||||||
|
### Exec
|
||||||
|
|
||||||
|
```
|
||||||
|
autorestic exec [-b, --backend] [-a, --all] <command> -- [native options]
|
||||||
|
```
|
||||||
|
|
||||||
|
This is avery handy command which enables you to run any native restic command on desired backends. An example would be listing all the snapshots of all your backends:
|
||||||
|
|
||||||
|
```
|
||||||
|
autorestic exec -a -- snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Install
|
||||||
|
|
||||||
|
Installs both restic and autorestic
|
||||||
|
|
||||||
|
#### Uninstall
|
||||||
|
|
||||||
|
Uninstall both restic and autorestic
|
||||||
|
|
||||||
|
#### Upgrade
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
This amazing people helped the project!
|
||||||
|
|
||||||
|
- @ChanceM [Docs]
|
||||||
|
- @EliotBerriot [Docs, Pruning, S3]
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
|
|
||||||
OUT_FILE=/usr/local/bin/autorestic
|
OUT_FILE=/usr/local/bin/autorestic
|
||||||
|
|
||||||
|
10
package.json
10
package.json
@@ -4,22 +4,22 @@
|
|||||||
"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": "npm run build && pkg lib/autorestic.js --targets latest-macos-x64,latest-linux-x64 --out-path bin"
|
"bin": "yarn run build && pkg lib/autorestic.js --targets latest-macos-x64,latest-linux-x64 --out-path bin"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/decompress": "^4.2.3",
|
|
||||||
"@types/js-yaml": "^3.12.1",
|
"@types/js-yaml": "^3.12.1",
|
||||||
"@types/minimist": "^1.2.0",
|
"@types/minimist": "^1.2.0",
|
||||||
"@types/node": "^12.11.7",
|
"@types/node": "^12.11.7",
|
||||||
"pkg": "^4.4.0",
|
"pkg": "^4.4.0",
|
||||||
"ts-node-dev": "^1.0.0-pre.40",
|
"ts-node-dev": "^1.0.0-pre.40",
|
||||||
"typescript": "^3.5.1"
|
"typescript": "^3.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.19.0",
|
"axios": "^0.19.0",
|
||||||
"clitastic": "0.0.1",
|
"clitastic": "0.0.1",
|
||||||
"colors": "^1.3.3",
|
"colors": "^1.3.3",
|
||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"minimist": "^1.2.0"
|
"minimist": "^1.2.0",
|
||||||
|
"uhrwerk": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,38 +3,44 @@ import minimist from 'minimist'
|
|||||||
|
|
||||||
import { init } from './config'
|
import { init } from './config'
|
||||||
import handlers, { error, help } from './handlers'
|
import handlers, { error, help } from './handlers'
|
||||||
import { Config } from './types'
|
|
||||||
|
|
||||||
|
|
||||||
process.on('uncaughtException', err => {
|
process.on('uncaughtException', err => {
|
||||||
console.log(err.message)
|
console.log(err.message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
|
export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
|
||||||
alias: {
|
alias: {
|
||||||
c: 'config',
|
c: 'config',
|
||||||
v: 'version',
|
v: 'version',
|
||||||
h: 'help',
|
h: 'help',
|
||||||
a: 'all',
|
a: 'all',
|
||||||
l: 'location',
|
l: 'location',
|
||||||
b: 'backend',
|
b: 'backend',
|
||||||
},
|
d: 'dry-run',
|
||||||
boolean: ['a'],
|
},
|
||||||
string: ['l', 'b'],
|
boolean: ['a', 'd'],
|
||||||
|
string: ['l', 'b'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const VERSION = '0.6'
|
export const VERSION = '0.13'
|
||||||
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() {
|
|
||||||
if (commands.length < 1) return help()
|
|
||||||
|
|
||||||
const command: string = commands[0]
|
async function main() {
|
||||||
const args: string[] = commands.slice(1)
|
if (commands.length < 1) return help()
|
||||||
;(handlers[command] || error)(args, flags)
|
|
||||||
|
const command: string = commands[0]
|
||||||
|
const args: string[] = commands.slice(1)
|
||||||
|
|
||||||
|
const fn = handlers[command] || error
|
||||||
|
await fn(args, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
|
||||||
|
main().catch((e: Error) => console.error(e.message))
|
||||||
|
@@ -1,58 +1,67 @@
|
|||||||
import { Writer } from 'clitastic'
|
import { Writer } from 'clitastic'
|
||||||
|
|
||||||
import { config, VERBOSE } from './autorestic'
|
import { config, VERBOSE } from './autorestic'
|
||||||
import { Backend, Backends } from './types'
|
import { Backend, Backends, Locations } from './types'
|
||||||
import { exec, ConfigError } from './utils'
|
import { exec, ConfigError, pathRelativeToConfigFile } from './utils'
|
||||||
|
|
||||||
const ALREADY_EXISTS = /(?=.*exists)(?=.*already)(?=.*config).*/
|
|
||||||
|
|
||||||
|
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}`
|
return `${backend.type}:${backend.path}`
|
||||||
case 'sftp':
|
case 'sftp':
|
||||||
case 'rest':
|
case 'rest':
|
||||||
throw new Error(`Unsupported backend type: "${backend.type}"`)
|
throw new Error(`Unsupported backend type: "${backend.type}"`)
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown backend type.`)
|
throw new Error(`Unknown backend type.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEnvFromBackend = (backend: Backend) => {
|
export const getEnvFromBackend = (backend: Backend) => {
|
||||||
const { type, path, key, ...rest } = backend
|
const { type, path, key, ...rest } = backend
|
||||||
return {
|
return {
|
||||||
RESTIC_PASSWORD: key,
|
RESTIC_PASSWORD: key,
|
||||||
RESTIC_REPOSITORY: getPathFromBackend(backend),
|
RESTIC_REPOSITORY: getPathFromBackend(backend),
|
||||||
...rest,
|
...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)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkAndConfigureBackends = (backends?: Backends) => {
|
export const checkAndConfigureBackends = (backends?: Backends) => {
|
||||||
if (!backends) {
|
if (!backends) {
|
||||||
if (!config) throw ConfigError
|
if (!config) throw ConfigError
|
||||||
backends = config.backends
|
backends = config.backends
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\nConfiguring Backends'.grey.underline)
|
console.log('\nConfiguring Backends'.grey.underline)
|
||||||
for (const [name, backend] of Object.entries(backends))
|
for (const [name, backend] of Object.entries(backends))
|
||||||
checkAndConfigureBackend(name, backend)
|
checkAndConfigureBackend(name, backend)
|
||||||
}
|
}
|
||||||
|
138
src/backup.ts
138
src/backup.ts
@@ -1,51 +1,115 @@
|
|||||||
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 { exec, ConfigError } from './utils'
|
import { Locations, Location, Backend } from './types'
|
||||||
import { CONFIG_FILE } from './config'
|
import {
|
||||||
import { resolve, dirname, isAbsolute } from 'path'
|
exec,
|
||||||
|
ConfigError,
|
||||||
|
pathRelativeToConfigFile,
|
||||||
|
getFlagsFromLocation,
|
||||||
|
makeArrayIfIsNot,
|
||||||
|
execPlain,
|
||||||
|
MeasureDuration,
|
||||||
|
fill,
|
||||||
|
decodeLocationFromPrefix,
|
||||||
|
checkIfDockerVolumeExistsOrFail,
|
||||||
|
getPathFromVolume,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
export const backupSingle = (name: string, from: string, to: string) => {
|
|
||||||
if (!config) throw ConfigError
|
|
||||||
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
|
|
||||||
const backend = config.backends[to]
|
|
||||||
|
|
||||||
// Check if is an absolute path, otherwise get the path relative to the config file
|
|
||||||
const pathRelativeToConfigFile = isAbsolute(from)
|
|
||||||
? from
|
|
||||||
: resolve(dirname(CONFIG_FILE), from)
|
|
||||||
|
|
||||||
const cmd = exec('restic', ['backup', pathRelativeToConfigFile], {
|
export const backupFromFilesystem = (from: string, location: Location, backend: Backend, tags?: string[]) => {
|
||||||
env: getEnvFromBackend(backend),
|
const path = pathRelativeToConfigFile(from)
|
||||||
})
|
|
||||||
|
|
||||||
if (VERBOSE) console.log(cmd.out, cmd.err)
|
const { out, err, status } = exec(
|
||||||
writer.done(name + to.blue + ' : ' + 'Done ✓'.green)
|
'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 backupLocation = (name: string, backup: Location) => {
|
export const backupFromVolume = (volume: string, location: Location, backend: Backend) => {
|
||||||
const display = name.yellow + ' ▶ '
|
const tmp = getPathFromVolume(volume)
|
||||||
if (Array.isArray(backup.to)) {
|
try {
|
||||||
let first = true
|
mkdirSync(tmp)
|
||||||
for (const t of backup.to) {
|
checkIfDockerVolumeExistsOrFail(volume)
|
||||||
const nameOrBlankSpaces: string = first
|
|
||||||
? display
|
// For incremental backups. Unfortunately due to how the docker mounts work the permissions get lost.
|
||||||
: new Array(name.length + 3).fill(' ').join('')
|
// execPlain(`docker run --rm -v ${volume}:/data -v ${tmp}:/backup alpine cp -aT /data /backup`)
|
||||||
backupSingle(nameOrBlankSpaces, backup.from, t)
|
execPlain(`docker run --rm -v ${volume}:/data -v ${tmp}:/backup alpine tar cf /backup/archive.tar -C /data .`)
|
||||||
if (first) first = false
|
|
||||||
}
|
backupFromFilesystem(tmp, location, backend)
|
||||||
} else backupSingle(display, backup.from, backup.to)
|
} catch (e) {
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
execPlain(`rm -rf ${tmp}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const backupAll = (backups?: Locations) => {
|
export const backupSingle = (name: string, to: string, location: Location) => {
|
||||||
if (!backups) {
|
if (!config) throw ConfigError
|
||||||
if (!config) throw ConfigError
|
const delta = new MeasureDuration()
|
||||||
backups = config.locations
|
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\nBacking Up'.underline.grey)
|
try {
|
||||||
for (const [name, backup] of Object.entries(backups))
|
const backend = config.backends[to]
|
||||||
backupLocation(name, backup)
|
const [type, value] = decodeLocationFromPrefix(location.from)
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const backupAll = (locations?: Locations) => {
|
||||||
|
if (!locations) {
|
||||||
|
if (!config) throw ConfigError
|
||||||
|
locations = config.locations
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nBacking Up'.underline.grey)
|
||||||
|
for (const [name, location] of Object.entries(locations))
|
||||||
|
backupLocation(name, location)
|
||||||
}
|
}
|
||||||
|
129
src/config.ts
129
src/config.ts
@@ -1,86 +1,97 @@
|
|||||||
import { readFileSync, writeFileSync, statSync } from 'fs'
|
import { readFileSync, writeFileSync, statSync } from 'fs'
|
||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
import yaml from 'js-yaml'
|
|
||||||
import { flags } from './autorestic'
|
|
||||||
import { Backend, Config } from './types'
|
|
||||||
import { makeObjectKeysLowercase, rand } from './utils'
|
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
import { flags } from './autorestic'
|
||||||
|
import { Backend, Config } from './types'
|
||||||
|
import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
for (const [name, { type, path, key, ...rest }] of Object.entries(
|
for (const [name, { type, path, key, ...rest }] of Object.entries(
|
||||||
config.backends
|
config.backends,
|
||||||
)) {
|
)) {
|
||||||
if (!type || !path)
|
if (!type || !path)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`The backend "${name}" is missing some required attributes`
|
`The backend "${name}" is missing some required attributes`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const tmp: any = {
|
const tmp: any = {
|
||||||
type,
|
type,
|
||||||
path,
|
path,
|
||||||
key: key || rand(128),
|
key: key || rand(128),
|
||||||
}
|
}
|
||||||
for (const [key, value] of Object.entries(rest))
|
for (const [key, value] of Object.entries(rest))
|
||||||
tmp[key.toUpperCase()] = value
|
tmp[key.toUpperCase()] = value
|
||||||
|
|
||||||
config.backends[name] = tmp as Backend
|
config.backends[name] = tmp as Backend
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const normalizeAndCheckBackups = (config: Config) => {
|
export const normalizeAndCheckBackups = (config: Config) => {
|
||||||
config.locations = makeObjectKeysLowercase(config.locations)
|
config.locations = makeObjectKeysLowercase(config.locations)
|
||||||
const backends = Object.keys(config.backends)
|
const backends = Object.keys(config.backends)
|
||||||
|
|
||||||
const checkDestination = (backend: string, backup: string) => {
|
const checkDestination = (backend: string, backup: string) => {
|
||||||
if (!backends.includes(backend))
|
if (!backends.includes(backend))
|
||||||
throw new Error(`Cannot find the backend "${backend}" for "${backup}"`)
|
throw new Error(`Cannot find the backend "${backend}" for "${backup}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, { from, to, ...rest }] of Object.entries(
|
for (const [name, { from, to, ...rest }] of Object.entries(
|
||||||
config.locations
|
config.locations,
|
||||||
)) {
|
)) {
|
||||||
if (!from || !to)
|
if (!from || !to)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`The backup "${name}" is missing some required attributes`
|
`The backup "${name}" is missing some required attributes`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (Array.isArray(to)) for (const t of to) checkDestination(t, name)
|
for (const t of makeArrayIfIsNot(to))
|
||||||
else checkDestination(to, name)
|
checkDestination(t, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const findConfigFile = (): string | undefined => {
|
const findConfigFile = (): string | undefined => {
|
||||||
const config = '.autorestic.yml'
|
const config = '.autorestic.yml'
|
||||||
const paths = [
|
const paths = [
|
||||||
resolve(flags.config || ''),
|
resolve(flags.config || ''),
|
||||||
resolve('./' + config),
|
resolve('./' + config),
|
||||||
homedir() + '/' + config,
|
homedir() + '/' + config,
|
||||||
]
|
]
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
try {
|
try {
|
||||||
const file = statSync(path)
|
const file = statSync(path)
|
||||||
if (file.isFile()) return path
|
if (file.isFile()) return path
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export let CONFIG_FILE: string = ''
|
export let CONFIG_FILE: string = ''
|
||||||
|
|
||||||
export const init = (): Config | undefined => {
|
export const init = (): Config | undefined => {
|
||||||
const file = findConfigFile()
|
const file = findConfigFile()
|
||||||
if (file) CONFIG_FILE = file
|
if (file) CONFIG_FILE = file
|
||||||
else return
|
else return
|
||||||
|
|
||||||
const raw: Config = makeObjectKeysLowercase(
|
const raw: Config = makeObjectKeysLowercase(
|
||||||
yaml.safeLoad(readFileSync(CONFIG_FILE).toString())
|
yaml.safeLoad(readFileSync(CONFIG_FILE).toString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
normalizeAndCheckBackends(raw)
|
normalizeAndCheckBackends(raw)
|
||||||
normalizeAndCheckBackups(raw)
|
normalizeAndCheckBackups(raw)
|
||||||
|
|
||||||
writeFileSync(CONFIG_FILE, yaml.safeDump(raw))
|
writeFileSync(CONFIG_FILE, yaml.safeDump(raw))
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
78
src/forget.ts
Normal file
78
src/forget.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Writer } from 'clitastic'
|
||||||
|
|
||||||
|
import { config, VERBOSE } from './autorestic'
|
||||||
|
import { getEnvFromBackend } from './backend'
|
||||||
|
import { LocationFromPrefixes } from './config'
|
||||||
|
import { Locations, Location, Flags } from './types'
|
||||||
|
import {
|
||||||
|
exec,
|
||||||
|
ConfigError,
|
||||||
|
pathRelativeToConfigFile,
|
||||||
|
getFlagsFromLocation,
|
||||||
|
makeArrayIfIsNot,
|
||||||
|
fill, decodeLocationFromPrefix, getPathFromVolume,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const forgetSingle = (name: string, to: string, location: Location, dryRun: boolean) => {
|
||||||
|
if (!config) throw ConfigError
|
||||||
|
const base = name + to.blue + ' : '
|
||||||
|
const writer = new Writer(base + 'Removing old snapshots… ⏳')
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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 (!config) throw ConfigError
|
||||||
|
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)
|
||||||
|
|
||||||
|
for (const [name, backup] of Object.entries(backups))
|
||||||
|
forgetLocation(name, backup, dryRun)
|
||||||
|
}
|
400
src/handlers.ts
400
src/handlers.ts
@@ -1,244 +1,250 @@
|
|||||||
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, getEnvFromBackend } from './backend'
|
import { checkAndConfigureBackends, getBackendsFromLocations, getEnvFromBackend } from './backend'
|
||||||
import { backupAll } from './backup'
|
import { backupAll } from './backup'
|
||||||
|
import { forgetAll } from './forget'
|
||||||
|
import showAll from './info'
|
||||||
|
import { restoreSingle } from './restore'
|
||||||
import { Backends, Flags, Locations } from './types'
|
import { Backends, Flags, Locations } from './types'
|
||||||
import {
|
import {
|
||||||
checkIfCommandIsAvailable,
|
checkIfCommandIsAvailable,
|
||||||
checkIfResticIsAvailable,
|
checkIfResticIsAvailable,
|
||||||
downloadFile,
|
downloadFile,
|
||||||
exec,
|
exec,
|
||||||
filterObjectByKey,
|
filterObjectByKey,
|
||||||
singleToArray,
|
ConfigError, makeArrayIfIsNot,
|
||||||
ConfigError,
|
|
||||||
} from './utils'
|
} from './utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type Handlers = {
|
export type Handlers = {
|
||||||
[command: string]: (args: string[], flags: Flags) => void
|
[command: string]: (args: string[], flags: Flags) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseBackend = (flags: Flags): Backends => {
|
const parseBackend = (flags: Flags): Backends => {
|
||||||
if (!config) throw ConfigError
|
if (!config) throw ConfigError
|
||||||
if (!flags.all && !flags.backend)
|
if (!flags.all && !flags.backend)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'No backends specified.'.red +
|
'No backends specified.'.red +
|
||||||
'\n--all [-a]\t\t\t\tCheck all.' +
|
'\n--all [-a]\t\t\t\tCheck all.' +
|
||||||
'\n--backend [-b] myBackend\t\tSpecify one or more backend'
|
'\n--backend [-b] myBackend\t\tSpecify one or more backend',
|
||||||
)
|
)
|
||||||
if (flags.all) return config.backends
|
if (flags.all) return config.backends
|
||||||
else {
|
else {
|
||||||
const backends = singleToArray<string>(flags.backend)
|
const backends = makeArrayIfIsNot<string>(flags.backend)
|
||||||
for (const backend of backends)
|
for (const backend of backends)
|
||||||
if (!config.backends[backend])
|
if (!config.backends[backend])
|
||||||
throw new Error('Invalid backend: '.red + backend)
|
throw new Error('Invalid backend: '.red + backend)
|
||||||
return filterObjectByKey(config.backends, backends)
|
return filterObjectByKey(config.backends, backends)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseLocations = (flags: Flags): Locations => {
|
const parseLocations = (flags: Flags): Locations => {
|
||||||
if (!config) throw ConfigError
|
if (!config) throw ConfigError
|
||||||
if (!flags.all && !flags.location)
|
if (!flags.all && !flags.location)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'No locations specified.'.red +
|
'No locations specified.'.red +
|
||||||
'\n--all [-a]\t\t\t\tBackup all.' +
|
'\n--all [-a]\t\t\t\tBackup all.' +
|
||||||
'\n--location [-l] site1\t\t\tSpecify one or more locations'
|
'\n--location [-l] site1\t\t\tSpecify one or more locations',
|
||||||
)
|
)
|
||||||
|
|
||||||
if (flags.all) {
|
if (flags.all) {
|
||||||
return config.locations
|
return config.locations
|
||||||
} else {
|
} else {
|
||||||
const locations = singleToArray<string>(flags.location)
|
const locations = makeArrayIfIsNot<string>(flags.location)
|
||||||
for (const location of locations)
|
for (const location of locations)
|
||||||
if (!config.locations[location])
|
if (!config.locations[location])
|
||||||
throw new Error('Invalid location: '.red + location)
|
throw new Error('Invalid location: '.red + location)
|
||||||
return filterObjectByKey(config.locations, locations)
|
return filterObjectByKey(config.locations, locations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers: Handlers = {
|
const handlers: Handlers = {
|
||||||
check(args, flags) {
|
check(args, flags) {
|
||||||
checkIfResticIsAvailable()
|
checkIfResticIsAvailable()
|
||||||
const backends = parseBackend(flags)
|
const backends = parseBackend(flags)
|
||||||
checkAndConfigureBackends(backends)
|
checkAndConfigureBackends(backends)
|
||||||
},
|
},
|
||||||
backup(args, flags) {
|
backup(args, flags) {
|
||||||
if (!config) throw ConfigError
|
if (!config) throw ConfigError
|
||||||
checkIfResticIsAvailable()
|
checkIfResticIsAvailable()
|
||||||
const locations: Locations = parseLocations(flags)
|
const locations: Locations = parseLocations(flags)
|
||||||
|
|
||||||
const backends = new Set<string>()
|
checkAndConfigureBackends(
|
||||||
for (const to of Object.values(locations).map(location => location.to))
|
filterObjectByKey(config.backends, getBackendsFromLocations(locations)),
|
||||||
Array.isArray(to) ? to.forEach(t => backends.add(t)) : backends.add(to)
|
)
|
||||||
|
backupAll(locations)
|
||||||
|
|
||||||
checkAndConfigureBackends(
|
console.log('\nFinished!'.underline + ' 🎉')
|
||||||
filterObjectByKey(config.backends, Array.from(backends))
|
},
|
||||||
)
|
restore(args, flags) {
|
||||||
backupAll(locations)
|
if (!config) throw ConfigError
|
||||||
|
checkIfResticIsAvailable()
|
||||||
|
|
||||||
console.log('\nFinished!'.underline + ' 🎉')
|
const locations = parseLocations(flags)
|
||||||
},
|
const keys = Object.keys(locations)
|
||||||
restore(args, flags) {
|
if (keys.length < 1) throw new Error(`You need to specify the location to restore with --location`.red)
|
||||||
if (!config) throw ConfigError
|
if (keys.length > 2) throw new Error(`Only one location is supported at a time when restoring`.red)
|
||||||
checkIfResticIsAvailable()
|
|
||||||
const locations = parseLocations(flags)
|
|
||||||
for (const [name, location] of Object.entries(locations)) {
|
|
||||||
const w = new Writer(name.green + `\t\tRestoring... ⏳`)
|
|
||||||
const env = getEnvFromBackend(
|
|
||||||
config.backends[
|
|
||||||
Array.isArray(location.to) ? location.to[0] : location.to
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
exec(
|
restoreSingle(keys[0], flags.from, flags.to)
|
||||||
'restic',
|
},
|
||||||
['restore', 'latest', '--path', resolve(location.from), ...args],
|
forget(args, flags) {
|
||||||
{ env }
|
if (!config) throw ConfigError
|
||||||
)
|
checkIfResticIsAvailable()
|
||||||
w.done(name.green + '\t\tDone 🎉')
|
const locations: Locations = parseLocations(flags)
|
||||||
}
|
|
||||||
},
|
|
||||||
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 })
|
checkAndConfigureBackends(
|
||||||
console.log(out, err)
|
filterObjectByKey(config.backends, getBackendsFromLocations(locations)),
|
||||||
}
|
)
|
||||||
},
|
forgetAll(locations, flags)
|
||||||
async install() {
|
|
||||||
try {
|
|
||||||
checkIfResticIsAvailable()
|
|
||||||
console.log('Restic is already installed')
|
|
||||||
return
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const w = new Writer('Checking latest version... ⏳')
|
console.log('\nFinished!'.underline + ' 🎉')
|
||||||
checkIfCommandIsAvailable('bzip2')
|
},
|
||||||
const { data: json } = await axios({
|
exec(args, flags) {
|
||||||
method: 'get',
|
checkIfResticIsAvailable()
|
||||||
url: 'https://api.github.com/repos/restic/restic/releases/latest',
|
const backends = parseBackend(flags)
|
||||||
responseType: 'json',
|
for (const [name, backend] of Object.entries(backends)) {
|
||||||
})
|
console.log(`\n${name}:\n`.grey.underline)
|
||||||
|
const env = getEnvFromBackend(backend)
|
||||||
|
|
||||||
const archMap: { [a: string]: string } = {
|
const { out, err } = exec('restic', args, { env })
|
||||||
x32: '386',
|
console.log(out, err)
|
||||||
x64: 'amd64',
|
}
|
||||||
}
|
},
|
||||||
|
info() {
|
||||||
|
showAll()
|
||||||
|
},
|
||||||
|
async install() {
|
||||||
|
try {
|
||||||
|
checkIfResticIsAvailable()
|
||||||
|
console.log('Restic is already installed')
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
w.replaceLn('Downloading binary... 🌎')
|
const w = new Writer('Checking latest version... ⏳')
|
||||||
const name = `${json.name.replace(' ', '_')}_${process.platform}_${
|
checkIfCommandIsAvailable('bzip2')
|
||||||
archMap[process.arch]
|
const { data: json } = await axios({
|
||||||
}.bz2`
|
method: 'get',
|
||||||
const dl = json.assets.find((asset: any) => asset.name === name)
|
url: 'https://api.github.com/repos/restic/restic/releases/latest',
|
||||||
if (!dl)
|
responseType: 'json',
|
||||||
return console.log(
|
})
|
||||||
'Cannot get the right binary.'.red,
|
|
||||||
'Please see https://bit.ly/2Y1Rzai'
|
|
||||||
)
|
|
||||||
|
|
||||||
const tmp = join(tmpdir(), name)
|
const archMap: { [a: string]: string } = {
|
||||||
const extracted = tmp.slice(0, -4) //without the .bz2
|
x32: '386',
|
||||||
|
x64: 'amd64',
|
||||||
|
}
|
||||||
|
|
||||||
await downloadFile(dl.browser_download_url, tmp)
|
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',
|
||||||
|
)
|
||||||
|
|
||||||
// TODO: Native bz2
|
const tmp = join(tmpdir(), name)
|
||||||
// Decompress
|
const extracted = tmp.slice(0, -4) //without the .bz2
|
||||||
w.replaceLn('Decompressing binary... 📦')
|
|
||||||
exec('bzip2', ['-dk', tmp])
|
|
||||||
unlinkSync(tmp)
|
|
||||||
|
|
||||||
w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`)
|
await downloadFile(dl.browser_download_url, tmp)
|
||||||
exec('chmod', ['+x', extracted])
|
|
||||||
exec('mv', [extracted, INSTALL_DIR + '/restic'])
|
|
||||||
|
|
||||||
w.done(
|
w.replaceLn('Decompressing binary... 📦')
|
||||||
`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉'
|
exec('bzip2', ['-dk', tmp])
|
||||||
)
|
unlinkSync(tmp)
|
||||||
},
|
|
||||||
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... ⏳')
|
w.replaceLn(`Moving to ${INSTALL_DIR} 🚙`)
|
||||||
const { data: json } = await axios({
|
chmodSync(extracted, 0o755)
|
||||||
method: 'get',
|
renameSync(extracted, INSTALL_DIR + '/restic')
|
||||||
url:
|
|
||||||
'https://api.github.com/repos/cupcakearmy/autorestic/releases/latest',
|
|
||||||
responseType: 'json',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (json.tag_name != VERSION) {
|
w.done(
|
||||||
const platformMap: { [key: string]: string } = {
|
`\nFinished! restic is installed under: ${INSTALL_DIR}`.underline + ' 🎉',
|
||||||
darwin: 'macos',
|
)
|
||||||
}
|
},
|
||||||
|
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'])
|
||||||
|
|
||||||
const name = `autorestic_${platformMap[process.platform] ||
|
w.replaceLn('Checking for latest autorestic version... ⏳')
|
||||||
process.platform}_${process.arch}`
|
const { data: json } = await axios({
|
||||||
const dl = json.assets.find((asset: any) => asset.name === name)
|
method: 'get',
|
||||||
|
url:
|
||||||
|
'https://api.github.com/repos/cupcakearmy/autorestic/releases/latest',
|
||||||
|
responseType: 'json',
|
||||||
|
})
|
||||||
|
|
||||||
const to = INSTALL_DIR + '/autorestic'
|
if (json.tag_name != VERSION) {
|
||||||
w.replaceLn('Downloading binary... 🌎')
|
const platformMap: { [key: string]: string } = {
|
||||||
await downloadFile(dl.browser_download_url, to)
|
darwin: 'macos',
|
||||||
|
}
|
||||||
|
|
||||||
exec('chmod', ['+x', to])
|
const name = `autorestic_${platformMap[process.platform] || process.platform}_${process.arch}`
|
||||||
}
|
const dl = json.assets.find((asset: any) => asset.name === name)
|
||||||
|
|
||||||
w.done('All up to date! 🚀')
|
const to = INSTALL_DIR + '/autorestic'
|
||||||
},
|
w.replaceLn('Downloading binary... 🌎')
|
||||||
version() {
|
await downloadFile(dl.browser_download_url, to)
|
||||||
console.log('version'.grey, VERSION)
|
|
||||||
},
|
chmodSync(to, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.done('All up to date! 🚀')
|
||||||
|
},
|
||||||
|
version() {
|
||||||
|
console.log('version'.grey, VERSION)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const help = () => {
|
export const help = () => {
|
||||||
console.log(
|
console.log(
|
||||||
'\nAutorestic'.blue +
|
'\nAutorestic'.blue +
|
||||||
` - ${VERSION} - Easy Restic CLI Utility` +
|
` - ${VERSION} - Easy Restic CLI Utility` +
|
||||||
'\n' +
|
'\n' +
|
||||||
'\nOptions:'.yellow +
|
'\nOptions:'.yellow +
|
||||||
`\n -c, --config Specify config file. Default: .autorestic.yml` +
|
`\n -c, --config Specify config file. Default: .autorestic.yml` +
|
||||||
'\n' +
|
'\n' +
|
||||||
'\nCommands:'.yellow +
|
'\nCommands:'.yellow +
|
||||||
'\n check [-b, --backend] [-a, --all] Check backends' +
|
'\n info Show all locations and backends' +
|
||||||
'\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
|
'\n check [-b, --backend] [-a, --all] Check backends' +
|
||||||
'\n restore [-l, --location] [-- --target <out dir>] Restore all or specified locations' +
|
'\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
|
||||||
'\n' +
|
'\n forget [-l, --location] [-a, --all] [--dry-run] Forget old snapshots according to declared policies' +
|
||||||
'\n exec [-b, --backend] [-a, --all] <command> -- [native options] Execute native restic command' +
|
'\n restore [-l, --location] [--from backend] [--to <out dir>] Restore all or specified locations' +
|
||||||
'\n' +
|
'\n' +
|
||||||
'\n install install restic' +
|
'\n exec [-b, --backend] [-a, --all] <command> -- [native options] Execute native restic command' +
|
||||||
'\n uninstall uninstall restic' +
|
'\n' +
|
||||||
'\n update update restic' +
|
'\n install install restic' +
|
||||||
'\n help Show help' +
|
'\n uninstall uninstall restic' +
|
||||||
'\n' +
|
'\n update update restic' +
|
||||||
'\nExamples: '.yellow +
|
'\n help Show help' +
|
||||||
'https://git.io/fjVbg' +
|
'\n' +
|
||||||
'\n'
|
'\nExamples: '.yellow +
|
||||||
)
|
'https://git.io/fjVbg' +
|
||||||
|
'\n',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const error = () => {
|
export const error = () => {
|
||||||
help()
|
help()
|
||||||
console.log(
|
console.log(
|
||||||
`Invalid Command:`.red.underline,
|
`Invalid Command:`.red.underline,
|
||||||
`${process.argv.slice(2).join(' ')}`
|
`${process.argv.slice(2).join(' ')}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default handlers
|
export default handlers
|
||||||
|
28
src/info.ts
Normal file
28
src/info.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { config } from './autorestic'
|
||||||
|
import { ConfigError, fill, treeToString } from './utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const showAll = () => {
|
||||||
|
if (!config) throw ConfigError
|
||||||
|
|
||||||
|
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:'],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
81
src/restore.ts
Normal file
81
src/restore.ts
Normal 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 🎉')
|
||||||
|
}
|
||||||
|
|
103
src/types.ts
103
src/types.ts
@@ -1,77 +1,94 @@
|
|||||||
|
export type StringOrArray = string | string[]
|
||||||
|
|
||||||
|
// BACKENDS
|
||||||
|
|
||||||
type BackendLocal = {
|
type BackendLocal = {
|
||||||
type: 'local'
|
type: 'local'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackendSFTP = {
|
type BackendSFTP = {
|
||||||
type: 'sftp'
|
type: 'sftp'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackendREST = {
|
type BackendREST = {
|
||||||
type: 'rest'
|
type: 'rest'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
user?: string
|
user?: string
|
||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackendS3 = {
|
type BackendS3 = {
|
||||||
type: 's3'
|
type: 's3'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
aws_access_key_id: string
|
aws_access_key_id: string
|
||||||
aws_secret_access_key: string
|
aws_secret_access_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackendB2 = {
|
type BackendB2 = {
|
||||||
type: 'b2'
|
type: 'b2'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
b2_account_id: string
|
b2_account_id: string
|
||||||
b2_account_key: string
|
b2_account_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackendAzure = {
|
type BackendAzure = {
|
||||||
type: 'azure'
|
type: 'azure'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
azure_account_name: string
|
azure_account_name: string
|
||||||
azure_account_key: string
|
azure_account_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackendGS = {
|
type BackendGS = {
|
||||||
type: 'gs'
|
type: 'gs'
|
||||||
key: string
|
key: string
|
||||||
path: string
|
path: string
|
||||||
google_project_id: string
|
google_project_id: string
|
||||||
google_application_credentials: string
|
google_application_credentials: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Backend =
|
export type Backend =
|
||||||
| BackendAzure
|
| BackendAzure
|
||||||
| BackendB2
|
| BackendB2
|
||||||
| BackendGS
|
| BackendGS
|
||||||
| BackendLocal
|
| BackendLocal
|
||||||
| BackendREST
|
| BackendREST
|
||||||
| BackendS3
|
| BackendS3
|
||||||
| BackendSFTP
|
| BackendSFTP
|
||||||
|
|
||||||
export type Backends = { [name: string]: Backend }
|
export type Backends = { [name: string]: Backend }
|
||||||
|
|
||||||
|
// LOCATIONS
|
||||||
|
|
||||||
export type Location = {
|
export type Location = {
|
||||||
from: string
|
from: string
|
||||||
to: string | string[]
|
to: StringOrArray
|
||||||
|
hooks?: {
|
||||||
|
before?: StringOrArray
|
||||||
|
after?: StringOrArray
|
||||||
|
}
|
||||||
|
options?: {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: StringOrArray
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Locations = { [name: string]: Location }
|
export type Locations = { [name: string]: Location }
|
||||||
|
|
||||||
|
// OTHER
|
||||||
|
|
||||||
export type Config = {
|
export type Config = {
|
||||||
locations: Locations
|
locations: Locations
|
||||||
backends: Backends
|
backends: Backends
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Flags = { [arg: string]: any }
|
export type Flags = { [arg: string]: any }
|
||||||
|
219
src/utils.ts
219
src/utils.ts
@@ -1,77 +1,200 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { spawnSync, SpawnSyncOptions } from 'child_process'
|
import { spawnSync, SpawnSyncOptions } from 'child_process'
|
||||||
import { randomBytes } from 'crypto'
|
import { createHash, randomBytes } from 'crypto'
|
||||||
import { createWriteStream } from 'fs'
|
import { createWriteStream, renameSync, unlinkSync } from 'fs'
|
||||||
|
import { homedir, tmpdir } from 'os'
|
||||||
|
import { dirname, isAbsolute, join, resolve } from 'path'
|
||||||
|
import { Duration, Humanizer } from 'uhrwerk'
|
||||||
|
|
||||||
export const exec = (
|
import { CONFIG_FILE, LocationFromPrefixes } from './config'
|
||||||
command: string,
|
import { Location } from './types'
|
||||||
args: string[],
|
|
||||||
{ env, ...rest }: SpawnSyncOptions = {}
|
|
||||||
) => {
|
|
||||||
const cmd = spawnSync(command, args, {
|
|
||||||
...rest,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
...env,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const out = cmd.stdout && cmd.stdout.toString().trim()
|
|
||||||
const err = cmd.stderr && cmd.stderr.toString().trim()
|
|
||||||
|
|
||||||
return { out, err }
|
|
||||||
|
export const exec = (command: string, args: string[], { env, ...rest }: SpawnSyncOptions = {}) => {
|
||||||
|
const { stdout, stderr, status } = spawnSync(command, args, {
|
||||||
|
...rest,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
...env,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const out = stdout && stdout.toString().trim()
|
||||||
|
const err = stderr && stderr.toString().trim()
|
||||||
|
|
||||||
|
return { out, err, status }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const execPlain = (command: string, opt: SpawnSyncOptions = {}) => {
|
||||||
|
const split = command.split(' ')
|
||||||
|
if (split.length < 1) throw new Error(`The command ${command} is not valid`.red)
|
||||||
|
|
||||||
|
return exec(split[0], split.slice(1), opt)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
Object.entries(object).map(([key, value]) => [key.toLowerCase(), value])
|
Object.entries(object).map(([key, value]) => [key.toLowerCase(), value]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
export function rand(length = 32): string {
|
export function rand(length = 32): string {
|
||||||
return randomBytes(length / 2).toString('hex')
|
return randomBytes(length / 2).toString('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const singleToArray = <T>(singleOrArray: T | T[]): T[] =>
|
|
||||||
Array.isArray(singleOrArray) ? singleOrArray : [singleOrArray]
|
|
||||||
|
|
||||||
export const filterObject = <T>(
|
export const filterObject = <T>(
|
||||||
obj: { [key: string]: T },
|
obj: { [key: string]: T },
|
||||||
filter: (item: [string, T]) => boolean
|
filter: (item: [string, T]) => boolean,
|
||||||
): { [key: string]: T } =>
|
): { [key: string]: T } =>
|
||||||
Object.fromEntries(Object.entries(obj).filter(filter))
|
Object.fromEntries(Object.entries(obj).filter(filter))
|
||||||
|
|
||||||
export const filterObjectByKey = <T>(
|
export const filterObjectByKey = <T>(
|
||||||
obj: { [key: string]: T },
|
obj: { [key: string]: T },
|
||||||
keys: string[]
|
keys: string[],
|
||||||
) => filterObject(obj, ([key]) => keys.includes(key))
|
) => filterObject(obj, ([key]) => keys.includes(key))
|
||||||
|
|
||||||
export const downloadFile = async (url: string, to: string) =>
|
export const downloadFile = async (url: string, to: string) =>
|
||||||
new Promise<void>(async res => {
|
new Promise<void>(async res => {
|
||||||
const { data: file } = await axios({
|
const { data: file } = await axios({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
url: url,
|
url: url,
|
||||||
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()
|
||||||
res()
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if is an absolute path, otherwise get the path relative to the config file
|
||||||
|
export const pathRelativeToConfigFile = (path: string): string => isAbsolute(path)
|
||||||
|
? path
|
||||||
|
: resolve(dirname(CONFIG_FILE), path)
|
||||||
|
|
||||||
|
export const resolveTildePath = (path: string): string | null =>
|
||||||
|
(path.length === 0 || path[0] !== '~')
|
||||||
|
? null
|
||||||
|
: join(homedir(), path.slice(1))
|
||||||
|
|
||||||
export const ConfigError = new Error('Config file not found')
|
export const ConfigError = new Error('Config file not found')
|
||||||
|
|
||||||
|
export const getFlagsFromLocation = (location: Location, command?: string): string[] => {
|
||||||
|
if (!location.options) return []
|
||||||
|
|
||||||
|
const all = {
|
||||||
|
...location.options.global,
|
||||||
|
...(location.options[command || ''] || {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags: string[] = []
|
||||||
|
// Map the flags to an array for the exec function.
|
||||||
|
for (let [flag, values] of Object.entries(all))
|
||||||
|
for (const value of makeArrayIfIsNot(values)) {
|
||||||
|
const stringValue = String(value)
|
||||||
|
const resolvedTilde = resolveTildePath(stringValue)
|
||||||
|
flags = [...flags, `--${String(flag)}`, resolvedTilde === null ? stringValue : resolvedTilde]
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags
|
||||||
|
}
|
||||||
|
|
||||||
|
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('')
|
||||||
|
|
||||||
|
export const capitalize = (string: string): string => string.charAt(0).toUpperCase() + string.slice(1)
|
||||||
|
|
||||||
|
export const treeToString = (obj: Object, highlight = [] as string[]): string => {
|
||||||
|
let cleaned = JSON.stringify(obj, null, 2)
|
||||||
|
.replace(/[{}"\[\],]/g, '')
|
||||||
|
.replace(/^ {2}/mg, '')
|
||||||
|
.replace(/\n\s*\n/g, '\n')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
for (const word of highlight)
|
||||||
|
cleaned = cleaned.replace(word, capitalize(word).green)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class MeasureDuration {
|
||||||
|
private static Humanizer: Humanizer = [
|
||||||
|
[d => d.hours() > 0, d => `${d.hours()}h ${d.minutes()}min`],
|
||||||
|
[d => d.minutes() > 0, d => `${d.minutes()}min ${d.seconds()}s`],
|
||||||
|
[d => d.seconds() > 0, d => `${d.seconds()}s`],
|
||||||
|
[() => true, d => `${d.milliseconds()}ms`],
|
||||||
|
]
|
||||||
|
|
||||||
|
private start = Date.now()
|
||||||
|
|
||||||
|
|
||||||
|
finished(human?: false): number
|
||||||
|
finished(human?: true): string
|
||||||
|
finished(human?: boolean): number | string {
|
||||||
|
const delta = Date.now() - this.start
|
||||||
|
|
||||||
|
return human
|
||||||
|
? new Duration(delta, 'ms').humanize(MeasureDuration.Humanizer)
|
||||||
|
: delta
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user