From 9ac37465dd65b2e5a50ca510a3ec139ff037c400 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Sun, 12 Jun 2016 12:30:26 +0530 Subject: [PATCH] disks: New module to manage disks - Show free space of currently mounted partitions. Should help with people running out of free space and ending up with non-working system. In future, this module could emit more visible messages. - Show and allow expanding root partition to help people who have written FreedomBox images to higher capacity SD cards. Very selective and restrictive checks to minimize problems. - Automated tests to ensure expansion works in non-trivial senarious. --- actions/disks | 222 ++++++++++++++++ data/etc/plinth/modules-enabled/disks | 1 + plinth/modules/disks/__init__.py | 100 ++++++++ plinth/modules/disks/templates/disks.html | 93 +++++++ .../modules/disks/templates/disks_expand.html | 51 ++++ plinth/modules/disks/tests/__init__.py | 0 plinth/modules/disks/tests/test_disks.py | 240 ++++++++++++++++++ plinth/modules/disks/urls.py | 30 +++ plinth/modules/disks/views.py | 92 +++++++ 9 files changed, 829 insertions(+) create mode 100755 actions/disks create mode 100644 data/etc/plinth/modules-enabled/disks create mode 100644 plinth/modules/disks/__init__.py create mode 100644 plinth/modules/disks/templates/disks.html create mode 100644 plinth/modules/disks/templates/disks_expand.html create mode 100644 plinth/modules/disks/tests/__init__.py create mode 100644 plinth/modules/disks/tests/test_disks.py create mode 100644 plinth/modules/disks/urls.py create mode 100644 plinth/modules/disks/views.py diff --git a/actions/disks b/actions/disks new file mode 100755 index 000000000..de0d3b2f0 --- /dev/null +++ b/actions/disks @@ -0,0 +1,222 @@ +#!/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 disks manager. +""" + +import argparse +import re +import subprocess +import sys + + +def parse_arguments(): + """Return parsed command line arguments as dictionary.""" + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest='subcommand', help='Sub command') + + subparser = subparsers.add_parser( + 'is-partition-expandable', + help='Return whether a given partition can be expanded') + subparser.add_argument( + 'device', help='Partition for which check needs to be performed') + + subparser = subparsers.add_parser( + 'expand-partition', + help='Expand a partition to take adjacent free space') + subparser.add_argument( + 'device', help='Partition which needs to be resized') + + return parser.parse_args() + + +def subcommand_is_partition_expandable(arguments): + """Return a list of partitions that can be expanded.""" + _, _, free_space = _get_free_space(arguments.device) + print(free_space['size']) + + +def subcommand_expand_partition(arguments): + """Expand a partition to take adjacent free space.""" + device = arguments.device + device, requested_partition, free_space = _get_free_space(device) + + if requested_partition['table_type'] == 'msdos' and \ + int(requested_partition['number']) >= 5: + print('Expanding logical partitions currently unsupported', + file=sys.stderr) + sys.exit(4) + + _resize_partition(device, requested_partition, free_space) + _resize_file_system(device, requested_partition, free_space) + + +def _resize_partition(device, requested_partition, free_space): + """Resize the partition table entry.""" + command = ['parted', '--align=optimal', '--script', device, 'unit', 'B', + 'resizepart', requested_partition['number'], + str(free_space['end'])] + try: + subprocess.run(command, check=True) + except subprocess.CalledProcessError as exception: + print('Error expanding partition:', exception, file=sys.stderr) + sys.exit(5) + + +def _resize_file_system(device, requested_partition, free_space): + """Resize a file system inside a partition.""" + if requested_partition['type'] == 'btrfs': + _resize_btrfs(device, requested_partition, free_space) + elif requested_partition['type'] == 'ext4': + _resize_ext4(device, requested_partition, free_space) + + +def _resize_ext4(device, requested_partition, free_space): + """Resize an ext4 file system inside a partition.""" + partition_device = _get_partition_device( + device, requested_partition['number']) + try: + command = ['resize2fs', partition_device] + subprocess.run(command, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True) + except subprocess.CalledProcessError as exception: + print('Error expanding filesystem:', exception, file=sys.stderr) + sys.exit(6) + + +def _resize_btrfs(device, requested_partition, free_space): + """Resize a btrfs file system inside a partition.""" + try: + command = ['btrfs', 'filesystem', 'resize', 'max', '/'] + subprocess.run(command, stdout=subprocess.DEVNULL, check=True) + except subprocess.CalledProcessError as exception: + print('Error expanding filesystem:', exception, file=sys.stderr) + sys.exit(6) + + +def _get_free_space(device): + """Return the amount of free space after a partition.""" + device, partition_number = \ + _get_root_device_and_partition_number(device) + + try: + requested_partition, free_spaces = \ + _get_partitions_and_free_spaces(device, partition_number) + except Exception as exception: + print('Error getting partition details: ', exception, file=sys.stderr) + sys.exit(2) + + # Don't accept extended partitions for now + if requested_partition['table_type'] == 'msdos' and \ + int(requested_partition['number']) >= 5: + print('Expanding logical partitions currently unsupported', + file=sys.stderr) + sys.exit(3) + + # Don't accept anything but btrfs and ext4 filesystems + if requested_partition['type'] not in ('btrfs', 'ext4'): + print('Unsupported file system type: ', requested_partition['type'], + file=sys.stderr) + sys.exit(4) + + found_free_space = None + for free_space in free_spaces: + if free_space['start'] != requested_partition['end'] + 1: + continue + + if free_space['size'] < 10 * 1024 * 1024: # Minimum 10MiB + continue + + found_free_space = free_space + + if not found_free_space: + sys.exit(5) + + return device, requested_partition, found_free_space + + +def _get_partition_device(device, partition_number): + """Return the device corresponding to a parition in a given device.""" + if re.match('[0-9]', device[-1]): + return device + 'p' + str(partition_number) + + return device + str(partition_number) + + +def _get_root_device_and_partition_number(device): + """Return the parent device and number of partition separately.""" + match = re.match('(.+[a-zA-Z]\d+)p(\d+)$', device) + if not match: + match = re.match('(.+[a-zA-Z])(\d+)$', device) + if not match: + print('Invalid device, must be a partition', file=sys.stderr) + sys.exit(1) + + return match.group(1), match.group(2) + + +def _get_partitions_and_free_spaces(device, partition_number): + """Run parted and return list of partitions and free spaces.""" + command = ['parted', '--machine', '--script', device, 'unit', 'B', + 'print', 'free'] + process = subprocess.run(command, stdout=subprocess.PIPE, check=True) + + requested_partition = None + free_spaces = [] + + lines = process.stdout.decode().splitlines() + partition_table_type = lines[1].split(':')[5] + for line in lines[2:]: + line = line.rstrip(';') + keys = ('number', 'start', 'end', 'size', 'type') + parts = line.split(':') + segment = dict(zip(keys, parts[:5])) + + segment['table_type'] = partition_table_type + segment['start'] = _interpret_unit(segment['start']) + segment['end'] = _interpret_unit(segment['end']) + segment['size'] = _interpret_unit(segment['size']) + + if segment['type'] == 'free': + segment['number'] = None + free_spaces.append(segment) + else: + if segment['number'] == partition_number: + requested_partition = segment + + return requested_partition, free_spaces + + +def _interpret_unit(value): + """Return value in bytes after understanding parted unit.""" + value = value.rstrip('B') # For now, we only need to understand bytes + return int(value) + + +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/disks b/data/etc/plinth/modules-enabled/disks new file mode 100644 index 000000000..6ffa3d8f9 --- /dev/null +++ b/data/etc/plinth/modules-enabled/disks @@ -0,0 +1 @@ +plinth.modules.disks diff --git a/plinth/modules/disks/__init__.py b/plinth/modules/disks/__init__.py new file mode 100644 index 000000000..02337c6fc --- /dev/null +++ b/plinth/modules/disks/__init__.py @@ -0,0 +1,100 @@ +# +# 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 disks. +""" + +from django.utils.translation import ugettext_lazy as _ +import json +import logging +import subprocess + +from plinth import actions +from plinth import cfg + + +version = 1 + +depends = ['system'] + +title = _('Disks') + +description = [] + +service = None + +logger = logging.getLogger(__name__) + + +def init(): + """Intialize the module.""" + menu = cfg.main_menu.get('system:index') + menu.add_urlname(title, 'glyphicon-hdd', 'disks:index') + + +def get_disks(): + """Return the list of disks and free space available.""" + command = ['df', '--exclude-type=tmpfs', '--exclude-type=devtmpfs', + '--output=source,target,fstype,size,used,pcent', + '--human-readable'] + try: + process = subprocess.run(command, stdout=subprocess.PIPE, check=True) + except subprocess.CalledProcessError as exception: + logger.exception('Error getting disk information: %s', exception) + return [] + + output = process.stdout.decode() + + disks = [] + for line in output.splitlines()[1:]: + parts = line.split() + keys = ('device', 'mount_point', 'file_system_type', 'size', 'used', + 'percentage_used') + disk = dict(zip(keys, parts)) + disk['percentage_used'] = int(disk['percentage_used'].rstrip('%')) + disks.append(disk) + + return disks + + +def get_root_device(disks): + """Return the root partition's device from list of partitions.""" + devices = [disk['device'] for disk in disks if disk['mount_point'] == '/'] + try: + return devices[0] + except IndexError: + return None + + +def is_expandable(device): + """Return the list of partitions that can be expanded.""" + if not device: + return False + + try: + output = actions.superuser_run( + 'disks', ['is-partition-expandable', device]) + except actions.ActionError: + return False + + return int(output.strip()) + + +def expand_partition(device): + """Expand a partition.""" + actions.superuser_run('disks', ['expand-partition', device]) diff --git a/plinth/modules/disks/templates/disks.html b/plinth/modules/disks/templates/disks.html new file mode 100644 index 000000000..067825a6d --- /dev/null +++ b/plinth/modules/disks/templates/disks.html @@ -0,0 +1,93 @@ +{% 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 page_head %} + +{% endblock %} + +{% block content %} +

