Compare commits

..

113 Commits
0.10 ... 0.27

Author SHA1 Message Date
d49e0d3836 Merge branch 'master' of https://github.com/cupcakearmy/autorestic 2020-12-09 00:07:45 +01:00
2008ba2771 changelog 2020-12-09 00:07:43 +01:00
1f6c13a595 fix locking issue 2020-12-09 00:07:03 +01:00
8e9b9dcebf Update README.md 2020-12-08 23:46:06 +01:00
fde4edc05f use version from package, stricter compiler 2020-11-29 15:37:39 +01:00
7e6cc7bb32 lock only if config required 2020-11-29 15:27:32 +01:00
878a7bd752 disable colors in ci mode 2020-11-16 17:30:32 +01:00
f6860115a3 remove ununsed log 2020-11-14 00:40:16 +01:00
f43e73ce41 process end in exit 2020-11-14 00:33:52 +01:00
6b4277b57b changelog 2020-11-13 21:47:33 +01:00
d4b8a7223f don't require config for update 2020-11-13 16:27:19 +01:00
cfcc010bc5 update some pkgs 2020-11-13 15:48:48 +01:00
1fd009b819 add ci mode 2020-11-13 15:48:40 +01:00
91e902d7ef docs 2020-11-13 15:48:20 +01:00
b690c1c3a9 correct link 2020-11-07 12:55:16 +01:00
b025aeeeab version bump 2020-11-07 12:50:05 +01:00
01cafb9436 Update README.md 2020-11-07 12:47:06 +01:00
6a2c34df48 vercel 2020-11-07 12:41:51 +01:00
88e47efc68 Merge pull request #30 from cupcakearmy/rewrite
Rewrite with commander & docs cleanup
2020-11-06 23:51:57 +01:00
60d7e0b561 rewrite with commander 2020-11-06 23:51:23 +01:00
e9a7e03af7 docs 2020-11-06 22:48:19 +01:00
d959a50b5a docs 2020-11-06 22:42:00 +01:00
b5bc1a4cee Merge pull request #27 from sumnerboy12/patch-1
fix typo on skipping/scheduled log message
2020-07-24 10:38:07 +02:00
Ben Jones
e61c940d23 fix typo on skipping/scheduled log message 2020-07-24 11:53:27 +12:00
7fb0fbd467 new version 2020-07-23 13:34:23 +02:00
cbc1b33750 docs 2020-07-23 13:33:14 +02:00
e7be01da37 Merge pull request #26 from sumnerboy12/patch-1
add support for rest server backends
2020-07-23 13:29:20 +02:00
Ben Jones
309073fe4d Update available.md 2020-07-23 23:26:31 +12:00
Ben Jones
1243721a7e add support for rest server backends
Backend configuration would be;

```
rest_repo:
  type: rest
  path: http://backup:8001/repo_name
```

Would result in the following env var;
```
RESTIC_REPOSITORY=rest:http://backup:8001/repo_name
```
2020-07-23 15:21:55 +12:00
ebb934d1c5 docs 2020-06-28 22:48:00 +02:00
8417a6e0aa update docs version 2020-06-28 22:47:46 +02:00
2b5efd0499 cron docs 2020-06-28 22:36:44 +02:00
af6e715b27 generate docs 2020-06-28 22:33:22 +02:00
9484299cd9 cron working now 2020-06-28 22:33:15 +02:00
ef93fef36a help for releasing 2020-06-25 09:36:17 +02:00
995f2250a1 version bump 2020-06-25 09:36:11 +02:00
3498e6da7b fix check for executable 2020-06-25 09:33:08 +02:00
7128983581 lock file 2020-06-25 09:21:33 +02:00
158407927a Merge branch 'master' of https://github.com/cupcakearmy/autorestic 2020-06-25 09:17:23 +02:00
ea8bf83799 always unlock 2020-06-25 09:17:20 +02:00
e8a8ccccd0 use longer wrap length 2020-06-25 09:17:02 +02:00
b332897713 upgrade packages, included lock file, fixed types 2020-06-25 09:16:53 +02:00
369037ea77 Merge pull request #23 from jin-park-dev/feature/spell-fix
Feature/spell fix
2020-06-25 08:43:59 +02:00
Jin Park
52cd6fe3fd Fix e to E 2020-06-25 01:40:04 +01:00
Jin Park
b6f7ef6577 Minor spell fixes in doc 2020-06-25 01:23:31 +01:00
1a891fffbd funding 2020-05-26 10:31:59 +02:00
ed68160a9a Update README.md 2020-05-20 19:31:59 +02:00
20ecd21928 Update README.md 2020-05-20 19:30:48 +02:00
df6fd75ca4 Update README.md 2020-05-20 19:30:31 +02:00
c918fb6ded Update README.md 2020-05-20 19:30:05 +02:00
2bc8fe4ee7 logo 2020-05-20 19:26:13 +02:00
757a134362 default open 2020-05-17 19:04:44 +02:00
6bb36cbde0 docs 2020-05-17 18:56:14 +02:00
5c0b67b7fb version bump 2020-05-17 18:14:09 +02:00
1520e10a59 do not allow multiple instances 2020-05-17 18:13:50 +02:00
f9c645120b run in shell to set paths and find restic 2020-05-17 18:12:58 +02:00
8fd0240929 changelog 2020-05-17 15:42:51 +02:00
da7b70c7a7 rebuild links 2020-05-17 15:37:38 +02:00
c138ac9882 Update README.md 2020-05-17 15:33:07 +02:00
af04f1cded Update README.md 2020-05-17 15:32:47 +02:00
3a43b2b113 Update README.md 2020-05-17 15:31:54 +02:00
885c7ca778 finished docs 2020-05-17 15:30:23 +02:00
44a48aab9b Update README.md 2020-05-17 15:14:00 +02:00
f5f550f5b1 fix paths 2020-05-17 15:11:09 +02:00
f1d5a6b5dd docs build 2020-05-17 14:52:35 +02:00
734c962006 docs 2020-05-17 14:52:30 +02:00
d20a2fda68 move image 2020-05-17 14:52:08 +02:00
8dcd0b1f12 add cron to format 2020-05-17 14:48:45 +02:00
9acb6296e4 add cron command 2020-05-17 11:13:02 +02:00
82f6942ff1 remove async 2020-05-17 09:35:18 +02:00
22f5f61ee0 check if cron is valid & give better feedback on init. also make sync 2020-05-17 09:35:07 +02:00
ddce8bf8a7 add cron parser 2020-05-17 09:34:35 +02:00
abeaacf182 check config in the beginning to avoid doing it all over the place 2020-05-17 09:11:33 +02:00
db436587ee version updates 2020-05-17 09:09:57 +02:00
32837e7e45 added extension 2020-05-17 09:09:45 +02:00
500abfbd27 Update README.md 2020-05-15 12:41:06 +02:00
4ee54110a6 Update README.md 2020-03-03 16:17:57 +01:00
92e5071343 Update README.md 2020-03-03 16:07:55 +01:00
7e577c439a notify user if config file was overwritten and make a copy of it as backup 2020-03-02 19:08:20 +01:00
bc36a39de4 Update README.md 2020-03-02 19:01:39 +01:00
9e6b393e62 Update README.md 2020-03-02 18:59:40 +01:00
de34396b93 Update README.md 2020-02-26 12:18:04 +01:00
ebbe10608a Update README.md 2020-02-26 12:13:46 +01:00
8a34270934 Update README.md 2020-02-26 12:12:40 +01:00
e459e393a9 Update README.md 2020-01-23 11:27:00 +01:00
b1a3074f33 changelog 2020-01-23 11:20:44 +01:00
ae63d8b12e protected drone file 2020-01-23 11:19:16 +01:00
7aa937dd41 automatic signing 2020-01-23 11:09:57 +01:00
cupcakearmy
37361727ba Merge remote-tracking branch 'origin/master' 2020-01-08 00:48:13 +01:00
f1874438e5 Update README.md 2020-01-08 00:46:36 +01:00
cupcakearmy
066342a7b7 changelog 2020-01-08 00:45:39 +01:00
cupcakearmy
f620bb1764 version bump and help command in addition to flag 2020-01-08 00:34:36 +01:00
cupcakearmy
e3506e44b5 enable sftp 2020-01-08 00:32:33 +01:00
cupcakearmy
f65a83991b Merge remote-tracking branch 'origin/master' 2020-01-08 00:30:16 +01:00
f10b8c7990 Update README.md 2020-01-08 00:29:12 +01:00
cupcakearmy
a8af085d9c dont' get stuck if backend is not supported 2020-01-08 00:22:49 +01:00
fa89d2941f Update README.md 2019-12-24 19:05:26 +01:00
cupcakearmy
bcabd467c9 changelog 2019-12-24 18:48:18 +01:00
cupcakearmy
005072b90f Merge remote-tracking branch 'origin/master' 2019-12-24 18:42:18 +01:00
cupcakearmy
d13d4f7cf1 if there is an error while backing up, show it to the user 2019-12-24 18:42:09 +01:00
330e3254f7 Update README.md 2019-12-24 17:51:03 +01:00
38763ed919 Update README.md 2019-12-24 17:50:44 +01:00
cupcakearmy
886b6362cd remove duplicated code and make the forget function compatible with the new docker mounts options 2019-12-24 17:31:44 +01:00
cupcakearmy
9ece1d867d typo 2019-12-24 16:54:36 +01:00
cupcakearmy
485ada6599 CHANGELOG 2019-12-24 16:53:32 +01:00
cupcakearmy
e80db74af4 ordered gitignore 2019-12-24 16:52:27 +01:00
cupcakearmy
2fd9e2dd22 typo 2019-12-24 16:52:01 +01:00
0c654eacf1 Update README.md 2019-12-24 00:11:41 +01:00
cupcakearmy
8fdf5188ff cleaner error handling & version bump 2019-12-22 14:26:27 +01:00
cupcakearmy
22d93f0b9c fix self update in Debian systems 2019-12-22 14:25:52 +01:00
cupcakearmy
f940f23338 tidy up imports 2019-12-22 14:25:22 +01:00
cupcakearmy
678aa96c06 version bump 2019-12-21 23:38:07 +01:00
cupcakearmy
e51eacf13c support for tilde in optional arguments 2019-12-21 23:37:44 +01:00
76 changed files with 8195 additions and 1064 deletions

