yast/yast-storage-ng

View on GitHub
src/lib/y2storage/proposal/devices_planner.rb

Summary

Maintainability
A
25 mins
Test Coverage
# Copyright (c) [2016-2024] 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 "y2storage/storage_manager"
require "y2storage/planned"
require "y2storage/disk_size"
require "y2storage/boot_requirements_checker"
require "y2storage/exceptions"

module Y2Storage
  module Proposal
    # Class to generate the list of planned devices of a proposal.
    class DevicesPlanner
      include Yast::Logger

      # Settings used to calculate the planned devices
      # @return [ProposalSettings]
      attr_accessor :settings

      # Constructor
      #
      # @param settings [ProposalSettings]
      def initialize(settings)
        @settings = settings
      end

      # List of all devices (read: partitions or logical volumes) that need to be created to
      # satisfy the settings, including those needed for booting.
      #
      # @param target [Symbol] :desired means the sizes of the planned devices
      #   should be the ideal ones, :min for generating the smallest functional
      #   devices
      # @param devicegraph [Devicegraph]
      # @return [Array<Planned::Device>]
      def planned_devices(target, devicegraph)
        devices = volumes_planned_devices(target, devicegraph)
        add_boot_devices(devices, target, devicegraph)
        devices
      end

      # List of devices that need to be created to satisfy the settings. Does not include
      # devices needed for booting.
      #
      # @param target [Symbol] see #planned_devices
      # @param devicegraph [Devicegraph]
      # @return [Array<Planned::Device>]
      def volumes_planned_devices(target, devicegraph)
        @target = target
        proposed_volumes = settings.volumes.select(&:proposed?)
        devices = proposed_volumes.map { |v| planned_device(v, devicegraph) }
        remove_shadowed_subvolumes(devices)
      end

      # Modifies the given list of planned devices, adding any planned partition needed for booting
      # the new target system
      #
      # @param devices [Array<Planned::Device>]
      # @param target [Symbol] see #planned_devices
      # @param devicegraph [Devicegraph]
      # @return [Array<Planned::Device>]
      def add_boot_devices(devices, target, devicegraph)
        return unless settings.boot

        @target = target
        devices.unshift(*planned_boot_devices(devices, devicegraph))
        remove_shadowed_subvolumes(devices)
      end

      protected

      # @return [Symbol] :desired or :min
      attr_reader :target

      # Planned devices needed by the bootloader
      #
      # @param planned_devices [Array<Planned::Device>] devices that have been planned
      # @param devicegraph [Devicegraph]
      # @return [Array<Planned::Device>]
      def planned_boot_devices(planned_devices, devicegraph)
        flat = planned_devices.flat_map do |dev|
          dev.respond_to?(:lvs) ? dev.lvs : dev
        end
        checker = BootRequirementsChecker.new(
          devicegraph, planned_devices: flat, boot_disk_name: settings.root_device
        )
        checker.needed_partitions(target)
      rescue BootRequirementsChecker::Error => e
        # As documented, {BootRequirementsChecker#needed_partition} raises this
        # exception if it's impossible to get a bootable system, even adding
        # more partitions.
        raise NotBootableError, e.message
      end

      # Delete shadowed subvolumes from each planned device
      # @param planned_devices [Array<Planned::Device>] devices that have been planned
      def remove_shadowed_subvolumes(planned_devices)
        planned_devices.each do |device|
          next unless device.respond_to?(:subvolumes)

          device.shadowed_subvolumes(planned_devices).each do |subvolume|
            log.info "Subvolume #{subvolume} would be shadowed. Removing it."
            device.subvolumes.delete(subvolume)
          end
        end
      end

      # Return the total amount of RAM as DiskSize
      #
      # @return [DiskSize] current RAM size
      def ram_size
        DiskSize.new(StorageManager.instance.arch.ram_size)
      end

      # Plans a device based on a <volume> section from control file
      #
      # @param volume [VolumeSpecification]
      # @return [Planned::Device]
      def planned_device(volume, devicegraph)
        reused = reusable_device(volume, devicegraph)

        if reused
          planned_reuse(volume, reused)
        elsif settings.separate_vgs && volume.separate_vg?
          planned_separate_vg(volume)
        else
          planned_type = planned_lv?(volume) ? Planned::LvmLv : Planned::Partition
          planned_blk_device(volume, planned_type)
        end
      end

      # @see #planned_device
      #
      # @param volume [VolumeSpecification] volume that is NOT assigned to a separate volume group
      # @return [Boolean]
      def planned_lv?(volume)
        # Exceptional case: although the mode is auto, a concrete device has been specified for a
        # volume that is not assigned to a separate VG
        return false if settings.allocate_mode?(:auto) && volume.device

        settings.lvm
      end

      # Device that should be used for the given volume
      #
      # @param volume [VolumeSpecification]
      # @param devicegraph [Devicegraph]
      # @return [Y2Storage::Device, nil] nil if no device should be reused or if the device could
      #   not be found
      def reusable_device(volume, devicegraph)
        return nil unless volume.reuse?

        device = devicegraph.find_by_any_name(volume.reuse_name)
        return nil unless device.is?(:blk_device)

        device
      end

      # @see #planned_device
      #
      # @param volume [VolumeSpecification]
      # @param reused [Y2Storage::Device]
      # @return [Planned::Device]
      def planned_reuse(volume, reused)
        planned_type = reused_planned_type(reused)
        planned = planned_blk_device(volume, planned_type)
        planned.assign_reuse(reused)
        planned.reformat = volume.reformat?
        planned
      end

      # @see #planned_device
      def reused_planned_type(reused)
        return Planned::LvmLv if reused.is?(:lvm_lv)
        return Planned::Partition if reused.is?(:partition)

        # There are more specific types of planned devices, such as Bcache, Md, or StrayBlkDevice,
        # but regarding reuse, relying on Disk seem to be good enough for all cases
        Planned::Disk
      end

      # @see #planned_device
      #
      # @param volume [VolumeSpecification]
      # @return [Planned::LvmLv, Planed::Partition]
      def planned_blk_device(volume, planned_type)
        planned_device = create_planned_device(planned_type, volume.mount_point)
        planned_device.filesystem_type = volume.fs_type
        adjust_to_settings(planned_device, volume)
        planned_device
      end

      # @see #planned_blk_device
      def create_planned_device(type, mount_point)
        return type.new(mount_point) if [Planned::LvmLv, Planned::Partition].include?(type)

        dev = type.new
        dev.mount_point = mount_point
        dev
      end

      # @see #planned_device
      #
      # @param volume [VolumeSpecification]
      # @return [Planned::LvmVg]
      def planned_separate_vg(volume)
        lv = Planned::LvmLv.new(volume.mount_point, volume.fs_type)
        adjust_to_settings(lv, volume)

        planned_device = Planned::LvmVg.new(volume_group_name: volume.separate_vg_name, lvs: [lv])
        adjust_pvs_encryption(planned_device)
        planned_device
      end

      # @see #planned_separate_vg
      #
      # @param vg [Planned::LvmVg]
      def adjust_pvs_encryption(vg)
        return unless settings.encryption_password

        vg.pvs_encryption_password = settings.encryption_password
        vg.pvs_encryption_method = settings.encryption_method
        vg.pvs_encryption_pbkdf = settings.encryption_pbkdf
      end

      # Adjusts planned device values according to settings
      #
      # @note planned_device is modified
      #
      # @param planned_device [Planned::Device]
      # @param volume [VolumeSpecification]
      def adjust_to_settings(planned_device, volume)
        adjust_weight(planned_device, volume)
        adjust_encryption(planned_device, volume)
        adjust_sizes(planned_device, volume)
        adjust_btrfs(planned_device, volume)
        adjust_device(planned_device, volume)

        planned_device.fstab_options = volume.mount_options&.split(",")
      end

      # Adjusts planned device weight according to settings
      #
      # @param planned_device [Planned::Device]
      # @param volume [VolumeSpecification]
      def adjust_weight(planned_device, volume)
        return unless planned_device.respond_to?(:weight)

        planned_device.weight = value_with_fallbacks(volume, :weight)
      end

      # Adjusts planned device encryption according to settings
      #
      # @param planned_device [Planned::Device]
      # @param _volume [VolumeSpecification]
      def adjust_encryption(planned_device, _volume)
        return unless planned_device.is_a?(Planned::Partition) || planned_device.is_a?(Planned::Disk)
        return unless settings.encryption_password

        planned_device.encryption_password = settings.encryption_password
        planned_device.encryption_method = settings.encryption_method
        planned_device.encryption_pbkdf = settings.encryption_pbkdf
      end

      # Adjusts planned device sizes according to settings
      #
      # @param planned_device [Planned::Device]
      # @param volume [VolumeSpecification]
      def adjust_sizes(planned_device, volume)
        return unless planned_device.respond_to?(:min_size)

        planned_device.min_size = min_size(volume)
        planned_device.max_size = max_size(volume)

        return if volume.ignore_adjust_by_ram?

        if volume.adjust_by_ram?
          planned_device.min_size = [planned_device.min_size, ram_size].max
          planned_device.max_size = [planned_device.max_size, ram_size].max
        end

        nil
      end

      # Min size for the given volume, not having adjust_by_ram? into account
      #
      # @param volume [VolumeSpecification]
      # @return [DiskSize]
      def min_size(volume)
        if :min == target
          value_with_fallbacks(volume, :min_size)
        else
          value_with_fallbacks(volume, :desired_size)
        end
      end

      # Max size for the given volume, not having adjust_by_ram? into account
      #
      # @param volume [VolumeSpecification]
      # @return [DiskSize]
      def max_size(volume)
        # If no LVM is involved, this is quite straightforward
        return value_with_fallbacks(volume, :max_size) unless settings.lvm

        # But with LVM, the behavior of fallback_max_size_lvm is not so obvious.
        # From the existing tests, it can be inferred that such attribute only
        # looks into max_size_lvm (never falling back to max_size), so it
        # basically only makes sense when combined with an explicit max_size_lvm.
        value = value_with_fallbacks(volume, :max_size_lvm)

        # But for the current volume being calculated, it is expected to fallback to
        # max_size when there is no max_size_lvm
        value += volume.max_size if volume.max_size_lvm.zero?
        value
      end

      # Adjusts btrfs values according to settings
      #
      # @param planned_device [Planned::Device]
      # @param volume [VolumeSpecification]
      def adjust_btrfs(planned_device, volume)
        return unless planned_device.btrfs?

        planned_device.default_subvolume = volume.btrfs_default_subvolume || ""
        planned_device.subvolumes = volume.subvolumes
        planned_device.snapshots = volume.snapshots
        planned_device.read_only = volume.btrfs_read_only?

        adjust_btrfs_sizes(planned_device, volume) if planned_device.snapshots?
      end

      # Adjusts sizes when using snapshots
      #
      # @param planned_device [Planned::Device]
      # @param volume [VolumeSpecification]
      def adjust_btrfs_sizes(planned_device, volume)
        return if volume.ignore_snapshots_sizes?

        if volume.snapshots_size > DiskSize.zero
          planned_device.min_size += volume.snapshots_size
          planned_device.max_size += volume.snapshots_size
        elsif volume.snapshots_percentage > 0
          multiplicator = 1.0 + (volume.snapshots_percentage / 100.0)
          planned_device.min_size *= multiplicator
          planned_device.max_size *= multiplicator
        end
      end

      # Adjusts the disk restrictions according to settings
      #
      # @param planned_device [Planned::Device]
      # @param volume [VolumeSpecification]
      def adjust_device(planned_device, volume)
        planned_device.disk = volume.device if planned_device.respond_to?(:disk=)
        return unless settings.allocate_mode?(:auto) && planned_device.root?

        # Forcing this when planned_device is a LV would imply the new VG can only be located
        # in that disk (preventing it to spread over several disks). We likely don't want that.
        planned_device.disk ||= settings.root_device if planned_device.is_a?(Planned::Partition)
      end

      # Calculates the value for a specific attribute taking into
      # account the fallback values
      #
      # @param volume [VolumeSpecification]
      # @param attr [Symbol, String]
      def value_with_fallbacks(volume, attr)
        value = volume.send(attr)
        return value if volume.ignore_fallback_sizes?

        volumes = volumes_with_fallback(volume.mount_point, attr)
        return value if volumes.empty?

        volumes.inject(value) { |total, v| total + v.send(attr) }
      end

      # Searches for volume specifications that have fallback value
      # for a specific mount point
      #
      # @param mount_point [String]
      # @param attr [Symbol, String]
      #
      # @return [Array<VolumeSpecification>]
      def volumes_with_fallback(mount_point, attr)
        not_proposed_volumes.select { |v| v.send("fallback_for_#{attr}") == mount_point }
      end

      # Searches for not proposed volume specifications
      # @return [Array<VolumeSpecification>]
      def not_proposed_volumes
        settings.volumes.reject(&:proposed?)
      end
    end
  end
end