{{ title }}

+ +
{% trans "The following disks are in use:" %}
+
+
+ + + + + + + + + + + {% for disk in disks %} + + + + + + + {% endfor %} + +
{% trans "Device" %}{% trans "Mount Point" %}{% trans "Type" %}{% trans "Used" %}
{{ disk.device }}{{ disk.mount_point }}{{ disk.file_system_type }} +
+ {% if disk.percentage_used < 75 %} +
+ {{ disk.percentage_used }}% +
+
+
{{ disk.used }} / {{ disk.size }}
+
+
+
+ + {% if expandable_root_size %} +

Expandable Partition

+

+ {% blocktrans trimmed %} + There is {{ expandable_root_size }} of unallocated space + available after your root partition. Root partition can be + expanded to use this space. This will provide you additional + free space to store your files. + {% endblocktrans %} +

+

+ + {% trans "Expand Root Partition" %} +

+ {% endif %} + +{% endblock %} diff --git a/plinth/modules/disks/templates/disks_expand.html b/plinth/modules/disks/templates/disks_expand.html new file mode 100644 index 000000000..61fc00a0d --- /dev/null +++ b/plinth/modules/disks/templates/disks_expand.html @@ -0,0 +1,51 @@ +{% 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 }}

+ + {% if expandable_root_size %} + + +

