mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-20 10:34:30 +00:00
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.
This commit is contained in:
parent
ec3739c942
commit
9ac37465dd
222
actions/disks
Executable file
222
actions/disks
Executable file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
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()
|
||||||
1
data/etc/plinth/modules-enabled/disks
Normal file
1
data/etc/plinth/modules-enabled/disks
Normal file
@ -0,0 +1 @@
|
|||||||
|
plinth.modules.disks
|
||||||
100
plinth/modules/disks/__init__.py
Normal file
100
plinth/modules/disks/__init__.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
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])
|
||||||
93
plinth/modules/disks/templates/disks.html
Normal file
93
plinth/modules/disks/templates/disks.html
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% load bootstrap %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block page_head %}
|
||||||
|
<style type="text/css">
|
||||||
|
.progress {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
|
||||||
|
<div>{% trans "The following disks are in use:" %}</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<table class="table table-bordered table-condensed table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Device" %}</th>
|
||||||
|
<th>{% trans "Mount Point" %}</th>
|
||||||
|
<th>{% trans "Type" %}</th>
|
||||||
|
<th>{% trans "Used" %}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for disk in disks %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ disk.device }}</td>
|
||||||
|
<td>{{ disk.mount_point }}</td>
|
||||||
|
<td>{{ disk.file_system_type }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress">
|
||||||
|
{% if disk.percentage_used < 75 %}
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-success"
|
||||||
|
{% elif disk.percentage_used < 90 %}
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-warning"
|
||||||
|
{% else %}
|
||||||
|
<div class="progress-bar progress-bar-striped progress-bar-danger"
|
||||||
|
{% endif %}
|
||||||
|
role="progressbar" aria-valuenow="disk.percentage_used"
|
||||||
|
aria-valuemin="0" aria-valuemax="100"
|
||||||
|
style="width: {{ disk.percentage_used }}%;">
|
||||||
|
{{ disk.percentage_used }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{{ disk.used }} / {{ disk.size }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if expandable_root_size %}
|
||||||
|
<h3>Expandable Partition</h3>
|
||||||
|
<p>
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn btn-primary btn-md" href="{% url 'disks:expand' %}">
|
||||||
|
{% trans "Expand Root Partition" %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
51
plinth/modules/disks/templates/disks_expand.html
Normal file
51
plinth/modules/disks/templates/disks_expand.html
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% load bootstrap %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>{{ title }}</h2>
|
||||||
|
|
||||||
|
{% if expandable_root_size %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<span class="glyphicon glyphicon-exclamation-sign"
|
||||||
|
aria-hidden="true"></span>
|
||||||
|
This feature is still in beta. Use at your own risk.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
<form class="form" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<input type="submit" class="btn btn-primary"
|
||||||
|
value="{% trans "Expand Root Partition" %}"/>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p>There are no partitions available to expand.
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
0
plinth/modules/disks/tests/__init__.py
Normal file
0
plinth/modules/disks/tests/__init__.py
Normal file
240
plinth/modules/disks/tests/test_disks.py
Normal file
240
plinth/modules/disks/tests/test_disks.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
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)
|
||||||
30
plinth/modules/disks/urls.py
Normal file
30
plinth/modules/disks/urls.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
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'),
|
||||||
|
]
|
||||||
92
plinth/modules/disks/views.py
Normal file
92
plinth/modules/disks/views.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
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)
|
||||||
Loading…
x
Reference in New Issue
Block a user