snapshot: New module for disk snapshots

- Create and list filesystem snapshots. Hide "current" snapshot.

- Allow deleting snapshots, except for default subvolume.

- Allow rollback to a snapshot.
This commit is contained in:
James Valleroy 2016-06-29 22:40:20 -04:00 committed by Sunil Mohan Adapa
parent 62934e7edc
commit 5e18a648e1
No known key found for this signature in database
GPG Key ID: 36C361440C9BC971
9 changed files with 510 additions and 0 deletions

130
actions/snapshot Executable file
View File

@ -0,0 +1,130 @@
#!/usr/bin/python3
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Configuration helper for filesystem snapshots.
"""
import argparse
import json
import subprocess
FSTAB = '/etc/fstab'
def parse_arguments():
"""Return parsed command line arguments as dictionary."""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='subcommand', help='Sub command')
subparsers.add_parser('setup', help='Configure snapper')
subparsers.add_parser('list', help='List snapshots')
subparsers.add_parser('create', help='Create snapshot')
subparser = subparsers.add_parser('delete', help='Delete snapshot')
subparser.add_argument('number', help='Number of snapshot to delete')
subparser = subparsers.add_parser('rollback', help='Rollback to snapshot')
subparser.add_argument('number', help='Number of snapshot to rollback to')
return parser.parse_args()
def subcommand_setup(_):
"""Configure snapper."""
# Check if root config exists.
command = ['snapper', 'list-configs']
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
output = process.stdout.decode()
# Create root config if needed.
if 'root' not in output:
command = ['snapper', 'create-config', '/']
subprocess.run(command, check=True)
# Add mountpoint for subvolumes.
with open(FSTAB, 'r') as fstab:
lines = fstab.readlines()
spec = None
for line in lines:
if '.snapshots' in line:
return
if 'btrfs' in line:
spec = line.split(' ')[0]
if spec:
with open(FSTAB, 'a') as fstab:
fstab.write(spec + ' /.snapshots btrfs subvol=.snapshots 0 1\n')
def subcommand_list(_):
"""List snapshots."""
command = ['snapper', 'list']
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
lines = process.stdout.decode().splitlines()
keys = ('type', 'number', 'pre_number', 'date', 'user', 'cleanup',
'description')
snapshots = []
for line in lines[2:]:
parts = [part.strip() for part in line.split('|')]
snapshots.append(dict(zip(keys, parts)))
# Mark default subvolume.
command = ['btrfs', 'subvolume', 'get-default', '/']
process = subprocess.run(command, stdout=subprocess.PIPE, check=True)
output = process.stdout.decode()
default = None
if '.snapshots' in output:
default = output.split('/')[1]
for snapshot in snapshots:
snapshot['is_default'] = snapshot['number'] == default
print(json.dumps(snapshots))
def subcommand_create(_):
"""Create snapshot."""
command = ['snapper', 'create', '--description', 'manually created']
subprocess.run(command, check=True)
def subcommand_delete(arguments):
"""Delete snapshot."""
command = ['snapper', 'delete', arguments.number]
subprocess.run(command, check=True)
def subcommand_rollback(arguments):
"""Rollback to snapshot."""
command = ['snapper', 'rollback', '--description', 'created by rollback',
arguments.number]
subprocess.run(command, check=True)
def main():
"""Parse arguments and perform all duties."""
arguments = parse_arguments()
subcommand = arguments.subcommand.replace('-', '_')
subcommand_method = globals()['subcommand_' + subcommand]
subcommand_method(arguments)
if __name__ == '__main__':
main()

View File

@ -0,0 +1 @@
plinth.modules.snapshot

View File

@ -0,0 +1,53 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Plinth module to manage filesystem snapshots.
"""
from django.utils.translation import ugettext_lazy as _
from plinth import actions
from plinth import cfg
version = 1
depends = ['system']
managed_packages = ['snapper']
title = _('Snapshots')
description = [
_('Snapshots allows creating and managing filesystem snapshots. These can '
'be used to roll back the system to a previous state.')
]
service = None
def init():
"""Initialize the module."""
menu = cfg.main_menu.get('system:index')
menu.add_urlname(title, 'glyphicon-film', 'snapshot:index')
def setup(helper, old_version=None):
"""Install and configure the module."""
helper.install(managed_packages)
helper.call('post', actions.superuser_run, 'snapshot', ['setup'])

