mirror of
https://github.com/cupcakearmy/autorestic.git
synced 2025-09-06 10:30:39 +00:00
Compare commits
52 Commits
Author | SHA1 | Date | |
---|---|---|---|
7e577c439a | |||
bc36a39de4 | |||
9e6b393e62 | |||
de34396b93 | |||
ebbe10608a | |||
8a34270934 | |||
e459e393a9 | |||
b1a3074f33 | |||
ae63d8b12e | |||
7aa937dd41 | |||
|
37361727ba | ||
f1874438e5 | |||
|
066342a7b7 | ||
|
f620bb1764 | ||
|
e3506e44b5 | ||
|
f65a83991b | ||
f10b8c7990 | |||
|
a8af085d9c | ||
fa89d2941f | |||
|
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 |
31
.drone.yml
Normal file
31
.drone.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
|
---
|
||||||
|
kind: signature
|
||||||
|
hmac: 3b1f235f6a6f0ee1aa3f572d0833c4f0eec931dbe0378f31b9efa336a7462912
|
||||||
|
|
||||||
|
...
|
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
|
3
CHANGELOG.md
Normal file
3
CHANGELOG.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## 0.16
|
||||||
|
|
||||||
|
- notify user if config file was overwritten and make a copy of it as backup
|
346
README.md
346
README.md
@@ -5,6 +5,10 @@ Autorestic is a wrapper around the amazing [restic](https://restic.net/). While
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## ✈️ Roadmap
|
||||||
|
|
||||||
|
I would like to make the official `1.0` release in the coming months. Until then please feel free to file issues or feature requests so that the tool is as flexible as possible :)
|
||||||
|
|
||||||
## 🌈 Features
|
## 🌈 Features
|
||||||
|
|
||||||
- Config files, no CLI
|
- Config files, no CLI
|
||||||
@@ -13,8 +17,23 @@ 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
|
||||||
|
|
||||||
## Installation
|
### 📒 Docs
|
||||||
|
|
||||||
|
* [Locations](#-locations)
|
||||||
|
* [Pruning & Deleting old files](#pruning-and-snapshot-policies)
|
||||||
|
* [Excluding files](#excluding-filesfolders)
|
||||||
|
* [Hooks](#before--after-hooks)
|
||||||
|
* [Docker volumes](#-Docker-volumes)
|
||||||
|
* [Backends](#-backends)
|
||||||
|
* [Commands](#-commands)
|
||||||
|
* [Examples](#-examples)
|
||||||
|
* [QA](#-qa)
|
||||||
|
|
||||||
|
## 🛳 Installation
|
||||||
|
|
||||||
|
Linux & macOS. Windows is not supported. If you have problems installing please open an issue :)
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
|
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
|
||||||
@@ -22,6 +41,12 @@ curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.
|
|||||||
|
|
||||||
## 🚀 Quickstart
|
## 🚀 Quickstart
|
||||||
|
|
||||||
|
##### ⚠️ Note ⚠️
|
||||||
|
|
||||||
|
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 or config file somewhere in case your server dies**. Otherwise DATA IS LOST!
|
||||||
|
|
||||||
|
Also, currently comments in the config file will be deleted, due how the yaml parsing library works. I will fix this soon :)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
First we need to configure our locations and backends. Simply create a `.autorestic.yml` either in your home directory of in the folder from which you will execute `autorestic`.
|
First we need to configure our locations and backends. Simply create a `.autorestic.yml` either in your home directory of in the folder from which you will execute `autorestic`.
|
||||||
@@ -56,31 +81,21 @@ Then we check if everything is correct by running the `check` command. We will p
|
|||||||
|
|
||||||
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`
|
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`
|
||||||
|
|
||||||
##### 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 --to /path/where/to/restore
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
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`
|
### 📲 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
|
||||||
|
|
||||||
@@ -95,43 +110,7 @@ locations:
|
|||||||
- also-backup-to-this-backend
|
- also-backup-to-this-backend
|
||||||
```
|
```
|
||||||
|
|
||||||
## 💽 Backends
|
#### Pruning and snapshot policies
|
||||||
|
|
||||||
Backends are the place where you data will be saved. Backups are incremental and encrypted.
|
|
||||||
|
|
||||||
### Fields
|
|
||||||
|
|
||||||
##### `type`
|
|
||||||
|
|
||||||
Type of the backend see a list [here](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html)
|
|
||||||
|
|
||||||
Supported are:
|
|
||||||
- [Local](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#local)
|
|
||||||
- [Backblaze B2](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#backblaze-b2)
|
|
||||||
- [Amazon S3](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3)
|
|
||||||
- [Minio](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#minio-server)
|
|
||||||
- [Google Cloud Storage](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#google-cloud-storage)
|
|
||||||
- [Microsoft Azure Storage](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#microsoft-azure-blob-storage)
|
|
||||||
|
|
||||||
For each backend you need to specify the right variables as shown in the example below.
|
|
||||||
|
|
||||||
##### `path`
|
|
||||||
|
|
||||||
The path on the remote server.
|
|
||||||
For object storages as
|
|
||||||
|
|
||||||
##### Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
backends:
|
|
||||||
name-of-backend:
|
|
||||||
type: b2
|
|
||||||
path: 'myAccount:myBucket/my/path'
|
|
||||||
B2_ACCOUNT_ID: backblaze_account_id
|
|
||||||
B2_ACCOUNT_KEY: backblaze_account_key
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pruning and snapshot policies
|
|
||||||
|
|
||||||
Autorestic supports declaring snapshot policies for location to avoid keeping old snapshot around if you don't need them.
|
Autorestic supports declaring snapshot policies for location to avoid keeping old snapshot around if you don't need them.
|
||||||
|
|
||||||
@@ -176,7 +155,7 @@ f8f8f976 2019-12-02 12:11:08 computer within 2w /etc
|
|||||||
3 snapshots
|
3 snapshots
|
||||||
```
|
```
|
||||||
|
|
||||||
### Excluding files/folders
|
#### 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).
|
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).
|
||||||
|
|
||||||
@@ -201,6 +180,267 @@ backends:
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 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"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🐳 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 are the place where you data will be saved. Backups are incremental and encrypted.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
##### `type`
|
||||||
|
|
||||||
|
Type of the backend see a list [here](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html)
|
||||||
|
|
||||||
|
Supported are:
|
||||||
|
- [Local](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#local)
|
||||||
|
- [Backblaze B2](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#backblaze-b2)
|
||||||
|
- [Amazon S3](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3)
|
||||||
|
- [Minio](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#minio-server)
|
||||||
|
- [Google Cloud Storage](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#google-cloud-storage)
|
||||||
|
- [Microsoft Azure Storage](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#microsoft-azure-blob-storage)
|
||||||
|
|
||||||
|
For each backend you need to specify the right variables as shown in the example below.
|
||||||
|
|
||||||
|
##### `path`
|
||||||
|
|
||||||
|
The path on the remote server.
|
||||||
|
For object storages as
|
||||||
|
|
||||||
|
##### Example Local
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backends:
|
||||||
|
name-of-backend:
|
||||||
|
type: local
|
||||||
|
path: /data/my/backups
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Example Backblaze
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backends:
|
||||||
|
name-of-backend:
|
||||||
|
type: b2
|
||||||
|
path: 'myAccount:myBucket/my/path'
|
||||||
|
B2_ACCOUNT_ID: backblaze_account_id
|
||||||
|
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
|
||||||
|
|
||||||
|
* [info](#info)
|
||||||
|
* [check](#check)
|
||||||
|
* [backup](#backup)
|
||||||
|
* [forget](#forget)
|
||||||
|
* [restore](#restore)
|
||||||
|
* [exec](#exec)
|
||||||
|
* [intall](#install)
|
||||||
|
* [uninstall](#uninstall)
|
||||||
|
* [upgrade](#upgrade)
|
||||||
|
|
||||||
|
|
||||||
|
### Info
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## ❓ QA
|
||||||
|
|
||||||
|
### My config file was moved?
|
||||||
|
|
||||||
|
This happens when autorestic needs to write to the config file. This happend e.g. when we are generating a key for you.
|
||||||
|
Unforunately during this process formatting and comments are lost. That is why autorestic will place a copy of your old config next to the one we are writing to.
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
|
|
||||||
This amazing people helped the project!
|
This amazing people helped the project!
|
||||||
|
@@ -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",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"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,7 +3,6 @@ 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'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -26,20 +25,22 @@ export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
|
|||||||
string: ['l', 'b'],
|
string: ['l', 'b'],
|
||||||
})
|
})
|
||||||
|
|
||||||
export const VERSION = '0.8'
|
export const VERSION = '0.16'
|
||||||
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))
|
||||||
|
@@ -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) => {
|
||||||
|
@@ -1,38 +1,106 @@
|
|||||||
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, pathRelativeToConfigFile, getFlagsFromLocation } from './utils'
|
import { Locations, Location, Backend } from './types'
|
||||||
|
import {
|
||||||
|
exec,
|
||||||
|
ConfigError,
|
||||||
|
pathRelativeToConfigFile,
|
||||||
|
getFlagsFromLocation,
|
||||||
|
makeArrayIfIsNot,
|
||||||
|
execPlain,
|
||||||
|
MeasureDuration,
|
||||||
|
fill,
|
||||||
|
decodeLocationFromPrefix,
|
||||||
|
checkIfDockerVolumeExistsOrFail,
|
||||||
|
getPathFromVolume,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const backupFromFilesystem = (from: string, location: Location, backend: Backend, tags?: string[]) => {
|
||||||
|
const path = pathRelativeToConfigFile(from)
|
||||||
|
|
||||||
|
const { 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 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)
|
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) => {
|
||||||
const display = name.yellow + ' ▶ '
|
const display = name.yellow + ' ▶ '
|
||||||
const filler = new Array(name.length + 3).fill(' ').join('')
|
const filler = fill(name.length + 3)
|
||||||
let first = true
|
let first = true
|
||||||
|
|
||||||
for (const t of Array.isArray(location.to) ? location.to : [location.to]) {
|
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)
|
backupSingle(first ? display : filler, t, location)
|
||||||
if (first) first = false
|
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) => {
|
export const backupAll = (locations?: Locations) => {
|
||||||
|
@@ -1,11 +1,19 @@
|
|||||||
import { readFileSync, writeFileSync, statSync } from 'fs'
|
import { readFileSync, writeFileSync, statSync, copyFileSync } 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) => {
|
||||||
@@ -48,8 +56,8 @@ export const normalizeAndCheckBackups = (config: Config) => {
|
|||||||
`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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +88,27 @@ export const init = (): Config | undefined => {
|
|||||||
yaml.safeLoad(readFileSync(CONFIG_FILE).toString()),
|
yaml.safeLoad(readFileSync(CONFIG_FILE).toString()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const current = JSON.stringify(raw)
|
||||||
|
|
||||||
normalizeAndCheckBackends(raw)
|
normalizeAndCheckBackends(raw)
|
||||||
normalizeAndCheckBackups(raw)
|
normalizeAndCheckBackups(raw)
|
||||||
|
|
||||||
writeFileSync(CONFIG_FILE, yaml.safeDump(raw))
|
const changed = JSON.stringify(raw) !== current
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
const OLD_CONFIG_FILE = CONFIG_FILE + '.old'
|
||||||
|
copyFileSync(CONFIG_FILE, OLD_CONFIG_FILE)
|
||||||
|
writeFileSync(CONFIG_FILE, yaml.safeDump(raw))
|
||||||
|
console.log(
|
||||||
|
'\n' +
|
||||||
|
'⚠️ MOVED OLD CONFIG FILE TO: ⚠️'.red.underline.bold +
|
||||||
|
'\n' +
|
||||||
|
OLD_CONFIG_FILE +
|
||||||
|
'\n' +
|
||||||
|
'What? Why? '.grey + 'https://git.io/Jv2D0'.underline.grey +
|
||||||
|
'\n'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
@@ -2,8 +2,16 @@ 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 { exec, ConfigError, pathRelativeToConfigFile, getFlagsFromLocation } from './utils'
|
import {
|
||||||
|
exec,
|
||||||
|
ConfigError,
|
||||||
|
pathRelativeToConfigFile,
|
||||||
|
getFlagsFromLocation,
|
||||||
|
makeArrayIfIsNot,
|
||||||
|
fill, decodeLocationFromPrefix, getPathFromVolume,
|
||||||
|
} from './utils'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -13,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')
|
||||||
@@ -35,10 +53,10 @@ export const forgetSingle = (name: string, to: string, location: Location, dryRu
|
|||||||
|
|
||||||
export const forgetLocation = (name: string, backup: Location, dryRun: boolean) => {
|
export const forgetLocation = (name: string, backup: Location, dryRun: boolean) => {
|
||||||
const display = name.yellow + ' ▶ '
|
const display = name.yellow + ' ▶ '
|
||||||
const filler = new Array(name.length + 3).fill(' ').join('')
|
const filler = fill(name.length + 3)
|
||||||
let first = true
|
let first = true
|
||||||
|
|
||||||
for (const t of Array.isArray(backup.to) ? backup.to : [backup.to]) {
|
for (const t of makeArrayIfIsNot(backup.to)) {
|
||||||
const nameOrBlankSpaces: string = first ? display : filler
|
const nameOrBlankSpaces: string = first ? display : filler
|
||||||
forgetSingle(nameOrBlankSpaces, t, backup, dryRun)
|
forgetSingle(nameOrBlankSpaces, t, backup, dryRun)
|
||||||
if (first) first = false
|
if (first) first = false
|
||||||
|
@@ -1,22 +1,24 @@
|
|||||||
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 { Backend, Backends, Flags, Locations } from './types'
|
import showAll from './info'
|
||||||
|
import { restoreSingle } from './restore'
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ const parseBackend = (flags: Flags): Backends => {
|
|||||||
)
|
)
|
||||||
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)
|
||||||
@@ -55,7 +57,7 @@ const parseLocations = (flags: Flags): 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)
|
||||||
@@ -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,12 +117,15 @@ const handlers: Handlers = {
|
|||||||
console.log(out, err)
|
console.log(out, err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
info() {
|
||||||
|
showAll()
|
||||||
|
},
|
||||||
async install() {
|
async install() {
|
||||||
try {
|
try {
|
||||||
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... ⏳')
|
||||||
@@ -161,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(
|
||||||
@@ -181,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 + ' 🎉',
|
||||||
@@ -222,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! 🚀')
|
||||||
@@ -241,6 +220,7 @@ export const help = () => {
|
|||||||
`\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 info Show all locations and backends' +
|
||||||
'\n check [-b, --backend] [-a, --all] Check backends' +
|
'\n check [-b, --backend] [-a, --all] Check backends' +
|
||||||
'\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
|
'\n backup [-l, --location] [-a, --all] Backup all or specified locations' +
|
||||||
'\n forget [-l, --location] [-a, --all] [--dry-run] Forget old snapshots according to declared policies' +
|
'\n forget [-l, --location] [-a, --all] [--dry-run] Forget old snapshots according to declared policies' +
|
||||||
|
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 🎉')
|
||||||
|
}
|
||||||
|
|
26
src/types.ts
26
src/types.ts
@@ -1,3 +1,7 @@
|
|||||||
|
export type StringOrArray = string | string[]
|
||||||
|
|
||||||
|
// BACKENDS
|
||||||
|
|
||||||
type BackendLocal = {
|
type BackendLocal = {
|
||||||
type: 'local'
|
type: 'local'
|
||||||
key: string
|
key: string
|
||||||
@@ -62,30 +66,26 @@ export type Backend =
|
|||||||
|
|
||||||
export type Backends = { [name: string]: Backend }
|
export type Backends = { [name: string]: Backend }
|
||||||
|
|
||||||
export type ForgetPolicy = {
|
// LOCATIONS
|
||||||
last?: number,
|
|
||||||
hourly?: number,
|
|
||||||
daily?: number,
|
|
||||||
weekly?: number,
|
|
||||||
monthly?: number,
|
|
||||||
yearly?: number,
|
|
||||||
within?: string,
|
|
||||||
tags?: string[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Location = {
|
export type Location = {
|
||||||
from: string
|
from: string
|
||||||
to: string | string[]
|
to: StringOrArray
|
||||||
keep?: ForgetPolicy
|
hooks?: {
|
||||||
|
before?: StringOrArray
|
||||||
|
after?: StringOrArray
|
||||||
|
}
|
||||||
options?: {
|
options?: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
[key: string]: string | 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
|
||||||
|
145
src/utils.ts
145
src/utils.ts
@@ -1,19 +1,18 @@
|
|||||||
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 { isAbsolute, resolve, dirname } from 'path'
|
import { homedir, tmpdir } from 'os'
|
||||||
import { CONFIG_FILE } from './config'
|
import { dirname, isAbsolute, join, resolve } from 'path'
|
||||||
|
import { Duration, Humanizer } from 'uhrwerk'
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -21,22 +20,30 @@ 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 = {}) => {
|
||||||
|
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 =>
|
||||||
@@ -50,9 +57,6 @@ export function rand(length = 32): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -72,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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -86,6 +98,11 @@ export const pathRelativeToConfigFile = (path: string): string => isAbsolute(pat
|
|||||||
? path
|
? path
|
||||||
: resolve(dirname(CONFIG_FILE), 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[] => {
|
export const getFlagsFromLocation = (location: Location, command?: string): string[] => {
|
||||||
@@ -98,12 +115,86 @@ export const getFlagsFromLocation = (location: Location, command?: string): stri
|
|||||||
|
|
||||||
let flags: string[] = []
|
let flags: string[] = []
|
||||||
// Map the flags to an array for the exec function.
|
// Map the flags to an array for the exec function.
|
||||||
for (let [flag, values] of Object.entries(all)) {
|
for (let [flag, values] of Object.entries(all))
|
||||||
if (!Array.isArray(values))
|
for (const value of makeArrayIfIsNot(values)) {
|
||||||
values = [values]
|
const stringValue = String(value)
|
||||||
|
const resolvedTilde = resolveTildePath(stringValue)
|
||||||
|
flags = [...flags, `--${String(flag)}`, resolvedTilde === null ? stringValue : resolvedTilde]
|
||||||
|
}
|
||||||
|
|
||||||
for (const value of values)
|
|
||||||
flags = [...flags, `--${String(flag)}`, String(value)]
|
|
||||||
}
|
|
||||||
return flags
|
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