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 }}
+
+
+
+
+
+ | {% trans "Number" %} |
+ {% trans "Date" %} |
+ {% trans "Description" %} |
+ {% trans "Rollback" %} |
+ {% trans "Delete" %} |
+
+
+ {% for snapshot in snapshots %}
+ {% if snapshot.description != "current" %}
+
+ | {{ snapshot.number }} |
+ {{ snapshot.date }} |
+
+ {% if snapshot.is_default %}
+ {% trans "[default subvolume]" %}
+ {% endif %}
+ {{ snapshot.description }}
+ |
+
+
+
+
+ |
+
+ {% if not snapshot.is_default %}
+
+
+
+ {% endif %}
+ |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+
+
+{% 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 }} |
+
+
+
+
+
+
+
+
+
+
+{% 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 }} |
+
+
+
+
+
+
+
+
+
+
+{% 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})