yast/yast-storage-ng

View on GitHub
src/lib/y2storage/proposal_settings.rb

Summary

Maintainability
A
0 mins
Test Coverage
# Copyright (c) [2015-2023] 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 "yast"
require "forwardable"
require "y2storage/disk_size"
require "y2storage/secret_attributes"
require "y2storage/volume_specification"
require "y2storage/subvol_specification"
require "y2storage/filesystems/type"
require "y2storage/partitioning_features"
require "y2storage/volume_specifications_set"
require "y2storage/encryption_method"
require "y2storage/equal_by_instance_variables"
require "y2storage/proposal_space_settings"
require "y2storage/storage_env"

module Y2Storage
  # Class to manage settings used by the proposal (typically read from control.xml)
  #
  # When a new object is created, all settings are nil or [] in case of a list is
  # expected. See {#for_current_product} to initialize settings with some values.
  class ProposalSettings
    include SecretAttributes
    include PartitioningFeatures
    include EqualByInstanceVariables
    extend Forwardable

    # @return [Boolean] whether to use LVM
    attr_accessor :use_lvm

    # Whether the volumes specifying a separate_vg_name should
    # be indeed created as separate volume groups
    #
    # @return [Boolean] if false, all volumes will be treated equally (no
    #   special handling resulting in separate volume groups)
    attr_accessor :separate_vgs

    # Mode to use when allocating the volumes in the available devices
    #
    # @return [:auto, :device] :auto by default
    attr_accessor :allocate_volume_mode

    # Whether the initial proposal should use a multidisk approach
    #
    # @return [Boolean] if true, the initial proposal will be tried using all
    #   available candidate devices.
    attr_accessor :multidisk_first

    # Criteria to use if it is possible to reuse an existing swap partition
    #
    #   * :any reuse a suitable swap partition from any disk, default historical behavior of YaST
    #   * :none do not reuse existing swap partitions
    #   * :candidate reuse a suitable partition only if it is located in a candidate disk
    #
    # @return [:any, :none, :candidate]
    attr_accessor :swap_reuse

    # Device name of the disk in which '/' must be placed.
    #
    # If it's set to nil and {#allocate_volume_mode} is :auto, the proposal will try
    # to find a good candidate
    #
    # @return [String, nil]
    def root_device
      if allocate_mode?(:device)
        root_volume ? root_volume.device : nil
      else
        @explicit_root_device
      end
    end

    # Sets {#root_device}
    #
    # If {#allocate_volume_mode} is :auto, this simply sets the value of the
    # attribute.
    #
    # If {#allocate_volume_mode} is :device this changes the value of
    # {VolumeSpecification#device} for the root volume and all its associated
    # volumes.
    def root_device=(name)
      @explicit_root_device = name

      return unless allocate_mode?(:device) && name

      root_set = volumes_sets.find(&:root?)
      root_set.device = name if root_set
    end

    # Most recent value of {#root_device} that was set via a call to the
    # {#root_device=} setter
    #
    # For settings with {#allocate_volume_mode} :auto, this is basically
    # equivalent to {#root_device}, but for settings with allocate mode :device,
    # the value of {#root_device} is usually a consequence of the status of the
    # {#volumes}. This method helps to identify the exception in which the root
    # device has been forced via the setter.
    #
    # @return [String, nil]
    attr_reader :explicit_root_device

    # Device names of the disks that can be used for the installation. If nil,
    # the proposal will try find suitable devices
    #
    # @return [Array<String>, nil]
    def candidate_devices
      if allocate_mode?(:device)
        # If any of the proposed volumes has no device assigned, the whole list
        # is invalid
        return nil if volumes.select(&:proposed).any? { |vol| vol.device.nil? }

        volumes.map(&:device).compact.uniq
      else
        @explicit_candidate_devices
      end
    end

    # Sets {#candidate_devices}
    #
    # If {#allocate_volume_mode} is :auto, this simply sets the value of the
    # attribute.
    #
    # If {#allocate_volume_mode} is :device this changes the value of
    # {VolumeSpecification#device} for all volumes using elements from the given
    # list.
    def candidate_devices=(devices)
      @explicit_candidate_devices = devices

      return unless allocate_mode?(:device)

      if devices.nil?
        volumes.each { |vol| vol.device = nil }
      else
        volumes_sets.select(&:proposed?).each_with_index do |set, idx|
          set.device = devices[idx] || devices.last
        end
      end
    end

    # Most recent value of {#candidate_devices} that was set via a call to the
    # {#candidate_devices=} setter
    #
    # For settings with {#allocate_volume_mode} :auto, this is basically
    # equivalent to {#candidate_devices}, but for settings with allocate mode
    # :device, the value of {#candidate_devices} is usually a consequence of the
    # status of the {#volumes}. This method helps to identify the exception in
    # which the list of devices has been forced via the setter.
    #
    # @return [Array<String>, nil]
    attr_reader :explicit_candidate_devices

    # TODO: it makes sense to encapsulate #encryption_password, #encryption_method and
    # #encryption_pbkdf in some new class (eg. EncryptionSettings), posponed for now

    # @!attribute encryption_password
    #   @return [String] password to use when creating new encryption devices
    secret_attr :encryption_password

    # Encryption method to use if {#encryption_password} is set
    #
    # @return [EncryptionMethod::Base]
    attr_accessor :encryption_method

    # PBKDF to use if {#encryption_password} is set and {#encryption_method} is LUKS2
    #
    # @return [PbkdFunction, nil] nil to use the default
    attr_accessor :encryption_pbkdf

    # When the user decides to use LVM, strategy to decide the size of the volume
    # group (and, thus, the number and size of created physical volumes).
    #
    # Options:
    #
    # * :use_available The VG will be created to use all the available space, thus the
    #   VG size could be greater than the sum of LVs sizes.
    # * :use_needed The created VG will match the requirements 1:1, so its size will be
    #   exactly the sum of all the LVs sizes.
    # * :use_vg_size The VG will have a predefined size, that could be greater than the
    #   LVs sizes.
    #
    # @return [Symbol] :use_available, :use_needed or :use_vg_size
    attr_reader :lvm_vg_strategy

    # @return [DiskSize] if :use_vg_size is specified in the previous option, this will
    #   specify the predefined size of the LVM volume group.
    attr_accessor :lvm_vg_size

    # @return [Boolean] whether a pre-existing LVM volume group should be reused if
    #   the conditions to do so are met. That is the historical YaST behavior, which
    #   can be inhibited by setting this to false.
    attr_accessor :lvm_vg_reuse

    # @return [Array<VolumeSpecification>] list of volumes specifications used during
    #   the proposal
    attr_accessor :volumes

    alias_method :lvm, :use_lvm
    alias_method :lvm=, :use_lvm=

    # @return [Boolean] whether the proposal should automatically configure any partition
    #   possibly needed for booting the system.
    attr_accessor :boot

    # @return [ProposalSpaceSettings]
    attr_reader :space_settings

    # Constructor
    def initialize
      @space_settings = ProposalSpaceSettings.new
    end

    # Volumes grouped by their location in the disks.
    #
    # This method is only useful when #allocate_volume_mode is set to
    # :device. All the volumes that must be allocated in the same disk
    # are grouped in a single {VolumeSpecificationsSet} object.
    #
    # The sorting of {#volumes} is honored as long as possible
    #
    # @return [Array<VolumeSpecificationsSet>]
    def volumes_sets
      separate_vgs ? vol_sets_with_separate : vol_sets_plain
    end

    # New object initialized according to the YaST product features (i.e. /control.xml)
    # @return [ProposalSettings]
    def self.new_for_current_product
      settings = new
      settings.for_current_product
      settings
    end

    # Set settings according to the current product
    def for_current_product
      apply_defaults
      load_features
      apply_user_enforced
    end

    # Produces a deep copy of settings
    #
    # @return [ProposalSettings]
    def deep_copy
      Marshal.load(Marshal.dump(self))
    end

    # Whether encryption must be used
    # @return [Boolean]
    def use_encryption
      !encryption_password.nil?
    end

    def_delegators :@space_settings,
      :windows_delete_mode, :linux_delete_mode, :other_delete_mode, :resize_windows,
      :resize_windows=, :delete_resize_configurable, :delete_resize_configurable=,
      :delete_forbidden, :delete_forbidden?, :delete_forced, :delete_forced?

    # @!attribute windows_delete_mode
    #   @see ProposalSpaceSettings

    # @!attribute linux_delete_mode
    #   @see ProposalSpaceSettings

    # @!attribute other_delete_mode
    #   @see ProposalSpaceSettings

    # @!attribute resize_windows
    #   @see ProposalSpaceSettings

    # @!attribute delete_resize_configurable
    #   @see ProposalSpaceSettings

    # @!method delete_forbidden
    #   @see ProposalSpaceSettings

    # @!method delete_forbidden?
    #   @see ProposalSpaceSettings

    # @!method delete_forced
    #   @see ProposalSpaceSettings

    # @!method delete_forced?
    #   @see ProposalSpaceSettings

    def windows_delete_mode=(mode)
      space_settings.windows_delete_mode = validated_delete_mode(mode)
    end

    def linux_delete_mode=(mode)
      space_settings.linux_delete_mode = validated_delete_mode(mode)
    end

    def other_delete_mode=(mode)
      space_settings.other_delete_mode = validated_delete_mode(mode)
    end

    def lvm_vg_strategy=(strategy)
      @lvm_vg_strategy = validated_lvm_vg_strategy(strategy)
    end

    # List of all the supported settings
    SETTINGS = [
      :multidisk_first, :root_device, :explicit_root_device,
      :candidate_devices, :explicit_candidate_devices, :boot,
      :windows_delete_mode, :linux_delete_mode, :other_delete_mode, :resize_windows,
      :delete_resize_configurable,
      :lvm, :separate_vgs, :allocate_volume_mode, :lvm_vg_strategy, :lvm_vg_size
    ].freeze
    private_constant :SETTINGS

    def to_s
      "Storage ProposalSettings\n" \
      "  proposal:\n" +
        SETTINGS.map { |s| "    #{s}: #{send(s)}\n" }.join +
        "  volumes:\n" \
        "    #{volumes}"
    end

    # Check whether using btrfs filesystem with snapshots for root
    #
    # @return [Boolean]
    def snapshots_active?
      root_volume.nil? ? false : root_volume.snapshots?
    end

    # Forces to enable snapshots for the root subvolume
    #
    # After calling this method, snapshots will not be configurable.
    def force_enable_snapshots
      return unless root_volume

      root_volume.snapshots = true
      root_volume.snapshots_configurable = false
    end

    # Forces to disable snapshots for the root subvolume
    #
    # After calling this method, snapshots will not be configurable.
    def force_disable_snapshots
      return unless root_volume

      root_volume.snapshots = false
      root_volume.snapshots_configurable = false
    end

    # Checks the value of {#allocate_volume_mode}
    #
    # @return [Boolean]
    def allocate_mode?(mode)
      allocate_volume_mode == mode
    end

    # Whether the value of {#separate_vgs} is relevant
    #
    # The mentioned setting only makes sense when there is at least one volume
    # specification at {#volumes} which contains a separate VG name.
    #
    # @return [Boolean]
    def separate_vgs_relevant?
      volumes.any?(&:separate_vg_name)
    end

    private

    # Volume specification for the root filesystem
    #
    # @return [VolumeSpecification]
    def root_volume
      volumes.find(&:root?)
    end

    # List of possible VG strategies.
    # TODO: enum?
    LVM_VG_STRATEGIES = [:use_available, :use_needed, :use_vg_size]
    private_constant :LVM_VG_STRATEGIES

    # Defaults when a setting is not specified
    DEFAULTS = {
      allocate_volume_mode:       :auto,
      boot:                       true,
      delete_resize_configurable: true,
      linux_delete_mode:          :ondemand,
      lvm:                        false,
      lvm_vg_strategy:            :use_available,
      lvm_vg_reuse:               true,
      encryption_method:          EncryptionMethod::LUKS1,
      multidisk_first:            false,
      other_delete_mode:          :ondemand,
      resize_windows:             true,
      separate_vgs:               false,
      swap_reuse:                 :any,
      volumes:                    [],
      windows_delete_mode:        :ondemand
    }
    private_constant :DEFAULTS

    # Sets default values for the settings.
    # These will be the final values when the setting is not specified in the control file
    def apply_defaults
      DEFAULTS.each do |key, value|
        send(:"#{key}=", value) if send(key).nil?
      end
    end

    # Some values can be explicitly enforced by user
    # This setting should have precendence over everything else
    def apply_user_enforced
      value = StorageEnv.instance.requested_lvm_reuse

      send(:lvm_vg_reuse=, StorageEnv.instance.requested_lvm_reuse) if !value.nil?
    end

    # Overrides the settings with values read from the YaST product features
    # (i.e. values in /control.xml).
    #
    # Settings omitted in the product features are not modified.
    def load_features
      load_feature(:proposal, :lvm)
      load_feature(:proposal, :separate_vgs)
      load_feature(:proposal, :resize_windows)
      load_feature(:proposal, :windows_delete_mode)
      load_feature(:proposal, :linux_delete_mode)
      load_feature(:proposal, :other_delete_mode)
      load_feature(:proposal, :delete_resize_configurable)
      load_feature(:proposal, :lvm_vg_strategy)
      load_feature(:proposal, :allocate_volume_mode)
      load_feature(:proposal, :multidisk_first)
      load_size_feature(:proposal, :lvm_vg_size)
      load_volumes_feature(:volumes)
      load_encryption
    end

    # Loads the default encryption settings
    #
    # The encryption settings are not part of control.xml, but can be injected by a previous step of
    # the installation, eg. the dialog of the Common Criteria system role
    def load_encryption
      enc = feature(:proposal, :encryption)

      return unless enc
      return unless enc.respond_to?(:password)

      passwd = enc.password.to_s
      return if passwd.nil? || passwd.empty?

      self.encryption_password = passwd
    end

    def validated_delete_mode(mode)
      validated_feature_value(mode, ProposalSpaceSettings.delete_modes)
    end

    def validated_lvm_vg_strategy(strategy)
      validated_feature_value(strategy, LVM_VG_STRATEGIES)
    end

    def validated_feature_value(value, valid_values)
      raise ArgumentError, "Invalid feature value: #{value}" unless value

      result = value.to_sym
      raise ArgumentError, "Invalid feature value: #{value}" if !valid_values.include?(result)

      result
    end

    # Implementation for {#volumes_sets} when {#separate_vgs} is set to false
    #
    # @see #volumes_sets
    #
    # @return [Array<VolumeSpecificationsSet]
    def vol_sets_plain
      if lvm
        [VolumeSpecificationsSet.new(volumes.dup, :lvm)]
      else
        volumes.map { |vol| VolumeSpecificationsSet.new([vol], :partition) }
      end
    end

    # Implementation for {#volumes_sets} when {#separate_vgs} is set to true
    #
    # @see #volumes_sets
    #
    # @return [Array<VolumeSpecificationsSet]
    def vol_sets_with_separate
      sets = []

      volumes.each do |vol|
        if vol.separate_vg_name
          # There should not be two volumes with the same separate_vg_name. But
          # just in case, let's group them if that happens.
          group = sets.find { |s| s.vg_name == vol.separate_vg_name }
          type = :separate_lvm
        elsif lvm
          group = sets.find { |s| s.type == :lvm }
          type = :lvm
        else
          group = nil
          type = :partition
        end

        if group
          group.push(vol)
        else
          sets << VolumeSpecificationsSet.new([vol], type)
        end
      end

      sets
    end
  end
end