15
.codedoc/build.ts Normal file
View File

@@ -0,0 +1,15 @@
import { build } from '@codedoc/core';
import { config } from './config';
import { installTheme$ } from './content/theme';
import { content } from './content';
build(config, content, installTheme$, {
resolve: {
modules: ['.codedoc/node_modules']
},
resolveLoader: {
modules: ['.codedoc/node_modules']
}
});

24
.codedoc/config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { configuration } from '@codedoc/core'
export const config = configuration({
src: {
base: 'docs',
},
dest: {
html: './build',
assets: './build',
bundle: './_',
styles: './_',
},
page: {
title: {
base: 'Autorestic',
},
},
misc: {
github: {
user: 'cupcakearmy',
repo: 'autorestic',
},
},
})

View File

@@ -0,0 +1,19 @@
import { CodedocConfig } from '@codedoc/core';
import { Footer as _Footer, GitterToggle$, Watermark} from '@codedoc/core/components';
export function Footer(config: CodedocConfig, renderer: any) {
let github$;
if (config.misc?.github)
github$ = <a href={`https://github.com/${config.misc.github.user}/${config.misc.github.repo}/`}
target="_blank">GitHub</a>;
let community$;
if (config.misc?.gitter)
community$ = <GitterToggle$ room={config.misc.gitter.room}/>
if (github$ && community$) return <_Footer>{github$}<hr/>{community$}</_Footer>;
else if (github$) return <_Footer>{github$}</_Footer>;
else if (community$) return <_Footer>{community$}</_Footer>;
else return <_Footer><Watermark/></_Footer>;
}

View File

@@ -0,0 +1,21 @@
import { CodedocConfig } from '@codedoc/core';
import { Header as _Header, GithubButton, Watermark } from '@codedoc/core/components';
export function Header(config: CodedocConfig, renderer: any) {
return (
<_Header>{config.misc?.github ?
<fragment>
<GithubButton action={config.misc.github.action || 'Star'}
repo={config.misc.github.repo}
user={config.misc.github.user}
large={config.misc.github.large === true}
count={config.misc.github.count !== false}
standardIcon={config.misc.github.standardIcon !== false}/>
<br/><br/>
</fragment>
: ''}
<Watermark/>
</_Header>
)
}

View File

@@ -0,0 +1,57 @@
import { RendererLike } from '@connectv/html'
import { File } from 'rxline/fs'
import {
Page,
Meta,
ContentNav,
Fonts,
ToC,
GithubSearch$,
} from '@codedoc/core/components'
import { config } from '../config'
import { Header } from './header'
import { Footer } from './footer'
export function content(
_content: HTMLElement,
toc: HTMLElement,
renderer: RendererLike<any, any>,
file: File<string>
) {
return (
<Page
title={config.page.title.extractor(_content, config, file)}
favicon={config.page.favicon}
meta={<Meta {...config.page.meta} />}
fonts={<Fonts {...config.page.fonts} />}
scripts={config.page.scripts}
stylesheets={config.page.stylesheets}
header={<Header {...config} />}
footer={<Footer {...config} />}
toc={
<ToC
default={'open'}
search={
config.misc?.github ? (
<GithubSearch$
repo={config.misc.github.repo}
user={config.misc.github.user}
root={config.src.base}
pick={config.src.pick.source}
drop={config.src.drop.source}
/>
) : (
false
)
}
>
{toc}
</ToC>
}
>
{_content}
<ContentNav content={_content} />
</Page>
)
}

View File

@@ -0,0 +1,8 @@
import { funcTransport } from '@connectv/sdh/transport';
import { useTheme } from '@codedoc/core/transport';
import { theme } from '../theme';
export function installTheme() { useTheme(theme); }
export const installTheme$ = /*#__PURE__*/funcTransport(installTheme);

4739
.codedoc/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

5
.codedoc/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@codedoc/core": "^0.2.15"
}
}

18
.codedoc/serve.ts Normal file
View File

@@ -0,0 +1,18 @@
import { join } from 'path';
import { serve } from '@codedoc/core';
import { config } from './config';
import { content } from './content';
import { installTheme$ } from './content/theme';
const root = join(__dirname, '../');
serve(root, config, content, installTheme$, {
resolve: {
modules: ['.codedoc/node_modules']
},
resolveLoader: {
modules: ['.codedoc/node_modules']
}
});

11
.codedoc/theme.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createTheme } from '@codedoc/core/transport';
export const theme = /*#__PURE__*/createTheme({
light: {
primary: '#1eb2a6'
},
dark: {
primary: '#1eb2a6'
}
});