+ {% blocktrans trimmed %} + Please backup your data before proceeding. After this + operation, {{ expandable_root_size }} of additional free space + will be available in your root partition. + {% endblocktrans %} +

+
+ {% csrf_token %} + + +
+ {% else %} +

There are no partitions available to expand. + {% endif %} + +{% endblock %} diff --git a/plinth/modules/disks/tests/__init__.py b/plinth/modules/disks/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plinth/modules/disks/tests/test_disks.py b/plinth/modules/disks/tests/test_disks.py new file mode 100644 index 000000000..fe78571cd --- /dev/null +++ b/plinth/modules/disks/tests/test_disks.py @@ -0,0 +1,240 @@ +# +# 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 . +# + +""" +Test module for disks module operations. +""" + +import os +import re +import subprocess +import unittest + + +euid = os.geteuid() + + +def _get_partition_device(device, partition_number): + """Return the device corresponding to a parition in a given device.""" + if re.match('[0-9]', device[-1]): + return device + 'p' + str(partition_number) + + return device + str(partition_number) + + +class Disk(): + """Context manager to create/destroy a disk.""" + def __init__(self, test_case, size, disk_info, file_system_info=None): + """Initialize the context manager object.""" + self.size = size + self.test_case = test_case + self.disk_info = disk_info + self.file_system_info = file_system_info + + self.disk_file = None + self.device = None + + def _create_disk_file(self): + """Create a temporary file to act as a disk.""" + directory = os.path.dirname(os.path.realpath(__file__)) + disk_file = os.path.join(directory, 'temp_disk.img') + + command = 'dd if=/dev/zero of={file} bs=1M count={size}' \ + .format(size=self.size, file=disk_file) + subprocess.run(command.split(), stderr=subprocess.DEVNULL, check=True) + + self.disk_file = disk_file + + def _setup_loopback(self): + """Setup loop back on the create disk file.""" + command = 'losetup --show --find {file}'.format(file=self.disk_file) + process = subprocess.run(command.split(), stdout=subprocess.PIPE, + check=True) + device = process.stdout.decode().strip() + + subprocess.run(['partprobe', device], check=True) + + self.device = device + self.test_case.device = device + + def _create_partitions(self): + """Create partitions as specified in disk_info.""" + steps = [step.split() for step in self.disk_info] + command = ['parted', '--align=optimal', '--script', self.disk_file] + for step in steps: + command += step + + subprocess.run(command, check=True) + + def _create_file_systems(self): + """Create file systems inside partitions.""" + if not self.file_system_info: + return + + for partition, file_system_type in self.file_system_info: + device = _get_partition_device(self.device, partition) + if file_system_type == 'btrfs': + command = ['mkfs.btrfs', '-K', device] + else: + command = ['mkfs.ext4', device] + + subprocess.run(command, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True) + + def _cleanup_loopback(self): + """Undo the loopback device setup.""" + subprocess.run(['losetup', '--detach', self.device]) + + def _remove_disk_file(self): + """Delete the disk_file.""" + os.remove(self.disk_file) + self.disk_file = None + + def __enter__(self): + """Enter the context, create the test disk.""" + self._create_disk_file() + self._create_partitions() + self._setup_loopback() + self._create_file_systems() + + def __exit__(self, *exc): + """Exit the context, destroy the test disk.""" + self._cleanup_loopback() + self._remove_disk_file() + + +class TestActions(unittest.TestCase): + """Test all actions related to disks.""" + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_simple_case(self): + """Test a simple with no complications""" + disk_info = ['mktable msdos', + 'mkpart primary btrfs 1 8', + 'mkpart primary btrfs 9 16', + 'mkpart primary btrfs 20 200'] + with Disk(self, 256, disk_info, [(3, 'btrfs')]): + # No free space + self.assert_free_space(1, space=False) + # < 10 MiB of free space + self.assert_free_space(2, space=False) + self.assert_free_space(3, space=True) + + self.expand_partition(1, success=False) + self.expand_partition(2, success=False) + self.expand_partition(3, success=True) + self.expand_partition(3, success=False) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_extended_partition_free_space(self): + """Test that free space does not show up when outside extended.""" + disk_info = ['mktable msdos', + 'mkpart primary 1 8', + 'mkpart extended 8 32', + 'mkpart logical 9 16'] + with Disk(self, 64, disk_info): + self.assert_free_space(5, space=False) + self.expand_partition(5, success=False) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_gpt_partition_free_space(self): + """Test that GPT partitions can be expanded.""" + # Specifically check for partition number > 4 + disk_info = ['mktable gpt', + 'mkpart primary 1 4', + 'mkpart extended 4 8', + 'mkpart extended 8 12', + 'mkpart extended 12 16', + 'mkpart extended 16 160'] + with Disk(self, 256, disk_info, [(5, 'btrfs')]): + self.assert_free_space(5, space=True) + self.expand_partition(5, success=True) + self.expand_partition(5, success=False) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_unsupported_file_system(self): + """Test that free space after unknown file system does not count.""" + disk_info = ['mktable msdos', + 'mkpart primary 1 8'] + with Disk(self, 32, disk_info): + self.assert_free_space(1, space=False) + self.expand_partition(1, success=False) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_btrfs_expansion(self): + """Test that btrfs file system can be expanded.""" + disk_info = ['mktable msdos', + 'mkpart primary btrfs 1 200'] + with Disk(self, 256, disk_info, [(1, 'btrfs')]): + self.expand_partition(1, success=True) + self.expand_partition(1, success=False) + self.assert_btrfs_file_system_healthy(1) + + @unittest.skipUnless(euid == 0, 'Needs to be root') + def test_ext4_expansion(self): + """Test that ext4 file system can be expanded.""" + disk_info = ['mktable msdos', + 'mkpart primary ext4 1 64'] + with Disk(self, 128, disk_info, [(1, 'ext4')]): + self.expand_partition(1, success=True) + self.expand_partition(1, success=False) + self.assert_ext4_file_system_healthy(1) + + def assert_free_space(self, partition_number, space=True): + """Verify that free is available/not available after a parition.""" + device = _get_partition_device(self.device, partition_number) + result = self.run_action(['disks', 'is-partition-expandable', device]) + self.assertEqual(result, space) + + def expand_partition(self, partition_number, success=True): + """Expand a partition.""" + self.assert_aligned(partition_number) + device = _get_partition_device(self.device, partition_number) + result = self.run_action(['disks', 'expand-partition', device]) + self.assertEqual(result, success) + self.assert_aligned(partition_number) + + def run_action(self, action_command): + """Run an action and return success/failure result.""" + current_directory = os.path.dirname(os.path.realpath(__file__)) + action = os.path.join(current_directory, '..', '..', '..', '..', + 'actions', action_command[0]) + action_command[0] = action + try: + subprocess.run(action_command, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True) + return True + except subprocess.CalledProcessError: + return False + + def assert_aligned(self, partition_number): + """Test that partition is optimally aligned.""" + subprocess.run(['parted', '--script', self.device, 'align-check', + 'opti', str(partition_number)]) + + def assert_btrfs_file_system_healthy(self, partition_number): + """Perform a successful ext4 file system check.""" + device = _get_partition_device(self.device, partition_number) + command = ['btrfs', 'check', device] + subprocess.run(command, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True) + + def assert_ext4_file_system_healthy(self, partition_number): + """Perform a successful ext4 file system check.""" + device = _get_partition_device(self.device, partition_number) + command = ['e2fsck', '-n', device] + subprocess.run(command, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=True) diff --git a/plinth/modules/disks/urls.py b/plinth/modules/disks/urls.py new file mode 100644 index 000000000..cbe3dc982 --- /dev/null +++ b/plinth/modules/disks/urls.py @@ -0,0 +1,30 @@ +# +# 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 disks module. +""" + +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^sys/disks/$', views.index, name='index'), + url(r'^sys/disks/expand$', views.expand, name='expand'), +] diff --git a/plinth/modules/disks/views.py b/plinth/modules/disks/views.py new file mode 100644 index 000000000..a5186a17f --- /dev/null +++ b/plinth/modules/disks/views.py @@ -0,0 +1,92 @@ +# +# 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 disks 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 _ + +from plinth.modules import disks as disks_module + + +def index(request): + """Show connection list.""" + disks = disks_module.get_disks() + root_device = disks_module.get_root_device(disks) + expandable_root_size = disks_module.is_expandable(root_device) + expandable_root_size = _format_bytes(expandable_root_size) + + return TemplateResponse(request, 'disks.html', + {'title': _('Disks'), + 'disks': disks, + 'expandable_root_size': expandable_root_size}) + + +def expand(request): + """Warn and expand the root partition.""" + disks = disks_module.get_disks() + root_device = disks_module.get_root_device(disks) + + if request.method == 'POST': + expand_partition(request, root_device) + return redirect(reverse('disks:index')) + + expandable_root_size = disks_module.is_expandable(root_device) + expandable_root_size = _format_bytes(expandable_root_size) + return TemplateResponse(request, 'disks_expand.html', + {'title': _('Expand Root Partition'), + 'expandable_root_size': expandable_root_size}) + + +def expand_partition(request, device): + """Expand the partition.""" + try: + disks_module.expand_partition(device) + except Exception as exception: + messages.error(request, _('Error expanding partition: {exception}') + .format(exception=exception)) + else: + messages.success(request, _('Partition expanded successfully.')) + + +def _format_bytes(size): + """Return human readable disk size from bytes.""" + if not size: + return size + + if size < 1024: + return _('{disk_size} bytes').format(disk_size=size) + + if size < 1024 ** 2: + size /= 1024 + return _('{disk_size} KiB').format(disk_size=size) + + if size < 1024 ** 3: + size /= 1024 ** 2 + return _('{disk_size} MiB').format(disk_size=size) + + if size < 1024 ** 4: + size /= 1024 ** 3 + return _('{disk_size} GiB').format(disk_size=size) + + size /= 1024 ** 4 + return _('{disk_size} TiB').format(disk_size=size)