View File

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h2>{{ title }}</h2>
<div class="row">
<div class="col-lg-8">
<table class="table table-bordered table-condensed table-striped">
<thead>
<th>{% trans "Number" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Rollback" %}</th>
<th>{% trans "Delete" %}</th>
</thead>
<tbody>
{% for snapshot in snapshots %}
{% if snapshot.description != "current" %}
<tr>
<td>{{ snapshot.number }}</td>
<td>{{ snapshot.date }}</td>
<td>
{% if snapshot.is_default %}
{% trans "[default subvolume]" %}<br />
{% endif %}
{{ snapshot.description }}
</td>
<td>
<a href="{% url 'snapshot:rollback' snapshot.number %}"
class="btn btn-default btn-sm"
role="button"
title="{% blocktrans with number=snapshot.number %}Rollback to snapshot #{{ number }}{% endblocktrans %}">
<span class="glyphicon glyphicon-repeat"
aria-hidden="true"></span>
</a>
</td>
<td>
{% if not snapshot.is_default %}
<a href="{% url 'snapshot:delete' snapshot.number %}"
class="btn btn-default btn-sm"
role="button"
title="{% blocktrans with number=snapshot.number %}Delete snapshot #{{ number }}{% endblocktrans %}">
<span class="glyphicon glyphicon-trash"
aria-hidden="true"></span>
</a>
{% endif %}
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
<p>
<form class="form" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-primary"
value="{% trans "Create Snapshot" %}"/>
</form>
</p>
{% endblock %}

View File

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h2>{{ title }}</h2>
<p>
{% blocktrans trimmed %}
The snapshot will be permanently deleted. Please confirm.
{% endblocktrans %}
</p>
<div class="row">
<div class="col-lg-8">
<table class="table table-bordered table-condensed table-striped">
<thead>
<th>{% trans "Number" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Description" %}</th>
</thead>
<tbody>
<tr>
<td>{{ snapshot.number }}</td>
<td>{{ snapshot.date }}</td>
<td>{{ snapshot.description }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>
<form class="form" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-primary"
value="{% blocktrans with number=snapshot.number %}Delete Snapshot #{{ number }}{% endblocktrans %}"/>
</form>
</p>
{% endblock %}

View File

@ -0,0 +1,61 @@
{% extends "base.html" %}
{% comment %}
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
{% endcomment %}
{% load bootstrap %}
{% load i18n %}
{% block content %}
<h2>{{ title }}</h2>
<p>
{% blocktrans trimmed %}
The system will be rolled back to the selected snapshot. Please confirm.
{% endblocktrans %}
</p>
<div class="row">
<div class="col-lg-8">
<table class="table table-bordered table-condensed table-striped">
<thead>
<th>{% trans "Number" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Description" %}</th>
</thead>
<tbody>
<tr>
<td>{{ snapshot.number }}</td>
<td>{{ snapshot.date }}</td>
<td>{{ snapshot.description }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p>
<form class="form" method="post">
{% csrf_token %}
<input type="submit" class="btn btn-primary"
value="{% blocktrans with number=snapshot.number %}Rollback to Snapshot #{{ number }}{% endblocktrans %}"/>
</form>
</p>
{% endblock %}

View File

@ -0,0 +1,32 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
URLs for the snapshot module.
"""
from django.conf.urls import url
from . import views
urlpatterns = [
url(r'^sys/snapshot/$', views.index, name='index'),
url(r'^sys/snapshot/(?P<number>\d+)/delete$', views.delete, name='delete'),
url(r'^sys/snapshot/(?P<number>\d+)/rollback$', views.rollback,
name='rollback'),
]

View File

@ -0,0 +1,87 @@
#
# This file is part of Plinth.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
"""
Views for snapshot module.
"""
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from django.utils.translation import ugettext as _
import json
from plinth import actions
from plinth.modules import snapshot as snapshot_module
def index(request):
"""Show snapshot list."""
if request.method == 'POST':
actions.superuser_run('snapshot', ['create'])
messages.success(request, _('Created snapshot.'))
output = actions.superuser_run('snapshot', ['list'])
snapshots = json.loads(output)
return TemplateResponse(request, 'snapshot.html',
{'title': snapshot_module.title,
'snapshots': snapshots})
def delete(request, number):
"""Show confirmation to delete a snapshot."""
if request.method == 'POST':
actions.superuser_run('snapshot', ['delete', number])
messages.success(request, _('Deleted snapshot.'))
return redirect(reverse('snapshot:index'))
output = actions.superuser_run('snapshot', ['list'])
snapshots = json.loads(output)
snapshot = None
for s in snapshots:
if s['number'] == number:
snapshot = s
return TemplateResponse(request, 'snapshot_delete.html',
{'title': _('Delete Snapshot'),
'snapshot': snapshot})
def rollback(request, number):
"""Show confirmation to rollback to a snapshot."""
if request.method == 'POST':
actions.superuser_run('snapshot', ['rollback', number])
messages.success(request, _('Set default subvolume for rollback.'))
messages.warning(
request,
_('The system must be restarted to complete the rollback.'))
return redirect(reverse('power:restart'))
output = actions.superuser_run('snapshot', ['list'])
snapshots = json.loads(output)
snapshot = None
for s in snapshots:
if s['number'] == number:
snapshot = s
return TemplateResponse(request, 'snapshot_rollback.html',
{'title': _('Rollback to Snapshot'),
'snapshot': snapshot})