26
.codedoc/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es6",
"noImplicitAny": true,
"declaration": false,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"alwaysStrict": true,
"sourceMap": true,
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react",
"jsxFactory": "renderer.create",
"lib": [
"es2017",
"dom"
]
},
"include": [
"./**/*"
]
}

22
.codedoc/watch.ts Normal file
View File

@@ -0,0 +1,22 @@
import { exec, spawn } from 'child_process';
import { config } from './config';
const cmd = 'ts-node-dev';
const params = `--project .codedoc/tsconfig.json`
+ ` -T --watch ${config.src.base},.codedoc`
+ ` --ignore-watch .codedoc/node_modules`
+ ` .codedoc/serve`;
if (process.platform === 'win32') {
const child = exec(cmd + ' ' + params);
child.stdout?.pipe(process.stdout);
child.stderr?.pipe(process.stderr);
child.on('close', () => {});
}
else {
const child = spawn(cmd, [params], { stdio: 'inherit', shell: 'bash' });
child.on('close', () => {});
}

31
.drone.yml Normal file
View 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
...

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: cupcakearmy

BIN
.github/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

17
.gitignore vendored
View File

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

View File

@@ -1,3 +0,0 @@
semi: false
singleQuote: true
trailingComma: 'es5'

4
.prettierrc.yml Normal file
View File

@@ -0,0 +1,4 @@
semi: false
singleQuote: true
trailingComma: 'es5'
printWidth: 150

3
CHANGELOG.md Normal file
View File

@@ -0,0 +1,3 @@
## 0.27
- fix locking issue

344
README.md
View File

