Working version with snapshots and ssh creation.

This commit is contained in:
Manu 2018-10-27 18:12:30 +08:00
parent 8cede50bd1
commit 1d0a6b5f45
8 changed files with 249 additions and 106 deletions

View File

@ -5,11 +5,10 @@
- Select and manage SSH keys
- Initialize new remote Borg repositories
- Create new Borg snapshots (backups) from local folders
- Display existing snapshots and repository details.
- Mount existing snapshots with FUSE
- Settings stored in sqlite
- Borg binary integrated
Planned features:
Missing features:
- Scheduling for background backups.
- Rule-based scheduling by time, Wifi SSID, etc.
@ -18,6 +17,7 @@ Planned features:
- Securely save repo password in Keychain instead of database.
- Handle encrypted SSH keys
- Check for duplicate source dirs
- Exclude options/patterns.
## Development
Conda is used for dependency management. Create a new virtual env using:
@ -36,6 +36,9 @@ To build a binary package:
$ pyinstaller --clean --noconfirm vorta.spec
```
## Why the Name?
[Vorta](http://memory-alpha.wikia.com/wiki/Vorta) are a race referenced in Star Trek. They serve the Dominion and are replaced by their clones if they die. Just like our backups.
## Author
(C) 2018 Manuel Riel for [BorgBase.com](https://www.borgbase.com)

View File

@ -12,6 +12,7 @@ dependencies:
- paramiko
- pyqt5
- peewee
- python-dateutil
dev-dependencies:
- pyinstaller

View File

@ -39,7 +39,7 @@
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>2</number>
<number>0</number>
</property>
<widget class="QWidget" name="tab_2">
<attribute name="title">
@ -81,104 +81,82 @@
<number>25</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="label_8">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Repository ID:</string>
<string>Encryption:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="repoId">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_9">
<property name="text">
<string>Location:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="repoLocation">
<widget class="QLabel" name="repoEncryption">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_10">
<property name="text">
<string>Encrypted:</string>
<widget class="Line" name="line">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="repoEncryption_2">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="Line" name="line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="Line" name="line_2">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item row="5" column="0">
<item row="3" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Original Size:</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="label_12">
<item row="3" column="1">
<widget class="QLabel" name="sizeOriginal">
<property name="text">
<string>Compressed Size:</string>
<string/>
</property>
</widget>
</item>
<item row="7" column="0">
<item row="4" column="0">
<widget class="QLabel" name="label_13">
<property name="text">
<string>Deduplicated Size:</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QLabel" name="sizeDeduplicated">
<item row="5" column="0">
<widget class="QLabel" name="label_12">
<property name="text">
<string/>
<string>Compressed Size:</string>
</property>
</widget>
</item>
<item row="6" column="1">
<item row="5" column="1">
<widget class="QLabel" name="sizeCompressed">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QLabel" name="sizeOriginal">
<item row="4" column="1">
<widget class="QLabel" name="sizeDeduplicated">
<property name="text">
<string/>
</property>
@ -385,7 +363,10 @@
</widget>
</item>
<item>
<widget class="QLabel" name="label_4">
<widget class="QLabel" name="mountErrors">
<property name="enabled">
<bool>true</bool>
</property>
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;To mount snapshots, first install &amp;quot;FUSE for macOS&amp;quot; from &lt;a href=&quot;https://osxfuse.github.io/&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;here&lt;/span&gt;&lt;/a&gt;.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
@ -394,7 +375,7 @@
<item>
<layout class="QHBoxLayout" name="horizontalLayout_3">
<item>
<widget class="QPushButton" name="pushButton">
<widget class="QPushButton" name="snapshotMountButton">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
@ -407,7 +388,7 @@
</widget>
</item>
<item>
<widget class="QPushButton" name="pushButton_2">
<widget class="QPushButton" name="snapshotDeleteButton">
<property name="enabled">
<bool>false</bool>
</property>
@ -423,7 +404,10 @@
</widget>
</item>
<item alignment="Qt::AlignLeft">
<widget class="QPushButton" name="pushButton_4">
<widget class="QPushButton" name="snapshotRefreshButton">
<property name="enabled">
<bool>false</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
@ -449,6 +433,43 @@
<property name="enabled">
<bool>true</bool>
</property>
<property name="palette">
<palette>
<active>
<colorrole role="Highlight">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>255</red>
<green>151</green>
<blue>41</blue>
</color>
</brush>
</colorrole>
</active>
<inactive>
<colorrole role="Highlight">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>255</red>
<green>151</green>
<blue>41</blue>
</color>
</brush>
</colorrole>
</inactive>
<disabled>
<colorrole role="Highlight">
<brush brushstyle="SolidPattern">
<color alpha="255">
<red>220</red>
<green>220</green>
<blue>220</blue>
</color>
</brush>
</colorrole>
</disabled>
</palette>
</property>
<property name="value">
<number>0</number>
</property>
@ -497,7 +518,7 @@
</property>
<widget class="QMenu" name="menuBackup_Configuration">
<property name="title">
<string>Backup Configuration</string>
<string>Backup Profile</string>
</property>
<addaction name="actionLatest"/>
</widget>

View File

@ -43,7 +43,9 @@ class BorgThread(QtCore.QThread):
'params': self.params,
'returncode': p.returncode,
}
if stdout.strip():
try:
result['data'] = json.loads(stdout)
except:
result['data'] = {}
self.result.emit(result)

View File

@ -1,17 +1,17 @@
import sys
import os
import platform
from datetime import datetime as dt
from PyQt5.QtWidgets import QApplication, QFileDialog, QTableWidgetItem, QTableView
from PyQt5 import uic, QtCore, QtGui
from dateutil import parser
from PyQt5.QtWidgets import QApplication, QFileDialog, QTableWidgetItem, QTableView, QTableWidget
from PyQt5 import uic, QtCore, QtGui, QtWidgets
from .repo_add import AddRepoWindow, ExistingRepoWindow
from .repo_init import InitRepoWindow
from .ssh_add import SSHAddWindow
from .config import APP_NAME, reset_app
from .models import RepoModel, SourceDirModel, SnapshotModel, BackupProfileModel
from .ssh_keys import get_private_keys
from .borg_runner import BorgThread
from .utils import prettyByes
uifile = os.path.join(os.path.dirname(__file__), 'UI/mainwindow.ui')
@ -33,44 +33,59 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.createStartBtn.clicked.connect(self.create_action)
self.actionResetApp.triggered.connect(self.menu_reset)
def set_status(self, text=None, progress_max=None):
if text:
self.createProgressText.setText(text)
if progress_max is not None:
self.createProgress.setRange(0, progress_max)
self.createProgressText.repaint()
def create_action(self):
n_backup_folders = self.sourceDirectoriesWidget.count()
if n_backup_folders == 0:
self.set_status('Add some folders to back up first.')
return
self.set_status('Starting Backup.', progress_max=0)
self.createStartBtn.setEnabled(False)
self.createStartBtn.repaint()
repo_id = self.repoSelector.currentData()
repo = RepoModel.get(id=repo_id)
cmd = ['borg', 'create', '--log-json', '--json',
cmd = ['borg', 'create', '--log-json', '--json', '-C', self.profile.compression,
f'{repo.url}::{platform.node()}-{dt.now().isoformat()}'
]
for i in range(self.sourceDirectoriesWidget.count()):
for i in range(n_backup_folders):
cmd.append(self.sourceDirectoriesWidget.item(i).text())
self.createProgress.setRange(0, 0)
thread = BorgThread(self, cmd, {})
thread.updated.connect(self.create_update_log)
thread.result.connect(self.create_get_result)
thread.start()
def create_update_log(self, text):
self.createProgressText.setText(text)
self.set_status(text)
def create_get_result(self, result):
self.createProgress.setRange(0, 100)
print(result)
self.createStartBtn.setEnabled(True)
self.createStartBtn.repaint()
if result['returncode'] == 0:
self.createProgressText.setText('Finished backup.')
self.set_status('Finished backup.', 100)
new_snapshot = SnapshotModel(
snapshot_id=result['data']['archive']['id'],
name=result['data']['archive']['name'],
time=result['data']['archive']['start'],
time=parser.parse(result['data']['archive']['start']),
repo=self.repoSelector.currentData()
)
def init_repo(self):
self.repoSelector.model().item(0).setEnabled(False)
self.repoSelector.addItem('Initialize New Repository', 'init')
self.repoSelector.addItem('Add Existing Repository', 'existing')
for repo in RepoModel.select():
self.repoSelector.addItem(repo.url, repo.id)
if self.profile.repo:
self.repoSelector.setCurrentIndex(self.repoSelector.findData(self.profile.repo.id))
self.repoSelector.currentIndexChanged.connect(self.repo_select_action)
new_snapshot.save()
if 'cache' in result['data']:
stats = result['data']['cache']['stats']
repo = self.profile.repo
repo.total_size = stats['total_size']
repo.unique_csize = stats['unique_csize']
repo.unique_size = stats['unique_size']
repo.total_unique_chunks = stats['total_unique_chunks']
repo.save()
self.init_snapshots()
def init_source(self):
self.sourceAdd.clicked.connect(self.source_add)
@ -96,24 +111,90 @@ class MainWindow(MainWindowBase, MainWindowUI):
self.profile.save()
print('set ssh key to', self.profile.ssh_key)
def init_snapshots(self):
snapshots = [s for s in SnapshotModel.select()]
self.snapshotTable.setRowCount(len(snapshots))
if self.profile.repo:
snapshots = [s for s in self.profile.repo.snapshots.select()]
for row, snapshot in enumerate(snapshots):
self.snapshotTable.insertRow(row)
self.snapshotTable.setItem(row, 0, QTableWidgetItem(snapshot.name))
formatted_time = snapshot.time.strftime('%Y-%m-%d %H:%M')
self.snapshotTable.setItem(row, 1, QTableWidgetItem(formatted_time))
self.sizeCompressed.setText(prettyByes(self.profile.repo.unique_csize))
self.sizeDeduplicated.setText(prettyByes(self.profile.repo.unique_size))
self.sizeOriginal.setText(prettyByes(self.profile.repo.total_size))
self.repoEncryption.setText(str(self.profile.repo.encryption))
self.snapshotTable.setRowCount(len(snapshots))
header = self.snapshotTable.horizontalHeader()
header.setVisible(True)
header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents)
for row, snapshot in enumerate(snapshots):
self.snapshotTable.insertRow(row)
self.snapshotTable.setItem(row, 0, QTableWidgetItem(snapshot.name))
self.snapshotTable.setItem(row, 1, QTableWidgetItem(snapshot.time))
self.snapshotTable.resizeColumnsToContents()
self.snapshotTable.horizontalHeader().setVisible(True)
self.snapshotTable.setSelectionBehavior(QTableView.SelectRows)
self.snapshotTable.setEditTriggers(QTableView.NoEditTriggers)
self.snapshotMountButton.clicked.connect(self.snapshot_mount)
self.snapshotDeleteButton.clicked.connect(self.snapshot_mount)
self.snapshotRefreshButton.clicked.connect(self.snapshot_mount)
def snapshot_mount(self):
cmd = ['borg', 'mount', '--log-json']
row_selected = self.snapshotTable.selectionModel().selectedRows()
if row_selected:
snapshot_cell = self.snapshotTable.item(row_selected[0].row(), 0)
if snapshot_cell:
snapshot_name = snapshot_cell.text()
cmd.append(f'{self.profile.repo.url}::{snapshot_name}')
else:
cmd.append(f'{self.profile.repo.url}')
else:
cmd.append(f'{self.profile.repo.url}')
options = QFileDialog.Options()
options |= QFileDialog.ShowDirsOnly
options |= QFileDialog.DontUseNativeDialog
mountPoint = QFileDialog.getExistingDirectory(
self, "Choose Mount Point", "", options=options)
if mountPoint:
cmd.append(mountPoint)
self.set_status('Mounting snapshot into folder', 0)
thread = BorgThread(self, cmd, {})
thread.updated.connect(self.mount_update_log)
thread.result.connect(self.mount_get_result)
thread.start()
def mount_update_log(self, text):
self.mountErrors.setText(text)
def mount_get_result(self, result):
self.set_status(progress_max=100)
if result['returncode'] == 0:
self.set_status('Mounted successfully.')
def init_compression(self):
self.repoCompression.addItem('LZ4 (default)', 'lz4')
self.repoCompression.addItem('Zstandard (medium)', 'zstd')
self.repoCompression.addItem('LZMA (high)', 'lzma,6')
self.repoCompression.setCurrentIndex(self.repoCompression.findData(self.profile.compression))
self.repoCompression.currentIndexChanged.connect(self.compression_select_action)
def compression_select_action(self, index):
self.profile.compression = self.repoCompression.currentData()
self.profile.save()
def init_repo(self):
self.repoSelector.model().item(0).setEnabled(False)
self.repoSelector.addItem('Initialize New Repository', 'init')
self.repoSelector.addItem('Add Existing Repository', 'existing')
for repo in RepoModel.select():
self.repoSelector.addItem(repo.url, repo.id)
if self.profile.repo:
self.repoSelector.setCurrentIndex(self.repoSelector.findData(self.profile.repo.id))
self.repoSelector.currentIndexChanged.connect(self.repo_select_action)
def repo_select_action(self, index):
if index <= 2:
@ -132,40 +213,54 @@ class MainWindow(MainWindowBase, MainWindowUI):
else:
cmd = ["borg", "list", "--json", params['repo_url']]
init_window = InitRepoWindow(cmd, params)
init_window.setParent(self, QtCore.Qt.Sheet)
init_window.show()
init_window._thread.start()
init_window._thread.result.connect(self.repo_add_result)
self.set_status('Connecting to repo...', 0)
thread = BorgThread(self, cmd, params)
thread.updated.connect(self.repo_add_update_log)
thread.result.connect(self.repo_add_result)
thread.start()
else:
self.profile.repo = self.repoSelector.currentData()
self.profile.save()
self.init_snapshots()
def repo_add_update_log(self, text):
self.set_status(text)
def repo_add_result(self, result):
print(result)
if result['returncode'] == 0:
self.set_status('Successfully connected to repo.', 100)
new_repo, _ = RepoModel.get_or_create(
url=result['params']['repo_url'],
defaults={
'password': result['params']['password'],
'encryption': 'na'
# 'encryption': result['params'].get('encryption', '')
}
)
if 'cache' in result['data']:
stats = result['data']['cache']['stats']
new_repo.total_size = stats['total_size']
new_repo.unique_csize = stats['unique_csize']
new_repo.unique_size = stats['unique_size']
new_repo.total_unique_chunks = stats['total_unique_chunks']
new_repo.encryption = result['data']['encryption']['mode']
new_repo.save()
self.profile.repo = new_repo.id
self.profile.save()
if 'archives' in result['data'].keys():
for snapshot in result['data']['archives']:
new_snapshot, _ = SnapshotModel.get_or_create(
id=snapshot['id'],
snapshot_id=snapshot['id'],
defaults={
'repo': new_repo.id,
'name': snapshot['name'],
'time': snapshot['time']
'time': parser.parse(snapshot['time'])
}
)
new_snapshot.save()
self.repoSelector.addItem(new_repo.url, new_repo.id)
self.repoSelector.setCurrentIndex(self.repoSelector.count()-1)
self.init_snapshots()
def source_add(self):
options = QFileDialog.Options()

View File

@ -11,7 +11,11 @@ class RepoModel(peewee.Model):
url = peewee.CharField(unique=True)
password = peewee.CharField()
added_at = peewee.DateTimeField(default=datetime.utcnow)
encryption = peewee.CharField()
encryption = peewee.CharField(null=True)
unique_size = peewee.IntegerField(null=True)
unique_csize = peewee.IntegerField(null=True)
total_size = peewee.IntegerField(null=True)
total_unique_chunks = peewee.IntegerField(null=True)
class Meta:
database = db
@ -23,7 +27,7 @@ class BackupProfileModel(peewee.Model):
added_at = peewee.DateTimeField(default=datetime.utcnow)
repo = peewee.ForeignKeyField(RepoModel, default=None, null=True)
ssh_key = peewee.CharField(default=None, null=True)
compression = peewee.CharField(default='lz4')
class Meta:
database = db
@ -40,11 +44,14 @@ class SourceDirModel(peewee.Model):
class SnapshotModel(peewee.Model):
"""A snapshot to a specific remote repository."""
id = peewee.CharField(primary_key=True)
snapshot_id = peewee.CharField(unique=True)
name = peewee.CharField()
repo = peewee.ForeignKeyField(RepoModel)
repo = peewee.ForeignKeyField(RepoModel, backref='snapshots')
time = peewee.DateTimeField()
def formatted_time(self):
return
class Meta:
database = db

View File

@ -12,6 +12,7 @@ class InitRepoWindow(InitRepoBase, InitRepoUI):
self.setupUi(self)
self.closeButton.clicked.connect(self.close)
self.logText.appendPlainText('Connecting to repo...')
self._thread = BorgThread(self, cmd, env)
self._thread.updated.connect(self.update_log)
self._thread.result.connect(self.get_result)

13
vorta/utils.py Normal file
View File

@ -0,0 +1,13 @@
def prettyByes(size):
"""from https://stackoverflow.com/questions/12523586/
python-format-size-application-converting-b-to-kb-mb-gb-tb/37423778"""
if type(size) != int:
return ''
power = 2**10
n = 0
Dic_powerN = {0: '', 1: 'K', 2: 'M', 3: 'G', 4: 'T'}
while size > power:
size /= power
n += 1
return str(round(size))+Dic_powerN[n]+'B'