mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-01-28 08:03:36 +00:00
- Remove freedombox-udiskie.service file. Don't run udiskie anymore. Use our own
implementation of auto-mounting.
- Schedule disk failure checking to 3 seconds after application initialization.
Also perform auto-mounting at that time.
- Listen to new filesystems added and auto-mount them.
- Listen to disk failing attribute and report to user via a notification.
- Add rules to polkit-1 to allow plinth user to mount drives.
- Add simple abstractions over DBusProxy objects make accessing properties
simpler.
- Replicate udiskie's approach to mounting disks.
- Mount as root user for now using command line instead of DBus API. This is to
keep compatibility with older code that mounted under /media/root with relaxed
permissions.
Udiskie analysis:
- On device added, media added, perform auto_add
- On device changed and is addable and old state is not addable or removeable
- Automount condition:
- Matches configuration
- Not ignored
- is_filesystem and not mounted -> mount
- crypto device -> try unlock -> if success, mount
- is partition table
- Get all non-ignored devices, if partition then mount
- Mount condition:
- Is not ignored
- Is filesystem
- Find device with path
- Get options from configuration
- Is ntfs and executable ntfs-3g is not available
- Call mount
- No support for udisks1
- Built-in rules
- {'symlinks': '/dev/mapper/docker-*', 'ignore': True}
- {'symlinks': '/dev/disk/by-id/dm-name-docker-*', 'ignore': True}
- {'is_loop': True, 'is_ignored': False, 'loop_file': '/*', 'ignore': False}
- {'is_block': False, 'ignore': True}
- {'is_external': False, 'is_toplevel': True, 'ignore': True}
- {'is_ignored': True, 'ignore': True}
Tests performed:
- Create a CDROM in VM, inject media. Disk should get mounted.
- Create a temp file. mkfs.ext4 it at top level. losetup it. It should not get
auto mounted as it is a top level internal device.
- Create a temp file. Create two partitions and format the partitions. kpartx
-a on it. Both the file systems should get mounted.
- Create a temp file. luksformat it. Create a filesystem. luksopen the file.
It should get auto mounted.
- Checking for disk space repeatedly happens every 3 minutes.
- Drives are checked for healthy status only once, 3 seconds after FreedomBox is started.
- FreedomBox is able to mount disks while running as 'plinth' user with
policykit-1 version 0.105-26.
- FreedomBox is able to mount disks while running as 'plinth' user with
policykit-1 version 0.116-2 from experimental.
- Temporarily flip the is_failing condition in report_failing_drive. When
FreedomBox is restarted, notification about drives failing show up. When the
condition is reverted to normal, the notification is withdrawn.
- Build new Debian package and upgrade system with 20.8 installed. Two files
should be removed:
/var/lib/systemd/deb-systemd-helper-enabled/freedombox-udiskie.service.dsh-also
/etc/systemd/system/multi-user.target.wants/freedombox-udiskie.service .
systemctl status freedombox-udiskie.service should report no such unit.
Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Veiko Aasa <veiko17@disroot.org>
355 lines
12 KiB
Python
355 lines
12 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
"""
|
|
FreedomBox app to manage storage.
|
|
"""
|
|
|
|
import base64
|
|
import logging
|
|
import subprocess
|
|
|
|
import psutil
|
|
from django.utils.translation import ugettext_lazy as _
|
|
from django.utils.translation import ugettext_noop
|
|
|
|
from plinth import actions
|
|
from plinth import app as app_module
|
|
from plinth import cfg, glib, menu, utils
|
|
from plinth.errors import ActionError, PlinthError
|
|
from plinth.utils import format_lazy, import_from_gi
|
|
|
|
from . import udisks2
|
|
from .manifest import backup # noqa, pylint: disable=unused-import
|
|
|
|
version = 4
|
|
|
|
managed_packages = ['parted', 'udisks2', 'gir1.2-udisks-2.0']
|
|
|
|
_description = [
|
|
format_lazy(
|
|
_('This module allows you to manage storage media attached to your '
|
|
'{box_name}. You can view the storage media currently in use, mount '
|
|
'and unmount removable media, expand the root partition etc.'),
|
|
box_name=_(cfg.box_name))
|
|
]
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
is_essential = True
|
|
|
|
app = None
|
|
|
|
|
|
class StorageApp(app_module.App):
|
|
"""FreedomBox app for storage."""
|
|
|
|
app_id = 'storage'
|
|
|
|
can_be_disabled = False
|
|
|
|
def __init__(self):
|
|
"""Create components for the app."""
|
|
super().__init__()
|
|
info = app_module.Info(app_id=self.app_id, version=version,
|
|
is_essential=is_essential, name=_('Storage'),
|
|
icon='fa-hdd-o', description=_description,
|
|
manual_page='Storage')
|
|
self.add(info)
|
|
|
|
menu_item = menu.Menu('menu-storage', info.name, None, info.icon,
|
|
'storage:index', parent_url_name='system')
|
|
self.add(menu_item)
|
|
|
|
# Check every hour for low disk space, every 3 minutes in debug mode
|
|
interval = 180 if cfg.develop else 3600
|
|
glib.schedule(interval, warn_about_low_disk_space)
|
|
|
|
# Schedule initialization of UDisks2 initialization
|
|
glib.schedule(3, udisks2.init, repeat=False)
|
|
|
|
|
|
def init():
|
|
"""Initialize the module."""
|
|
global app
|
|
app = StorageApp()
|
|
app.set_enabled(True)
|
|
|
|
|
|
def get_disks():
|
|
"""Returns list of disks by combining information from df and udisks."""
|
|
disks = _get_disks_from_df()
|
|
disks_from_udisks = _get_disks_from_udisks()
|
|
|
|
# Add info from udisks to the disks from df based on mount point.
|
|
for disk_from_df in disks:
|
|
for disk_from_udisks in disks_from_udisks:
|
|
if disk_from_udisks['mount_point'] == disk_from_df['mount_point']:
|
|
disk_from_df.update(disk_from_udisks)
|
|
|
|
return sorted(disks, key=lambda disk: disk['device'])
|
|
|
|
|
|
def _get_disks_from_udisks():
|
|
"""List devices that can be ejected."""
|
|
udisks = utils.import_from_gi('UDisks', '2.0')
|
|
client = udisks.Client.new_sync()
|
|
object_manager = client.get_object_manager()
|
|
devices = []
|
|
|
|
for obj in object_manager.get_objects():
|
|
|
|
if not obj.get_block():
|
|
continue
|
|
|
|
block = obj.get_block()
|
|
|
|
if block.props.id_usage != 'filesystem':
|
|
continue
|
|
|
|
device = {
|
|
'device': block.props.device,
|
|
'label': block.props.id_label,
|
|
'size': format_bytes(block.props.size),
|
|
'filesystem_type': block.props.id_type,
|
|
'is_removable': not block.props.hint_system
|
|
}
|
|
try:
|
|
device['mount_point'] = obj.get_filesystem().props.mount_points[0]
|
|
except Exception:
|
|
continue
|
|
devices.append(device)
|
|
|
|
return devices
|
|
|
|
|
|
def _get_disks_from_df():
|
|
"""Return the list of disks and free space available using 'df'."""
|
|
try:
|
|
output = actions.superuser_run('storage', ['usage-info'])
|
|
except subprocess.CalledProcessError as exception:
|
|
logger.exception('Error getting disk information: %s', exception)
|
|
return []
|
|
|
|
disks = []
|
|
for line in output.splitlines()[1:]:
|
|
parts = line.split(maxsplit=6)
|
|
keys = ('device', 'filesystem_type', 'size', 'used', 'free',
|
|
'percent_used', 'mount_point')
|
|
disk = dict(zip(keys, parts))
|
|
disk['percent_used'] = int(disk['percent_used'].rstrip('%'))
|
|
disk['size'] = int(disk['size'])
|
|
disk['used'] = int(disk['used'])
|
|
disk['free'] = int(disk['free'])
|
|
disk['size_str'] = format_bytes(disk['size'])
|
|
disk['used_str'] = format_bytes(disk['used'])
|
|
disk['free_str'] = format_bytes(disk['free'])
|
|
disk['label'] = None
|
|
disk['is_removable'] = None
|
|
disks.append(disk)
|
|
|
|
return disks
|
|
|
|
|
|
def get_filesystem_type(mount_point='/'):
|
|
"""Returns the type of the filesystem mounted at mountpoint."""
|
|
for partition in psutil.disk_partitions():
|
|
if partition.mountpoint == mount_point:
|
|
return partition.fstype
|
|
|
|
raise ValueError('No such mount point')
|
|
|
|
|
|
def get_disk_info(mount_point):
|
|
"""Get information about the free space of a drive"""
|
|
disks = get_disks()
|
|
list_root = [disk for disk in disks if disk['mount_point'] == mount_point]
|
|
if not list_root:
|
|
raise PlinthError('Mount point {} not found.'.format(mount_point))
|
|
|
|
percent_used = list_root[0]['percent_used']
|
|
free_bytes = list_root[0]['free']
|
|
free_gib = free_bytes / (1024**3)
|
|
return {
|
|
'percent_used': percent_used,
|
|
'free_bytes': free_bytes,
|
|
'free_gib': free_gib
|
|
}
|
|
|
|
|
|
def get_root_device(disks):
|
|
"""Return the root partition's device from list of partitions."""
|
|
for disk in disks:
|
|
if disk['mount_point'] == '/':
|
|
return disk['device']
|
|
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('storage',
|
|
['is-partition-expandable', device],
|
|
log_error=False)
|
|
except actions.ActionError:
|
|
return False
|
|
|
|
return int(output.strip())
|
|
|
|
|
|
def expand_partition(device):
|
|
"""Expand a partition."""
|
|
actions.superuser_run('storage', ['expand-partition', device])
|
|
|
|
|
|
def format_bytes(size):
|
|
"""Return human readable disk size from bytes."""
|
|
if not size:
|
|
return size
|
|
|
|
if size < 1024:
|
|
return _('{disk_size:.1f} bytes').format(disk_size=size)
|
|
|
|
if size < 1024**2:
|
|
size /= 1024
|
|
return _('{disk_size:.1f} KiB').format(disk_size=size)
|
|
|
|
if size < 1024**3:
|
|
size /= 1024**2
|
|
return _('{disk_size:.1f} MiB').format(disk_size=size)
|
|
|
|
if size < 1024**4:
|
|
size /= 1024**3
|
|
return _('{disk_size:.1f} GiB').format(disk_size=size)
|
|
|
|
size /= 1024**4
|
|
return _('{disk_size:.1f} TiB').format(disk_size=size)
|
|
|
|
|
|
def get_error_message(error):
|
|
"""Return an error message given an exception."""
|
|
udisks = import_from_gi('UDisks', '2.0')
|
|
if error.matches(udisks.Error.quark(), udisks.Error.FAILED):
|
|
message = _('The operation failed.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.CANCELLED):
|
|
message = _('The operation was cancelled.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.ALREADY_UNMOUNTING):
|
|
message = _('The device is already unmounting.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.NOT_SUPPORTED):
|
|
message = _('The operation is not supported due to '
|
|
'missing driver/tool support.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.TIMED_OUT):
|
|
message = _('The operation timed out.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.WOULD_WAKEUP):
|
|
message = _('The operation would wake up a disk that is '
|
|
'in a deep-sleep state.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.DEVICE_BUSY):
|
|
message = _('Attempting to unmount a device that is busy.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.ALREADY_CANCELLED):
|
|
message = _('The operation has already been cancelled.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.NOT_AUTHORIZED) or \
|
|
error.matches(udisks.Error.quark(),
|
|
udisks.Error.NOT_AUTHORIZED_CAN_OBTAIN) or \
|
|
error.matches(udisks.Error.quark(),
|
|
udisks.Error.NOT_AUTHORIZED_DISMISSED):
|
|
message = _('Not authorized to perform the requested operation.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.ALREADY_MOUNTED):
|
|
message = _('The device is already mounted.')
|
|
elif error.matches(udisks.Error.quark(), udisks.Error.NOT_MOUNTED):
|
|
message = _('The device is not mounted.')
|
|
elif error.matches(udisks.Error.quark(),
|
|
udisks.Error.OPTION_NOT_PERMITTED):
|
|
message = _('Not permitted to use the requested option.')
|
|
elif error.matches(udisks.Error.quark(),
|
|
udisks.Error.MOUNTED_BY_OTHER_USER):
|
|
message = _('The device is mounted by another user.')
|
|
else:
|
|
message = error.message
|
|
|
|
return message
|
|
|
|
|
|
def setup(helper, old_version=None):
|
|
"""Install and configure the module."""
|
|
helper.install(managed_packages, skip_recommends=True)
|
|
helper.call('post', actions.superuser_run, 'storage', ['setup'])
|
|
helper.call('post', app.enable)
|
|
disks = get_disks()
|
|
root_device = get_root_device(disks)
|
|
if is_expandable(root_device):
|
|
try:
|
|
expand_partition(root_device)
|
|
except ActionError:
|
|
pass
|
|
|
|
|
|
def warn_about_low_disk_space(request):
|
|
"""Warn about insufficient space on root partition."""
|
|
from plinth.notification import Notification
|
|
|
|
try:
|
|
root_info = get_disk_info('/')
|
|
except PlinthError as exception:
|
|
logger.exception('Error getting information about root partition: %s',
|
|
exception)
|
|
return
|
|
|
|
show = False
|
|
if root_info['percent_used'] > 90 or root_info['free_gib'] < 1:
|
|
severity = 'error'
|
|
show = True
|
|
elif root_info['percent_used'] > 75 or root_info['free_gib'] < 2:
|
|
severity = 'warning'
|
|
show = True
|
|
|
|
if not show:
|
|
try:
|
|
Notification.get('storage-low-disk-space').delete()
|
|
except KeyError:
|
|
pass
|
|
else:
|
|
message = ugettext_noop(
|
|
# xgettext:no-python-format
|
|
'Low space on system partition: {percent_used}% used, '
|
|
'{free_space} free.')
|
|
title = ugettext_noop('Low disk space')
|
|
data = {
|
|
'app_icon': 'fa-hdd-o',
|
|
'app_name': ugettext_noop('Storage'),
|
|
'percent_used': root_info['percent_used'],
|
|
'free_space': format_bytes(root_info['free_bytes'])
|
|
}
|
|
actions = [{
|
|
'type': 'link',
|
|
'class': 'primary',
|
|
'text': 'Go to {app_name}',
|
|
'url': 'storage:index'
|
|
}, {
|
|
'type': 'dismiss'
|
|
}]
|
|
Notification.update_or_create(id='storage-low-disk-space',
|
|
app_id='storage', severity=severity,
|
|
title=title, message=message,
|
|
actions=actions, data=data,
|
|
group='admin')
|
|
|
|
|
|
def report_failing_drive(id, is_failing):
|
|
"""Show or withdraw notification about failing drive."""
|
|
notification_id = 'storage-disk-failure-' + base64.b32encode(
|
|
id.encode()).decode()
|
|
|
|
from plinth.notification import Notification
|
|
title = ugettext_noop('Disk failure imminent')
|
|
message = ugettext_noop(
|
|
'Disk {id} is reporting that it is likely to fail in the near future. '
|
|
'Copy any data while you still can and replace the drive.')
|
|
data = {'id': id}
|
|
note = Notification.update_or_create(id=notification_id, app_id='storage',
|
|
severity='error', title=title,
|
|
message=message, actions=[{
|
|
'type': 'dismiss'
|
|
}], data=data, group='admin')
|
|
note.dismiss(should_dismiss=not is_failing)
|