diff --git a/actions/snapshot b/actions/snapshot new file mode 100755 index 000000000..8f0a3dd64 --- /dev/null +++ b/actions/snapshot @@ -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 . +# + +""" +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() diff --git a/data/etc/plinth/modules-enabled/snapshot b/data/etc/plinth/modules-enabled/snapshot new file mode 100644 index 000000000..d057db596 --- /dev/null +++ b/data/etc/plinth/modules-enabled/snapshot @@ -0,0 +1 @@ +plinth.modules.snapshot diff --git a/plinth/modules/snapshot/__init__.py b/plinth/modules/snapshot/__init__.py new file mode 100644 index 000000000..c679cf7db --- /dev/null +++ b/plinth/modules/snapshot/__init__.py @@ -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 . +# + +""" +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']) diff --git a/plinth/modules/snapshot/templates/snapshot.html b/plinth/modules/snapshot/templates/snapshot.html new file mode 100644 index 000000000..3a0d1b157 --- /dev/null +++ b/plinth/modules/snapshot/templates/snapshot.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} +

{{ title }}

+ +
+
+ + + + + + + + + + {% for snapshot in snapshots %} + {% if snapshot.description != "current" %} + + + + + + + + {% endif %} + {% endfor %} + +
{% trans "Number" %}{% trans "Date" %}{% trans "Description" %}{% trans "Rollback" %}{% trans "Delete" %}
{{ snapshot.number }}{{ snapshot.date }} + {% if snapshot.is_default %} + {% trans "[default subvolume]" %}
+ {% endif %} + {{ snapshot.description }} +
+ + + + + {% if not snapshot.is_default %} + + + + {% endif %} +
+
+
+ +

+

+ {% csrf_token %} + + +
+

+{% endblock %} diff --git a/plinth/modules/snapshot/templates/snapshot_delete.html b/plinth/modules/snapshot/templates/snapshot_delete.html new file mode 100644 index 000000000..aded1202d --- /dev/null +++ b/plinth/modules/snapshot/templates/snapshot_delete.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} +

{{ title }}

+ +

+ {% blocktrans trimmed %} + The snapshot will be permanently deleted. Please confirm. + {% endblocktrans %} +

+ +
+
+ + + + + + + + + + + + + +
{% trans "Number" %}{% trans "Date" %}{% trans "Description" %}
{{ snapshot.number }}{{ snapshot.date }}{{ snapshot.description }}
+
+
+ +

+

+ {% csrf_token %} + + +
+

+ +{% endblock %} diff --git a/plinth/modules/snapshot/templates/snapshot_rollback.html b/plinth/modules/snapshot/templates/snapshot_rollback.html new file mode 100644 index 000000000..d9c804ea6 --- /dev/null +++ b/plinth/modules/snapshot/templates/snapshot_rollback.html @@ -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 . +# +{% endcomment %} + +{% load bootstrap %} +{% load i18n %} + +{% block content %} +

{{ title }}

+ +

+ {% blocktrans trimmed %} + The system will be rolled back to the selected snapshot. Please confirm. + {% endblocktrans %} +

+ +
+
+ + + + + + + + + + + + + +
{% trans "Number" %}{% trans "Date" %}{% trans "Description" %}
{{ snapshot.number }}{{ snapshot.date }}{{ snapshot.description }}
+
+
+ +

+

+ {% csrf_token %} + + +
+

+ +{% endblock %} diff --git a/plinth/modules/snapshot/tests/__init__.py b/plinth/modules/snapshot/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/snapshot/urls.py b/plinth/modules/snapshot/urls.py new file mode 100644 index 000000000..2dc1403a7 --- /dev/null +++ b/plinth/modules/snapshot/urls.py @@ -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 . +# + +""" +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\d+)/delete$', views.delete, name='delete'), + url(r'^sys/snapshot/(?P\d+)/rollback$', views.rollback, + name='rollback'), +] diff --git a/plinth/modules/snapshot/views.py b/plinth/modules/snapshot/views.py new file mode 100644 index 000000000..87702a4e9 --- /dev/null +++ b/plinth/modules/snapshot/views.py @@ -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 . +# + +""" +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})