yast/yast-storage-ng

View on GitHub
src/lib/y2partitioner/dialogs/blk_device_resize.rb

Summary

Maintainability
A
0 mins
Test Coverage
# Copyright (c) [2017-2021] SUSE LLC
#
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of version 2 of the GNU General Public License as published
# by the Free Software Foundation.
#
# 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 General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, contact SUSE LLC.
#
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.

require "yast2/popup"
require "cwm/custom_widget"
require "cwm/common_widgets"
require "y2storage"
require "y2partitioner/dialogs/base"
require "y2partitioner/dialogs/unmount"
require "y2partitioner/widgets/controller_radio_buttons"
require "y2partitioner/size_parser"
require "y2partitioner/filesystem_errors"

module Y2Partitioner
  module Dialogs
    # Dialog to set the new size for a partition or LVM LV
    class BlkDeviceResize < Base
      # Constructor
      #
      # @param controller [Y2Partitioner::Actions::Controllers::BlkDevice] controller for a block device
      def initialize(controller)
        super()
        textdomain "storage"

        @controller = controller
        @device = controller.device

        detect_space_info
      end

      # @macro seeDialog
      def title
        # TRANSLATORS: dialog title, where %{name} is the name of a partition
        # (e.g. /dev/sda1) or LVM logical volume (e.g. /dev/system/home)
        format(_("Resize %{name}"), name: device.name)
      end

      # @macro seeDialog
      def contents
        HVSquash(
          VBox(
            size_selector,
            size_info
          )
        )
      end

      # @macro seeDialog
      # Necessary to mimic wizard dialog layout and behaviour
      def should_open_dialog?
        true
      end

      # Handler for "next" button
      #
      # It shows a dialog to unmount the device if required.
      #
      # @return [Boolean] see {#unmount}
      def next_handler
        unmount
      end

      private

      # @return [Y2Storage::Partition, Y2Storage::LvmLv]
      attr_reader :device

      # @return [Y2Partitioner::Actions::Controllers::BlkDevice] controller for a block device
      attr_reader :controller

      # @return [Y2Storage::SpaceInfo]
      attr_reader :space_info

      def detect_space_info
        return if controller.multidevice_filesystem? ||
          !controller.committed_current_filesystem? || swap?

        begin
          @space_info = device.filesystem.detect_space_info
        rescue Storage::Exception => e
          detect_space_info_failed_warning(e)
        end
      end

      # Show a warning popup about an error during detect_space_info.
      #
      # @param err [Storage::Exception] libstorage-ng exception for details
      def detect_space_info_failed_warning(err)
        log.warn "detect_space_info for #{device.name} failed: #{err.what}"

        # TRANSLATORS: Warning message when the user wanted to resize a filesystem
        # and there was a problem getting information about that filesystem.
        msg = _("Obtaining information about free space on this filesystem failed.\n" \
                "Resizing it might or might not work. If you continue, there is a risk\n" \
                "of losing all data on this filesystem.")
        Yast2::Popup.show(msg, headline: :warning, details: err.what, buttons: :ok)
      end

      # Whether the device is formatted
      #
      # @return [Boolean]
      def formatted?
        device.formatted?
      end

      # Whether the device is for swap
      #
      # @return [Boolean]
      def swap?
        return true if device.is?(:partition) && device.id.is?(:swap)

        device.formatted_as?(:swap)
      end

      # Disk size in use
      #
      # @note This value only makes sense if the device is formatted and committed.
      #
      # @return [Y2Storage::Disksize, nil] nil if it is not possible to detect its
      #   space info.
      def used_size
        return nil if space_info.nil?

        space_info.used
      end

      # Currently selected disk size
      #
      # @return [Y2Storage::DiskSize]
      def selected_size
        size_selector.current_widget.size
      end

      # Widget to select the new size
      #
      # @return [SizeSelector]
      def size_selector
        @size_selector ||= SizeSelector.new(device)
      end

      # Widgets to show size info of the device (current and used sizes)
      #
      # Used size is only shown if space info can be detected.
      #
      # @return [Array<CWM::WidgetTerm>]
      def size_info
        widgets = []
        widgets << current_size_info
        widgets << size_details_info
        widgets.compact!

        VBox(*widgets)
      end

      # Widget for current size
      #
      # @return [CWM::WidgetTerm]
      def current_size_info
        size = device.size.to_human_string
        # TRANSLATORS: label for current size of the partition or LVM logical volume,
        # where %{size} is replaced by a size (e.g., 5.5 GiB)
        Left(Label(format(_("Current size: %{size}"), size: size)))
      end

      # Widget with more details about the size
      #
      # @return [CWM::WidgetTerm, nil] nil if there is no details to show
      def size_details_info
        multidevice_filesystem_info || used_size_info
      end

      # Widget with information when the device belongs to a multi-device filesystem
      #
      # @return [CWM::WidgetTerm, nil] nil if the device does not belong to a multi-device filesystem
      def multidevice_filesystem_info
        return nil unless controller.multidevice_filesystem?

        # TRANSLATORS: label when the device is used by a multi-device Btrfs, where %{btrfs} is replaced
        # by the display name of the filesystem (e.g., "Btrfs over 5 devices").
        Left(Label(format(_("Part of %{btrfs}"), btrfs: device.filesystem.display_name)))
      end

      # Widget for used size
      #
      # @return [CWM::WidgetTerm, nil] nil when used size cannot be calculated
      def used_size_info
        return nil unless space_info

        size = used_size.to_human_string
        # TRANSLATORS: label for currently used size of the partition or LVM volume,
        # where %{size} is replaced by a size (e.g., 5.5 GiB)
        Left(Label(format(_("Currently used: %{size}"), size: size)))
      end

      # Tries to unmount the device, if required.
      #
      # @return [Boolean] true if it is not required to unmount or the device was correctly
      #   unmounted or the user decides to continue; false when the user cancels.
      def unmount
        return true unless mounted?

        try_unmount_for_shrinking &&
          try_unmount_for_growing &&
          try_unmount_for_big_growing
      end

      # Whether the filesystem exists on disk and it is mounted in the system
      #
      # @return [Boolean]
      def mounted?
        controller.committed_current_filesystem? &&
          controller.mounted_committed_filesystem?
      end

      # Tries to unmount when shrinking the device
      #
      # @return [Boolean] true if the device supports mounted shrinking or the device was
      #   correctly unmounted or user decides to continue; false if user cancels.
      def try_unmount_for_shrinking
        return true unless shrinking? && controller.unmount_for_shrinking?

        # TRANSLATORS: Note added to the dialog for trying to unmount a device
        note = _("It is not possible to shrink the file system while it is mounted.")

        Unmount.new(controller.committed_filesystem, note: note).run == :finish
      end

      # Tries to unmount when growing the device
      #
      # @return [Boolean] true if the device supports mounted growing or the device was
      #   correctly unmounted or user decides to continue; false if user cancels.
      def try_unmount_for_growing
        return true unless growing? && controller.unmount_for_growing?

        # TRANSLATORS: Note added to the dialog for trying to unmount a device
        note = _("It is not possible to extend the file system while it is mounted.")

        Unmount.new(controller.committed_filesystem, note: note).run == :finish
      end

      # Tries to unmount when performing big growing
      #
      # @return [Boolean] true if the device was correctly unmounted or user decides to
      #   continue; false if user cancels.
      def try_unmount_for_big_growing
        return true unless big_growing? && controller.mounted_committed_filesystem?

        # TRANSLATORS: %s is replaced by a number that represents the amount of GiB to extend (e.g., 56).
        note = format(
          _("You are extending a mounted filesystem by %s Gigabyte. \n" \
            "This may be quite slow and can take hours. You might possibly want \n" \
            "to consider umounting the filesystem, which will increase speed of \n" \
            "resize task a lot."),
          growing_size.to_i / Y2Storage::DiskSize.GiB(1).to_i
        )

        Unmount.new(controller.committed_filesystem, note: note).run == :finish
      end

      # Whether the device is going to be shrunk
      #
      # @return [Boolean]
      def shrinking?
        selected_size < device.size
      end

      # Whether the device is going to be grown
      #
      # @return [Boolean]
      def growing?
        selected_size > device.size
      end

      # Whether the device is going to be grown more than 50 GiB
      #
      # @note Threshold to consider a big growing is defined in the old code, but there is not
      #   any kind of explanation:
      #   https://github.com/yast/yast-storage/blob/SLE-12-SP4/src/include/partitioning/ep-dialogs.rb#L1229
      #
      # @return [Boolean]
      def big_growing?
        growing? && growing_size > Y2Storage::DiskSize.GiB(50)
      end

      # How much the device is going to be grown
      #
      # @return [Y2Storage::DiskSize]
      def growing_size
        selected_size - device.size
      end
    end

    class BlkDeviceResize
      # Widget to select a new size
      #
      # @note The device is updated with the selected size.
      class SizeSelector < Widgets::ControllerRadioButtons
        include FilesystemErrors

        # Constructor
        #
        # @param device [Y2Storage::Partition, Y2Storage::LvmLv]
        def initialize(device)
          super()
          textdomain "storage"

          @device = device
        end

        # @macro seeAbstractWidget
        def label
          _("Size")
        end

        # @see Widgets::ControllerRadioButtons
        def items
          max_size_label = format(_("Maximum Size (%{size})"), size: max_size.to_human_string)
          min_size_label = format(_("Minimum Size (%{size})"), size: min_size.to_human_string)
          [
            [:max_size, max_size_label],
            [:min_size, min_size_label],
            [:custom_size, _("Custom Size")]
          ]
        end

        # @see Widgets::ControllerRadioButtons
        def widgets
          @widgets ||= [
            BlkDeviceResize::FixedSizeWidget.new(max_size),
            BlkDeviceResize::FixedSizeWidget.new(min_size),
            BlkDeviceResize::CustomSizeWidget.new(min_size, max_size, current_size)
          ]
        end

        # @macro seeAbstractWidget
        def init
          self.value = :max_size
          # trigger disabling the other subwidgets
          handle("ID" => value)
        end

        # @macro seeAbstractWidget
        # Updates the device with the new size
        def store
          device.resize(current_widget.size)
          show_result_warnings
        end

        # @macro seeAbstractWidget
        def help
          _("<p>Choose new size.</p>")
        end

        # @macro seeAbstractWidget
        # Whether the given size is valid. It must be a size between the
        # min and max possible sizes.
        #
        # @note An error popup is shown when the given size is not valid.
        #   A warning popup is shown if there are some warnings.
        #
        # @see #errors
        # @see #validation_warnings
        #
        # @return [Boolean] true if there are no errors in the given size and
        #   the user decides to continue despite of the warnings (if any);
        #   false otherwise.
        def validate
          current_errors = errors
          current_warnings = validation_warnings

          return true if current_errors.empty? && current_warnings.empty?

          Yast::UI.SetFocus(Id(widgets.last.widget_id))

          if current_errors.any?
            message = current_errors.join("\n\n")
            Yast2::Popup.show(message, headline: :error)
            false
          else
            message = current_warnings
            message << _("Do you want to continue with the current setup?")
            message = message.join("\n\n")
            Yast2::Popup.show(message, headline: :warning, buttons: :yes_no) == :yes
          end
        end

        private

        # @return [Y2Storage::Partition, Y2Storage::LvmLv]
        attr_reader :device

        # Whether the device is a striped logical volume
        #
        # @return [Boolean]
        def striped_lv?
          device.is?(:lvm_lv) && device.striped?
        end

        # Resize information of the device to be resized
        #
        # @return [Y2Storage::ResizeInfo]
        def resize_info
          device.resize_info
        end

        # Min possible size
        #
        # @return [Y2Storage::DiskSize]
        def min_size
          min =
            if device.respond_to?(:aligned_min_size)
              device.aligned_min_size
            else
              resize_info.min_size
            end
          [min, device.size].min
        end

        # Max possible size
        #
        # @return [Y2Storage::DiskSize]
        def max_size
          striped_lv? ? max_size_for_striped_lv : resize_info.max_size
        end

        # Max size for a striped logical volume
        #
        # This is the maximum possible size, but nothing guarantees that the assigned physical volumes
        # have enough free extends to allocate it.
        #
        # @return [Y2Storage::DiskSize]
        def max_size_for_striped_lv
          [device.lvm_vg.max_size_for_striped_lv(device.stripes), resize_info.max_size].compact.min
        end

        # Current device size
        #
        # @return [Y2Storage::DiskSize]
        def current_size
          device.size
        end

        # Errors detected in the given size
        #
        # @see #size_limits_error
        #
        # @return [Array<String>]
        def errors
          [size_limits_error].compact
        end

        # Error when the given size is not between the allowed min and max values
        #
        # @return [String, nil] nil if the size is valid.
        def size_limits_error
          v = current_widget.size
          return nil if v && v >= min_size && v <= max_size

          min_s = min_size.human_ceil
          max_s = max_size.human_floor

          format(
            # TRANSLATORS: error popup message, where %{min} and %{max} are replaced by sizes.
            _("The size entered is invalid. Enter a size between %{min} and %{max}."),
            min: min_s,
            max: max_s
          )
        end

        # Warnings detected in the given size
        #
        # @see FilesystemValidation
        #
        # @return [Array<String>]
        def validation_warnings
          filesystem_errors(device.filesystem, new_size: current_widget.size)
        end

        # Shows warning messages after setting the new size
        #
        # @note A popup is shown with the warnings.
        #
        # @see #result_warnings
        def show_result_warnings
          warnings = result_warnings
          return if warnings.empty?

          message = warnings.join("\n\n")

          Yast2::Popup.show(message, headline: :warning)
        end

        # Warnings after saving the given new size
        #
        # @see #overcommitted_thin_pool_warning
        #
        # @return [Array<String>]
        def result_warnings
          [overcommitted_thin_pool_warning].compact
        end

        # Warning when the resizing device is an LVM thin pool and it is overcommitted
        #
        # @see #overcommitted_thin_pool?
        #
        # @return [String, nil] nil if the device is not a thin pool or it is not
        #   overcommitted.
        def overcommitted_thin_pool_warning
          return nil unless overcommitted_thin_pool?

          total_thin_size = Y2Storage::DiskSize.sum(device.lvm_lvs.map(&:size))

          format(
            _("The LVM thin pool %{name} is overcomitted "\
              "(needs %{total_thin_size} and only has %{size}).\n" \
              "It might not have enough space for some LVM thin volumes."),
            name:            device.name,
            size:            device.size.to_human_string,
            total_thin_size: total_thin_size.to_human_string
          )
        end

        # Whether the device is an overcommitted thin pool
        #
        # @return [Boolean]
        def overcommitted_thin_pool?
          return false unless device.is?(:lvm_lv)

          device.overcommitted?
        end
      end
    end

    class BlkDeviceResize
      # An invisible widget that knows a fixed size
      class FixedSizeWidget < CWM::Empty
        # @return [Y2Storage::DiskSize]
        attr_reader :size

        # Constructor
        #
        # @param size [Y2Storage::DiskSize]
        def initialize(size)
          super("__FixedSizeWidget")
          @size = size
        end

        # @macro seeAbstractWidget
        def store
          # nothing to do, that's OK
        end
      end
    end

    class BlkDeviceResize
      # Widget to enter a human readable size
      class CustomSizeWidget < CWM::InputField
        include SizeParser

        # @return [Y2Storage::DiskSize]
        attr_reader :min_size

        # @return [Y2Storage::DiskSize]
        attr_reader :max_size

        # @return [Y2Storage::DiskSize]
        attr_reader :current_size

        # Constructor
        #
        # @param min_size [Y2Storage::DiskSize]
        # @param max_size [Y2Storage::DiskSize]
        # @param current_size [Y2Storage::DiskSize]
        def initialize(min_size, max_size, current_size)
          super()
          textdomain "storage"

          @min_size = min_size
          @max_size = max_size
          @current_size = current_size
        end

        # @macro seeAbstractWidget
        def label
          _("Size")
        end

        # @macro seeAbstractWidget
        def init
          self.value = current_size
        end

        # @macro seeAbstractWidget
        def store
          # nothing to do, that's OK
        end

        # @return [Y2Storage::DiskSize, nil] nil if the given size is not human readable.
        def value
          parse_user_size(super)
        end

        alias_method :size, :value

        # @param disk_size [Y2Storage::DiskSize]
        def value=(disk_size)
          super(disk_size.to_human_string)
        end
      end
    end
  end
end