@@ -1,331 +1,25 @@
# autorestic
High backup level CLI utility for [restic](https://restic.net/).
<p align="center">
<br>
<br>
<br>
<img align="center" src="https://github.com/cupcakearmy/autorestic/raw/master/.github/logo.png" height="50" alt="autorestic logo">
<br>
<br>
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 🙂
<p align="center">
Config driven, easy backup cli for <a href="https://restic.net/">restic</a>.
<br>
<strong><a href="https://autorestic.vercel.app/">»»» Docs & Getting Started »»»</a></strong>
</p>
</p>
![Sketch](./docs/Sketch.png)
<br>
<br>
## 🌈 Features
### Why / What?
- Config files, no CLI
- Predictable
- Backup locations to multiple backends
- Snapshot policies and pruning
- Simple interface
- Fully encrypted
Autorestic is a wrapper around the amazing [restic](https://restic.net/). While being amazing the restic cli can be a bit overwhelming and difficult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂
### 📒 Docs
### Questions / Support
* [Locations](#-locations)
* [Pruning & Deleting old files](#pruning-and-snapshot-policies)
* [Excluding files](#excluding-filesfolders)
* [Hooks](#before--after-hooks)
* [Backends](#-backends)
* [Commands](#-commands)
## 🛳 Installation
Linux & macOS. Windows is not supported.
```
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
```
## 🚀 Quickstart
### 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`.
Optionally you can specify the location of your config file by passing it as argument: `autorestic -c ../path/config.yml`
```yaml
locations:
home:
from: /home/me
to: remote
important:
from: /path/to/important/stuff
to:
- remote
- hdd
backends:
remote:
type: b2
path: 'myBucket:backup/home'
B2_ACCOUNT_ID: account_id
B2_ACCOUNT_KEY: account_key
hdd:
type: local
path: /mnt/my_external_storage
```
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`
##### 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 somewhere in case your server dies. Otherwise DATA IS LOST!
### 📦 Backup
```
autorestic backup -a
```
### 📼 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
A location simply a folder on your machine that restic will backup. The paths can be relative from the config file. A location can have multiple backends, so that the data is secured across multiple servers.
```yaml
locations:
my-location-name:
from: path/to/backup
to:
- name-of-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 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
```
## 👉 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`
```
autorestic restore
```
Performes a backup of all locations if the `-a` flag is passed. To only backup some locations pass one or more `-l` or `--location` flags.
### Forget
```
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
## Contributors
This amazing people helped the project!
- @ChanceM [Docs]
- @EliotBerriot [Docs, Pruning, S3]
Check the [discussions page](https://github.com/cupcakearmy/autorestic/discussions)

9
RELEASE.md Normal file
View File

@@ -0,0 +1,9 @@
# Releasing
Releases are handled by the CD server with includes checksums for each binary.
```bash
git tag 0.x
git push
git push origin --tags
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

38
docs/_toc.md Normal file
View File

@@ -0,0 +1,38 @@
[Home](/)
[Quick Start](/quick)
[Installation](/installation)
[Configuration](/config)
> :Collapse label=Locations
>
> [Overview](/location/overview)
> [Hooks](/location/hooks)
> [Excluding Files](/location/exclude)
> [Forget Policy](/location/forget)
> [Cron](/location/cron)
> [Docker Volumes](/location/docker)
> :Collapse label=Backend
>
> [Overview](/backend/overview)
> [Available Backends](/backend/available)
> :Collapse label=CLI
>
> [General](/cli/general)
> [Info](/cli/info)
> [Check](/cli/check)
> [Backup](/cli/backup)
> [Restore](/cli/restore)
> [Forget](/cli/forget)
> [Cron](/cli/cron)
> [Exec](/cli/exec)
> [Install](/cli/install)
> [Uninstall](/cli/uninstall)
> [Update](/cli/update)
[Examples](/examples)
[QA](/qa)
[Contributors](/contrib)

62
docs/backend/available.md Normal file
View File

@@ -0,0 +1,62 @@
# Available Backends
In theory [all the restic backends](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html) are supported.
Those tested are the following:
## Local
```yaml
backends:
name-of-backend:
type: local
path: /data/my/backups
```
## 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
```
## 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
```
## 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](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#sftp) on the matter.
```yaml
backends:
name-of-backend:
type: sftp
path: my-host:/remote/path/on/the/server
```
## Rest Server
See [here](https://github.com/restic/rest-server) for how to install a rest server backend and [here](https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html#rest-server) for further documentation.
```yaml
backends:
name-of-backend:
type: rest
path: http://localhost:8000/repo_name
```
> :ToCPrevNext

16
docs/backend/overview.md Normal file
View File

@@ -0,0 +1,16 @@
# 💽 Backends
Backends are the ouputs of the backup process. Each location needs at least one.
```yaml | .autorestic.yml
backends:
name-of-backend:
type: local
path: /data/my/backups
```
## Types
We restic supports multiple types of backends. See the [full list](/backend/available) for details.
> :ToCPrevNext

13
docs/cli/backup.md Normal file
View File

@@ -0,0 +1,13 @@
# Backup
```bash
autorestic backup [-l, --location] [-a, --all]
```
Performs a backup of all locations if the `-a` flag is passed. To only backup some locations pass one or more `-l` or `--location` flags.
```bash
autorestic backup -l my-location
```
> :ToCPrevNext

15
docs/cli/check.md Normal file
View File

@@ -0,0 +1,15 @@
# Check
```bash
autorestic check [-b, --backend] [-a, --all]
```
Cheks if one or more backend are configured properly and initializes them if they are not already.
This is mostly an internal command, but useful to verify if a backend is configured correctly.
```bash
autorestic check -b my-backend
```
> :ToCPrevNext

11
docs/cli/cron.md Normal file
View File

@@ -0,0 +1,11 @@
# Cron
```bash
autorestic cron
```
This command is mostly intended to be triggered by an automated system like systemd or crontab.
It will run cron jobs es [specified in the cron section](/locations/cron) of a specific location.
> :ToCPrevNext

15
docs/cli/exec.md Normal file
View File

@@ -0,0 +1,15 @@
# Exec
```bash
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:
```bash
autorestic exec -a -- snapshots
```
With `exec` you can basically run every cli command that you would be able to run with the restic cli. It only pre-fills path, key, etc.
> :ToCPrevNext

11
docs/cli/forget.md Normal file
View File

@@ -0,0 +1,11 @@
# Forget
```bash
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](/locations/forget)
The `--dry-run` flag will do a dry run showing what would have been deleted, but won't touch the actual data.
> :ToCPrevNext

29
docs/cli/general.md Normal file
View File

@@ -0,0 +1,29 @@
# General
## `--version`
Prints the current version
```bash
autorestic --version
```
## `--c, --config`
Specify the config file to be used.
If omitted `autorestic` will search for for a `.autorestic.yml` in the current directory and your home directory.
```bash
autorestic -c /path/to/my/config.yml
```
## `--ci`
> Available since version 0.22
Run the CLI in CI Mode, which means there will be no interactivity.
This can be useful when you want to run cron e.g. as all the output will be saved.
```bash
autorestic --ci
```

18
docs/cli/info.md Normal file
View File

@@ -0,0 +1,18 @@
# Info
Displays the config file that autorestic is referring to.
Useful when you want to quickly see what locations are being backed-up where.
**Pro tip:** if it gets a bit long you can read it more easily with `autorestic info | less` 😉
```bash
autorestic info
```
## With a custom file
```bash
autorestic -c path/to/some/config.yml info
```
> :ToCPrevNext

9
docs/cli/install.md Normal file
View File

@@ -0,0 +1,9 @@
# Install
Installs both restic and autorestic to `/usr/local/bin`.
```bash
autorestic install
```
> :ToCPrevNext

17
docs/cli/restore.md Normal file
View File

@@ -0,0 +1,17 @@
# Restore
```bash
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.
## Example
```bash
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`
> :ToCPrevNext

9
docs/cli/uninstall.md Normal file
View File

@@ -0,0 +1,9 @@
# Uninstall
Installs both restic and autorestic from `/usr/local/bin`.
```bash
autorestic uninstall
```
> :ToCPrevNext

11
docs/cli/update.md Normal file
View File

@@ -0,0 +1,11 @@
# Update
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.
```bash
autorestic update
```
Updates both restic and autorestic automagically.
> :ToCPrevNext

42
docs/config.md Normal file
View File

@@ -0,0 +1,42 @@
# 🎛 Config File
## Path
By default autorestic searches for a `.autorestic.yml` file in the current directory and your home folder.
- `./.autorestic.yml`
- `~/.autorestic.yml`
You can also specify a custom file with the `-c path/to/some/config.yml`
> **⚠️ WARNING ⚠️**
>
> 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!
## Example configuration
```yaml | .autorestic.yml
locations:
home:
from: /home/me
to: remote
important:
from: /path/to/important/stuff
to:
- remote
- hdd
backends:
remote:
type: b2
path: 'myBucket:backup/home'
B2_ACCOUNT_ID: account_id
B2_ACCOUNT_KEY: account_key
hdd:
type: local
path: /mnt/my_external_storage
```
> :ToCPrevNext

8
docs/contrib.md Normal file
View File

@@ -0,0 +1,8 @@
# 🙋‍♀️🙋‍♂️ Contributors
This amazing people helped the project!
- @ChanceM [Docs]
- @EliotBerriot [Docs, Pruning, S3]
> :ToCPrevNext

21
docs/examples.md Normal file
View File

@@ -0,0 +1,21 @@
# 🐣 Examples
## Exec
### List all the snapshots for all the backends
```bash
autorestic exec -a -- snapshots
```
### Unlock a locked repository
If you accidentally cancelled a running operation this could be useful.
Only do this if you know what you are doing.
```bash
autorestic exec -b my-backend -- unlock
```
> :ToCPrevNext

27
docs/index.md Normal file
View File

@@ -0,0 +1,27 @@
# `autorestic`
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 difficult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂
<!-- ![Sketch](./docs/Sketch.png) -->
## ✈️ 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 :)~~
As of version `0.18` crons are supported wich where the last feature missing for a `1.0`. Will test this for a few weeks and then it's time for the first "real" release! 🎉 Also we now have waaay better docs 📒
## 🌈 Features
- YAML config files, no CLI
- Incremental -> Minimal space is used
- Backup locations to multiple backends
- Snapshot policies and pruning
- Fully encrypted
- Pre/After hooks
- Exclude pattern/files
- Cron jobs for automatic backup
- Backup & Restore docker volumes
> :ToCPrevNext

11
docs/installation.md Normal file
View File

@@ -0,0 +1,11 @@
# 🛳 Installation
Linux & macOS. Windows is not supported. If you have problems installing please open an issue :)
Autorestic requires `curl`, `wget` and `bzip2` to be installed. For most systems these should be already installed.
```bash
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
```
> :ToCPrevNext

49
docs/location/cron.md Normal file
View File

@@ -0,0 +1,49 @@
# Cron
Often it is usefull to trigger backups autmatically. For this we can specify a `cron` attribute to each location.
> Available since version 0.18
```yaml | .autorestic.yml
locations:
my-location:
from: /data
to: my-backend
cron: '0 3 * * 0' # Every Sunday at 3:00
```
Here is a awesome website with [some examples](https://crontab.guru/examples.html) and an [explorer](https://crontab.guru/)
## Installing the cron
**This has to be done only once, regadless of now many cros you have in your config file.**
To actually enable cron jobs you need something to call `autorestic cron` on a timed shedule.
Note that the shedule has nothing to do with the `cron` attribute in each location.
My advise would be to trigger the command every 5min, but if you have a cronjob that runs only once a week, it's probably enough to shedule it once a day.
### Crontab
Here is an example using crontab, but systemd would do too.
First, open your crontab in edit mode
```bash
crontab -e
```
Then paste this at the bottom of the file and save it. Note that in this specific example the `.autorestic.yml` is located in `/srv/`. You need to modify that part of course to fit your config file.
```bash
# This is required, as it otherwise cannot find restic as a command.
PATH="/usr/local/bin:/usr/bin:/bin"
# Example running every 5 minutes
*/5 * * * * autorestic -c /srv/.autorestic.yml --ci cron
```
> The `--ci` option is not required, but recommended
Now you can add as many `cron` attributes as you wish in the config file ⏱
> :ToCPrevNext

58
docs/location/docker.md Normal file
View File

@@ -0,0 +1,58 @@
# Docker
autorestic supports docker volumes directly, without needing them to be mounted to the host filesystem.
> Available since version 0.13
Let see an example.
```yaml | docker-compose.yml
version: '3.7'
volumes:
data:
name: my-data
services:
api:
image: alpine
volumes:
- data:/foo/bar
```
```yaml | .autorestic.yml
locations:
hello:
from: 'volume:my-data'
to:
- remote
options:
forget:
keep-last: 14 # Useful for limitations explained belowd
backends:
remote: ...
```
Now you can backup and restore as always.
```bash
autorestic -l hello backup
```
```bash
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:
1. Incremental updates are not possible right now due to how the current docker mounting works. This means that it will take significantely more space.
2. Exclude patterns and files also do not work as restic only sees a compressed tarball as source and not the actual data.
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 🙂
> :ToCPrevNext

20
docs/location/exclude.md Normal file
View File

@@ -0,0 +1,20 @@
# 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) so you can use any flag used there.
```yaml
locations:
my-location:
from: /data
to: my-backend
options:
backup:
exclude:
- '*.nope'
- '*.abc'
exclude-file: .gitignore
```
> :ToCPrevNext

25
docs/location/forget.md Normal file
View File

@@ -0,0 +1,25 @@
# Forget/Prune 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:
> **Note** This is a full example, of course you also can specify only one of them
```yaml | .autorestic.yml
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
```
> :ToCPrevNext

18
docs/location/hooks.md Normal file
View File

@@ -0,0 +1,18 @@
# 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.
```yml | .autorestic.yml
locations:
my-location:
from: /data
to: my-backend
hooks:
before:
- echo "Hello"
- echo "Human"
after:
- echo "kthxbye"
```
> :ToCPrevNext

27
docs/location/overview.md Normal file
View File

@@ -0,0 +1,27 @@
# 🗂 Locations
Locations can be seen as the input to the backup process. Generally this is simply a folder.
The paths can be relative from the config file. A location can have multiple backends, so that the data is secured across multiple servers.
```yaml | .autorestic.yml
locations:
my-location-name:
from: path/to/backup
to:
- name-of-backend
- also-backup-to-this-backend
```
## `from`
This is the source of the location.
#### How are paths resolved?
Paths can be absolute or relative. If relative they are resolved relative to the location of the config file. Tilde `~` paths are also supported for home folder resolution.
## `to`
This is einther a single backend or an array of backends. The backends have to be configured in the same config file.
> :ToCPrevNext

8
docs/qa.md Normal file
View File

@@ -0,0 +1,8 @@
# ❓ 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.
Unfortunately 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.
> :ToCPrevNext

72
docs/quick.md Normal file
View File

@@ -0,0 +1,72 @@
# 🚀 Quickstart
## Installation
```bash
curl -s https://raw.githubusercontent.com/CupCakeArmy/autorestic/master/install.sh | bash
```
## Write a simple config file
```bash
vim .autorestic.yml
```
For a quick overview:
- `locations` can be seen as the inputs and `backends` the output where the data is stored and backed up.
- One `location` can have one or multiple `backends` for redudancy.
- One `backend` can also be the target for multiple `locations`.
- **Backup the config file as it will contain the generated keys**. If you don't have a copy of that keys, the backups are useless as they are encrypted and data will be not recoverable.
```yaml | .autorestic.yml
locations:
home:
from: /home/me
to: remote
important:
from: /path/to/important/stuff
to:
- remote
- hdd
backends:
remote:
type: s3
path: 's3.amazonaws.com/bucket_name'
AWS_ACCESS_KEY_ID: account_id
AWS_SECRET_ACCESS_KEY: account_key
hdd:
type: local
path: /mnt/my_external_storage
```
## Check
```bash
autorestic check -a
```
This checks if the config file has any issues. If this is the first time this can take longer as autorestic will setup the backends.
Now is good time to **backup the config**. After you run autorestic at least once we will add the generated encryption keys to the config.
## Backup
```bash
autorestic backup -a
```
This will do a backup of all locations.
## Restore
```bash
autorestic restore -l home --from hdd --to /path/where/to/restore
```
This will restore the location `home` from the backend `hdd` to the given path.
> :ToCPrevNext

View File

@@ -1,25 +1,29 @@
{
"private": true,
"version": "0.27",
"scripts": {
"build": "tsc",
"build:watch": "tsc -w",
"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"
"dev": "tsc -w",
"move": "mv bin/index-linux bin/autorestic_linux_x64 && mv bin/index-macos bin/autorestic_macos_x64",
"bin": "yarn run build && pkg dist/src/index.js --targets latest-macos-x64,latest-linux-x64 --out-path bin && yarn run move",
"docs:build": "codedoc install && codedoc build",
"docs:dev": "codedoc serve"
},
"devDependencies": {
"@types/js-yaml": "^3.12.1",
"@types/minimist": "^1.2.0",
"@types/node": "^12.11.7",
"pkg": "^4.4.0",
"ts-node-dev": "^1.0.0-pre.40",
"typescript": "^3.7"
"@codedoc/cli": "^0.2",
"@types/js-yaml": "^3",
"@types/node": "^14",
"pkg": "^4.4",
"ts-node-dev": "^1",
"typescript": "^3.9"
},
"dependencies": {
"axios": "^0.19.0",
"clitastic": "0.0.1",
"colors": "^1.3.3",
"js-yaml": "^3.13.1",
"minimist": "^1.2.0",
"uhrwerk": "^1.0.0"
"axios": "^0.19",
"clitastic": "^0.1.2",
"colors": "^1",
"commander": "^6.2",
"cron-parser": "2.x.x",
"js-yaml": "3.x.x",
"uhrwerk": "1.x.x"
}
}

View File

@@ -1,44 +0,0 @@
import 'colors'
import minimist from 'minimist'
import { init } from './config'
import handlers, { error, help } from './handlers'
process.on('uncaughtException', err => {
console.log(err.message)
process.exit(1)
})
export const { _: commands, ...flags } = minimist(process.argv.slice(2), {
alias: {
c: 'config',
v: 'version',
h: 'help',
a: 'all',
l: 'location',
b: 'backend',
d: 'dry-run',
},
boolean: ['a', 'd'],
string: ['l', 'b'],
})
export const VERSION = '0.10'
export const INSTALL_DIR = '/usr/local/bin'
export const VERBOSE = flags.verbose
export const config = init()
function main() {
if (commands.length < 1) return help()
const command: string = commands[0]
const args: string[] = commands.slice(1)
;(handlers[command] || error)(args, flags)
}
main()

View File

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

View File

@@ -1,36 +1,76 @@
import { Writer } from 'clitastic'
import { mkdirSync } from 'fs'
import { config, VERBOSE } from './autorestic'
import { config, hasError, VERBOSE } from './'
import { getEnvFromBackend } from './backend'
import { Locations, Location } from './types'
import { LocationFromPrefixes } from './config'
import { Locations, Location, Backend } from './types'
import {
exec,
ConfigError,
pathRelativeToConfigFile,
getFlagsFromLocation,
makeArrayIfIsNot,
execPlain,
MeasureDuration, fill,
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) => {
if (!config) throw ConfigError
const delta = new MeasureDuration()
const writer = new Writer(name + to.blue + ' : ' + 'Backing up... ⏳')
try {
const backend = config.backends[to]
const path = pathRelativeToConfigFile(location.from)
const [type, value] = decodeLocationFromPrefix(location.from)
const cmd = exec(
'restic',
['backup', path, ...getFlagsFromLocation(location, 'backup')],
{ env: getEnvFromBackend(backend) },
)
switch (type) {
case LocationFromPrefixes.Filesystem:
backupFromFilesystem(value, location, backend)
break
case LocationFromPrefixes.DockerVolume:
backupFromVolume(value, location, backend)
break
}
if (VERBOSE) console.log(cmd.out, cmd.err)
writer.done(`${name}${to.blue} : ${'Done ✓'.green} (${delta.finished(true)})`)
} catch (e) {
hasError()
writer.done(`${name}${to.blue} : ${'Failed!'.red} (${delta.finished(true)}) ${e.message}`)
}
}
export const backupLocation = (name: string, location: Location) => {
@@ -40,8 +80,8 @@ export const backupLocation = (name: string, location: Location) => {
if (location.hooks && location.hooks.before)
for (const command of makeArrayIfIsNot(location.hooks.before)) {
const cmd = execPlain(command)
if (cmd) console.log(cmd.out, cmd.err)
const cmd = execPlain(command, {})
console.log(cmd.out, cmd.err)
}
for (const t of makeArrayIfIsNot(location.to)) {
@@ -52,17 +92,13 @@ export const backupLocation = (name: string, location: Location) => {
if (location.hooks && location.hooks.after)
for (const command of makeArrayIfIsNot(location.hooks.after)) {
const cmd = execPlain(command)
if (cmd) console.log(cmd.out, cmd.err)
console.log(cmd.out, cmd.err)
}
}
export const backupAll = (locations?: Locations) => {
if (!locations) {
if (!config) throw ConfigError
locations = config.locations
}
if (!locations) locations = config.locations
console.log('\nBacking Up'.underline.grey)
for (const [name, location] of Object.entries(locations))
backupLocation(name, location)
for (const [name, location] of Object.entries(locations)) backupLocation(name, location)
}

View File

@@ -1,89 +1,104 @@
import { readFileSync, writeFileSync, statSync } from 'fs'
import { readFileSync, writeFileSync, statSync, copyFileSync } from 'fs'
import { resolve } from 'path'
import yaml from 'js-yaml'
import { flags } from './autorestic'
import { Backend, Config } from './types'
import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils'
import { homedir } from 'os'
import yaml from 'js-yaml'
import CronParser from 'cron-parser'
import { Backend, Config } from './types'
import { makeArrayIfIsNot, makeObjectKeysLowercase, rand } from './utils'
export enum LocationFromPrefixes {
Filesystem,
DockerVolume,
}
export const normalizeAndCheckBackends = (config: Config) => {
config.backends = makeObjectKeysLowercase(config.backends)
for (const [name, { type, path, key, ...rest }] of Object.entries(
config.backends,
)) {
if (!type || !path)
throw new Error(
`The backend "${name}" is missing some required attributes`,
)
for (const [name, { type, path, key, ...rest }] of Object.entries(config.backends)) {
if (!type || !path) throw new Error(`The backend "${name}" is missing some required attributes`)
const tmp: any = {
type,
path,
key: key || rand(128),
}
for (const [key, value] of Object.entries(rest))
tmp[key.toUpperCase()] = value
for (const [key, value] of Object.entries(rest)) tmp[key.toUpperCase()] = value
config.backends[name] = tmp as Backend
}
}
export const normalizeAndCheckBackups = (config: Config) => {
export const normalizeAndCheckLocations = (config: Config) => {
config.locations = makeObjectKeysLowercase(config.locations)
const backends = Object.keys(config.backends)
const checkDestination = (backend: string, backup: string) => {
if (!backends.includes(backend))
throw new Error(`Cannot find the backend "${backend}" for "${backup}"`)
const checkDestination = (backend: string, location: string) => {
if (!backends.includes(backend)) throw new Error(`Cannot find the backend "${backend}" for "${location}"`)
}
for (const [name, { from, to, ...rest }] of Object.entries(
config.locations,
)) {
if (!from || !to)
throw new Error(
`The backup "${name}" is missing some required attributes`,
)
for (const [name, { from, to, cron, ...rest }] of Object.entries(config.locations)) {
if (!from) throw new Error(`The location "${name.blue}" is missing the "${'from'.underline.red}" source folder. See https://git.io/Jf0xw`)
if (!to || (Array.isArray(to) && !to.length))
throw new Error(`The location "${name.blue}" has no backend "${'to'.underline.red}" to save the backups. See https://git.io/Jf0xw`)
for (const t of makeArrayIfIsNot(to))
checkDestination(t, name)
for (const t of makeArrayIfIsNot(to)) checkDestination(t, name)
if (cron) {
try {
CronParser.parseExpression(cron)
} catch {
throw new Error(`The location "${name.blue}" has an invalid ${'cron'.underline.red} entry. See https://git.io/Jf0xP`)
}
}
}
}
const findConfigFile = (): string | undefined => {
const findConfigFile = (custom: string): string => {
const config = '.autorestic.yml'
const paths = [
resolve(flags.config || ''),
resolve('./' + config),
homedir() + '/' + config,
]
const paths = [resolve(custom || ''), resolve('./' + config), homedir() + '/' + config]
for (const path of paths) {
try {
const file = statSync(path)
if (file.isFile()) return path
} catch (e) {
}
} catch (e) {}
}
throw new Error('Config file not found')
}
export let CONFIG_FILE: string = ''
export const init = (): Config | undefined => {
const file = findConfigFile()
if (file) CONFIG_FILE = file
else return
export const init = (custom: string): Config => {
const file = findConfigFile(custom)
CONFIG_FILE = file
const raw: Config = makeObjectKeysLowercase(
yaml.safeLoad(readFileSync(CONFIG_FILE).toString()),
)
const parsed = yaml.safeLoad(readFileSync(CONFIG_FILE).toString())
if (!parsed || typeof parsed === 'string') throw new Error('Could not parse the config file')
const raw: Config = makeObjectKeysLowercase(parsed)
const current = JSON.stringify(raw)
normalizeAndCheckBackends(raw)
normalizeAndCheckBackups(raw)
normalizeAndCheckLocations(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/Jf0xK'.underline.grey +
'\n'
)
}
return raw
}

31
src/cron.ts Normal file
View File

@@ -0,0 +1,31 @@
import CronParser from 'cron-parser'
import { config } from './'
import { checkAndConfigureBackendsForLocations } from './backend'
import { Location } from './types'
import { backupLocation } from './backup'
import { readLock, writeLock } from './lock'
const runCronForLocation = (name: string, location: Location) => {
const lock = readLock()
const parsed = CronParser.parseExpression(location.cron || '')
const last = parsed.prev()
if (!lock.crons[name] || last.toDate().getTime() > lock.crons[name].lastRun) {
backupLocation(name, location)
lock.crons[name] = { lastRun: Date.now() }
writeLock(lock)
} else {
console.log(`${name.yellow} ▶ Skipping. Scheduled for: ${parsed.next().toString().underline.blue}`)
}
}
export const runCron = () => {
const locationsWithCron = Object.entries(config.locations).filter(([name, { cron }]) => !!cron)
checkAndConfigureBackendsForLocations(Object.fromEntries(locationsWithCron))
console.log('\nRunning cron jobs'.underline.gray)
for (const [name, location] of locationsWithCron) runCronForLocation(name, location)
console.log('\nFinished!'.underline + ' 🎉')
}

View File

@@ -1,40 +1,37 @@
import { Writer } from 'clitastic'
import { config, VERBOSE } from './autorestic'
import { config, VERBOSE } from './'
import { getEnvFromBackend } from './backend'
import { LocationFromPrefixes } from './config'
import { Locations, Location, Flags } from './types'
import {
exec,
ConfigError,
pathRelativeToConfigFile,
getFlagsFromLocation,
makeArrayIfIsNot,
fill,
} from './utils'
import { exec, 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 path = pathRelativeToConfigFile(location.from)
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')
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) },
)
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)
@@ -52,16 +49,13 @@ export const forgetLocation = (name: string, backup: Location, dryRun: boolean)
}
}
export const forgetAll = (backups?: Locations, flags?: Flags) => {
if (!config) throw ConfigError
export const forgetAll = (backups?: Locations, dryRun = false) => {
if (!backups) {
backups = config.locations
}
console.log('\nRemoving old snapshots according to policy'.underline.grey)
const dryRun = flags ? flags['dry-run'] : false
if (dryRun) console.log('Running in dry-run mode, not touching data\n'.yellow)
for (const [name, backup] of Object.entries(backups))
forgetLocation(name, backup, dryRun)
for (const [name, backup] of Object.entries(backups)) forgetLocation(name, backup, dryRun)
}

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

136
src/index.ts Normal file
View File

@@ -0,0 +1,136 @@
import colors from 'colors'
import { program } from 'commander'
import { setCIMode } from 'clitastic'
import { unlock, readLock, writeLock, lock } from './lock'
import { Config } from './types'
import { init } from './config'
import { version } from '../package.json'
import info from './handlers/info'
import check from './handlers/check'
import backup from './handlers/backup'
import restore from './handlers/restore'
import forget from './handlers/forget'
import { cron } from './handlers/cron'
import exec from './handlers/exec'
import install from './handlers/install'
import { uninstall } from './handlers/uninstall'
import { upgrade } from './handlers/upgrade'
export const VERSION = version
export const INSTALL_DIR = '/usr/local/bin'
let requireConfig: boolean = true
let error: boolean = false
export function hasError() {
error = true
}
process.on('uncaughtException', (err) => {
console.log(err.message)
unlock()
process.exit(1)
})
let queue: () => Promise<void> = async () => {}
const enqueue = (fn: Function) => (cmd: any) => {
queue = async () => fn(cmd.opts())
}
program.storeOptionsAsProperties()
program.name('autorestic').description('Easy Restic CLI Utility').version(VERSION)
program.option('-c, --config <path>', 'Config file')
program.option('-v, --verbose', 'Verbosity', false)
program.option('--ci', 'CI Mode. Removes interactivity from the shell', false)
program.command('info').action(enqueue(info))
program.on('--help', () => {
console.log('')
console.log(`${'Docs:'.yellow}\t\thttps://autorestic.vercel.app`)
console.log(`${'Examples:'.yellow}\thttps://autorestic.vercel.app/examples`)
})
program
.command('check')
.description('Checks and initializes backend as needed')
.option('-b, --backend <backends...>')
.option('-a, --all')
.action(enqueue(check))
program.command('backup').description('Performs a backup').option('-l, --location <locations...>').option('-a, --all').action(enqueue(backup))
program
.command('restore')
.description('Restores data to a specified folder from a location')
.requiredOption('-l, --location <location>')
.option('--from <backend>')
.requiredOption('--to <path>', 'Path to save the restored data to')
.action(enqueue(restore))
program
.command('forget')
.description('This will prune and remove data according to your policies')
.option('-l, --location <locations...>')
.option('-a, --all')
.option('--dry-run')
.action(enqueue(forget))
program
.command('cron')
.description('Intended to be triggered by an automated system like systemd or crontab.')
.option('-a, --all')
.action(enqueue(cron))
program
.command('exec')
.description('Run any native restic command on desired backends')
.option('-b, --backend <backends...>')
.option('-a, --all')
.action(({ args, all, backend }) => {
queue = async () => exec({ all, backend }, args)
})
program.command('install').description('Installs both restic and autorestic to /usr/local/bin').action(enqueue(install))
program.command('uninstall').description('Uninstalls autorestic from the system').action(enqueue(uninstall))
program
.command('upgrade')
.alias('update')
.description('Checks and installs new autorestic versions')
.action(() => {
requireConfig = false
queue = upgrade
})
const { verbose, config: configFile, ci } = program.parse(process.argv)
export const VERBOSE = verbose
export let config: Config
setCIMode(ci)
if (ci) colors.disable()
async function main() {
try {
if (requireConfig) {
config = init(configFile)
const { running } = readLock()
if (running) {
console.log('An instance of autorestic is already running for this config file'.red)
process.exit(1)
}
lock()
}
await queue()
} catch (e) {
console.error(e.message)
} finally {
if (requireConfig) unlock()
}
if (error) process.exit(1)
}
main()

View File

@@ -1,27 +1,17 @@
import { config } from './autorestic'
import { ConfigError, fill, treeToString } from './utils'
import { config } from './'
import { 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(treeToString(data, ['to:', 'from:', 'hooks:', 'options:', 'cron:']))
}
console.log('\n\n' + fill(32, '_') + 'BACKENDS:'.underline)
for (const [key, data] of Object.entries(config.backends)) {
console.log(`\n${key.blue.underline}:`)
console.log(treeToString(
data,
['type:', 'path:', 'key:'],
))
console.log(treeToString(data, ['type:', 'path:', 'key:']))
}
}

39
src/lock.ts Normal file
View File

@@ -0,0 +1,39 @@
import fs from 'fs'
import { pathRelativeToConfigFile } from './utils'
import { Lockfile } from './types'
export const getLockFileName = () => {
const LOCK_FILE = '.autorestic.lock'
return pathRelativeToConfigFile(LOCK_FILE)
}
export const readLock = (): Lockfile => {
const name = getLockFileName()
let lock = {
running: false,
crons: {},
}
try {
lock = JSON.parse(fs.readFileSync(name, { encoding: 'utf-8' }))
} catch {}
return lock
}
export const writeLock = (lock: Lockfile) => {
const name = getLockFileName()
fs.writeFileSync(name, JSON.stringify(lock, null, 2), { encoding: 'utf-8' })
}
export const unlock = () => {
writeLock({
...readLock(),
running: false,
})
}
export const lock = () => {
writeLock({
...readLock(),
running: true,
})
}

63
src/restore.ts Normal file
View File

@@ -0,0 +1,63 @@
import { Writer } from 'clitastic'
import { resolve } from 'path'
import { config } from './'
import { getEnvFromBackend } from './backend'
import { LocationFromPrefixes } from './config'
import { Backend } from './types'
import { checkIfDockerVolumeExistsOrFail, decodeLocationFromPrefix, exec, execPlain, getPathFromVolume } from './utils'
export const restoreToFilesystem = (from: string, to: string, backend: Backend) => {
exec('restic', ['restore', 'latest', '--path', resolve(from), '--target', to], { env: getEnvFromBackend(backend) })
}
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) => {
const location = config.locations[locationName]
const baseText = locationName.green + '\t\t'
const w = new Writer(baseText + `Restoring...`)
let backendName: string = Array.isArray(location.to) ? location.to[0] : location.to
if (from) {
if (!location.to.includes(from)) {
w.done(baseText + `Backend ${from} is not a valid location for ${locationName}`.red)
return
}
backendName = from
w.replaceLn(baseText + `Restoring from ${backendName.blue}...`)
} else if (Array.isArray(location.to) && location.to.length > 1) {
w.replaceLn(baseText + `Restoring from ${backendName.blue}...\tTo select a specific backend pass the ${'--from'.blue} flag`)
}
const backend = config.backends[backendName]
const [type, value] = decodeLocationFromPrefix(location.from)
switch (type) {
case LocationFromPrefixes.Filesystem:
if (!to) throw new Error(`You need to specify the restore path with --to`.red)
restoreToFilesystem(value, to, backend)
break
case LocationFromPrefixes.DockerVolume:
restoreToVolume(value, backend)
break
}
w.done(locationName.green + '\t\tDone 🎉')
}

View File

@@ -55,14 +55,7 @@ type BackendGS = {
google_application_credentials: string
}
export type Backend =
| BackendAzure
| BackendB2
| BackendGS
| BackendLocal
| BackendREST
| BackendS3
| BackendSFTP
export type Backend = BackendAzure | BackendB2 | BackendGS | BackendLocal | BackendREST | BackendS3 | BackendSFTP
export type Backends = { [name: string]: Backend }
@@ -71,6 +64,7 @@ export type Backends = { [name: string]: Backend }
export type Location = {
from: string
to: StringOrArray
cron?: string
hooks?: {
before?: StringOrArray
after?: StringOrArray
@@ -91,4 +85,13 @@ export type Config = {
backends: Backends
}
export type Lockfile = {
running: boolean
crons: {
[name: string]: {
lastRun: number
}
}
}
export type Flags = { [arg: string]: any }

View File

@@ -1,21 +1,18 @@
import axios from 'axios'
import { spawnSync, SpawnSyncOptions } from 'child_process'
import { randomBytes } from 'crypto'
import { createWriteStream } from 'fs'
import { dirname, isAbsolute, resolve } from 'path'
import { createHash, randomBytes } from 'crypto'
import { createWriteStream, renameSync, unlinkSync } from 'fs'
import { homedir, tmpdir } from 'os'
import { dirname, isAbsolute, join, resolve } from 'path'
import axios from 'axios'
import { Duration, Humanizer } from 'uhrwerk'
import { CONFIG_FILE } from './config'
import { Location } from './types'
import { CONFIG_FILE, LocationFromPrefixes } from './config'
import { Backends, Location, Locations } from './types'
import { config } from '.'
export const exec = (
command: string,
args: string[],
{ env, ...rest }: SpawnSyncOptions = {},
) => {
const cmd = spawnSync(command, args, {
export const exec = (command: string, args: string[], { env, ...rest }: SpawnSyncOptions = {}) => {
const { stdout, stderr, status } = spawnSync(command, args, {
...rest,
env: {
...process.env,
@@ -23,79 +20,73 @@ export const exec = (
},
})
const out = cmd.stdout && cmd.stdout.toString().trim()
const err = cmd.stderr && cmd.stderr.toString().trim()
const out = stdout && stdout.toString().trim()
const err = stderr && stderr.toString().trim()
return { out, err }
return { out, err, status }
}
export const execPlain = (command: string, opt: SpawnSyncOptions = {}) => {
const split = command.split(' ')
if (split.length < 1) {
console.log(`The command ${command} is not valid`.red)
return
}
if (split.length < 1) throw new Error(`The command ${command} is not valid`.red)
return exec(split[0], split.slice(1), opt)
return exec(split[0], split.slice(1), { shell: true, ...opt })
}
export const checkIfResticIsAvailable = () =>
checkIfCommandIsAvailable(
'restic',
'Restic is not installed'.red +
' https://restic.readthedocs.io/en/latest/020_installation.html#stable-releases',
'restic is not installed'.red +
'\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) => {
if (require('child_process').spawnSync(cmd).error)
throw new Error(errorMsg ? errorMsg : `"${errorMsg}" is not installed`.red)
const error = spawnSync(cmd, { shell: true }).stderr
if (error.length) throw new Error(errorMsg ? errorMsg : `"${cmd}" is not installed`.red)
}
export const makeObjectKeysLowercase = (object: Object): any =>
Object.fromEntries(
Object.entries(object).map(([key, value]) => [key.toLowerCase(), value]),
)
Object.fromEntries(Object.entries(object).map(([key, value]) => [key.toLowerCase(), value]))
export function rand(length = 32): string {
return randomBytes(length / 2).toString('hex')
}
export const filterObject = <T>(
obj: { [key: string]: T },
filter: (item: [string, T]) => boolean,
): { [key: string]: T } =>
export const filterObject = <T>(obj: { [key: string]: T }, filter: (item: [string, T]) => boolean): { [key: string]: T } =>
Object.fromEntries(Object.entries(obj).filter(filter))
export const filterObjectByKey = <T>(
obj: { [key: string]: T },
keys: string[],
) => filterObject(obj, ([key]) => keys.includes(key))
export const filterObjectByKey = <T>(obj: { [key: string]: T }, keys: string[]) => filterObject(obj, ([key]) => keys.includes(key))
export const downloadFile = async (url: string, to: string) =>
new Promise<void>(async res => {
new Promise<void>(async (res) => {
const { data: file } = await axios({
method: 'get',
url: url,
responseType: 'stream',
})
const stream = createWriteStream(to)
const tmp = join(tmpdir(), rand(64))
const stream = createWriteStream(tmp)
const writer = file.pipe(stream)
writer.on('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()
})
})
// 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 pathRelativeToConfigFile = (path: string): string => (isAbsolute(path) ? path : resolve(dirname(CONFIG_FILE), path))
export const ConfigError = new Error('Config file not found')
export const resolveTildePath = (path: string): string | null => (path.length === 0 || path[0] !== '~' ? null : join(homedir(), path.slice(1)))
export const getFlagsFromLocation = (location: Location, command?: string): string[] => {
if (!location.options) return []
@@ -108,13 +99,45 @@ export const getFlagsFromLocation = (location: Location, command?: string): stri
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))
flags = [...flags, `--${String(flag)}`, String(value)]
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 function parseBackend(backends: string[] = [], all: boolean = false): Backends {
if (all) return config.backends
if (backends.length) {
for (const backend of backends) if (!config.backends[backend]) throw new Error('Invalid backend: '.red + backend)
return filterObjectByKey(config.backends, backends)
} else {
throw new Error(
'No backends specified.'.red + '\n-a, --all, -a\t\t\tSelect all.' + '\n-b, --backend <backends...>\t\tSpecify one or more backend'
)
}
}
export function checkIfValidLocation(location: string) {
if (!config.locations[location]) throw new Error('Invalid location: '.red + location)
}
export function parseLocations(locations: string[] = [], all: boolean = false): Locations {
if (all) {
return config.locations
}
if (locations.length) {
for (const location of locations) checkIfValidLocation(location)
return filterObjectByKey(config.locations, locations)
}
throw new Error(
'No locations specified.'.red + '\n-a, --all\t\t\tSelect all.' + '\n-l, --location <locations...>\t\t\tSpecify one or more location'
)
}
export const makeArrayIfIsNot = <T>(maybeArray: T | T[]): T[] => (Array.isArray(maybeArray) ? maybeArray : [maybeArray])
export const fill = (length: number, filler = ' '): string => new Array(length).fill(filler).join('')
@@ -123,36 +146,56 @@ export const capitalize = (string: string): string => string.charAt(0).toUpperCa
export const treeToString = (obj: Object, highlight = [] as string[]): string => {
let cleaned = JSON.stringify(obj, null, 2)
.replace(/[{}"\[\],]/g, '')
.replace(/^ {2}/mg, '')
.replace(/^ {2}/gm, '')
.replace(/\n\s*\n/g, '\n')
.trim()
for (const word of highlight)
cleaned = cleaned.replace(word, capitalize(word).green)
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`],
[(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
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')
}

View File

@@ -1,9 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"target": "es2019",
"module": "commonjs",
"outDir": "./lib",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
"esModuleInterop": true,
"resolveJsonModule": true,
"alwaysStrict": true,
"strictNullChecks": true
},
"include": ["./src", "./package.json"]
}

3
vercel.json Normal file
View File

@@ -0,0 +1,3 @@
{
"cleanUrls": true
}

1531
yarn.lock Normal file

File diff suppressed because it is too large Load Diff