src/lib/y2storage/guided_proposal.rb
# 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 "yast"
require "y2storage/proposal"
require "y2storage/proposal_settings"
require "y2storage/exceptions"
module Y2Storage
# Class to calculate a storage proposal to install the system
#
# @example
# proposal = Y2Storage::GuidedProposal.new
# proposal.settings.use_separate_home = true
# proposal.proposed? # => false
# proposal.devices # => nil
# proposal.planned_devices # => nil
#
# proposal.propose # Performs the calculation
#
# proposal.proposed? # => true
# proposal.devices # => Proposed layout
# proposal.settings.use_separate_home = false # raises RuntimeError
class GuidedProposal < Proposal::Base
# @overload settings
#
# Settings for calculating the proposal.
#
# @note The settings cannot be modified once the proposal has been calculated
#
# @return [ProposalSettings]
attr_reader :settings
class << self
# Calculates the initial proposal
#
# If a proposal is not possible by honoring current settings, other settings
# are tried. For example, a proposal without separate home or without snapshots
# will be calculated.
#
# @see InitialGuidedProposal
# @see #initialize
#
# @param settings [ProposalSettings]
# @param devicegraph [Devicegraph]
# @param disk_analyzer [DiskAnalyzer]
#
# @return [InitialGuidedProposal]
def initial(settings: nil, devicegraph: nil, disk_analyzer: nil)
proposal = InitialGuidedProposal.new(
settings: settings,
devicegraph: devicegraph,
disk_analyzer: disk_analyzer
)
proposal.propose
proposal
rescue Y2Storage::Error
log.error("Initial proposal failed")
proposal
end
end
# Constructor
#
# @param settings [ProposalSettings] if nil, default settings will be used
# @param devicegraph [Devicegraph] starting point. If nil, the probed devicegraph
# will be used.
# @param disk_analyzer [DiskAnalyzer] by default, the method will create a new one
# based on the initial {devicegraph} or will use the one in {StorageManager} if
# starting from probed (i.e. {devicegraph} argument is also missing).
def initialize(settings: nil, devicegraph: nil, disk_analyzer: nil)
super(devicegraph: devicegraph, disk_analyzer: disk_analyzer)
@settings = settings || ProposalSettings.new_for_current_product
end
private
# @return [ProposalSettings]
attr_writer :settings
# Calculates the proposal
#
# @see #try_proposal
#
# @raise [Error, NoDiskSpaceError] if there is no enough space to perform the installation
#
# @return [true]
def calculate_proposal
try_proposal
ensure
settings.freeze
end
# Tries to perform a proposal
#
# Settings might be completed with default values for candidate devices and root device.
#
# This method is intended to be redefined for derived classes, see {InitialGuidedProposal}.
#
# @raise [Error, NoDiskSpaceError] if it was not possible to calculate the proposal
#
# @return [true]
def try_proposal
complete_settings
try_with_each_target_size
end
# Generic method that tries to execute a block on each element of a
# collection, returning the first successful result
#
# If a {Y2Storage::Error} exception is raised, it tries with the
# next element of the collection. It returns the result of the first
# execution of the passed block that succeeds (i.e. that does not raise an
# Error exception).
#
# @raise [Exception] when the block fails for all elements in the collection
#
# @param iterator [#each] collection to iterate
# @param error_proc [Proc, nil] optional code to execute before trying the next
# item when an exception is raised
def try_with_each(iterator, error_proc: nil)
error = default_proposal_error
iterator.each do |item|
return yield(item)
rescue Error => e
error_proc&.call(e, item)
next
end
raise error
end
# Helper method to do a proposal attempt for each possible target size
#
# @see #target_sizes
#
# @raise [Error, NoDiskSpaceError] if it was not possible to calculate the proposal
#
# @return [true]
def try_with_each_target_size
log_error = proc do |e, target_size|
log.info "Failed to make a proposal with target size: #{target_size}"
log.info "Error: #{e.message}"
end
try_with_each(target_sizes, error_proc: log_error) do |target_size|
try_with_target(target_size)
end
end
# Helper method to do a proposal attempt with the given target size
#
# @raise [Error, NoDiskSpaceError] if it was not possible to calculate the proposal
#
# @param target_size [Symbol] see {#target_sizes}
# @return [true]
def try_with_target(target_size)
log.info "Trying to make a proposal with target size: #{target_size}\n" \
"using the following settings:\n#{settings}"
# Calculate the planned devices even before checking #useless_volumes_sets?
# because they can contain useful information
@planned_devices = initial_planned_devices(target_size)
raise Error if useless_volumes_sets?
@devices = devicegraph(target_size)
true
end
# All possible target sizes to make the proposal
#
# @return [Array<Symbol>]
def target_sizes
[:desired, :min]
end
# Default error when it is not possible to create a proposal
#
# @return [NoDiskSpaceError]
def default_proposal_error
NoDiskSpaceError.new("No usable disks detected")
end
# Completes the current settings with reasonable fallback values
#
# All settings coming from the control file have a fallback value, but there are some
# settings that are only given by the user, for example: candidate_devices and
# root_device. For those settings, some reasonable fallback values are given.
def complete_settings
settings.candidate_devices ||= candidate_devices
settings.root_device ||= candidate_devices.first
end
# @return [Array<Planned::Device>]
def initial_planned_devices(target)
planner = Proposal::DevicesPlanner.new(settings)
planner.volumes_planned_devices(target, initial_devicegraph)
end
# Devicegraph resulting of accommodating the planned devices and the boot-related
# partitions in the initial devicegraph
#
# Note this method modifies the list of planned devices to add partitions needed for
# booting and to reuse existing swap devices if possible.
#
# @param target_size [Symbol] see {#target_sizes}
# @return [Devicegraph]
def devicegraph(target_size)
planner = Proposal::DevicesPlanner.new(settings)
planner.add_boot_devices(@planned_devices, target_size, clean_graph)
swap = Proposal::SwapReusePlanner.new(settings, clean_graph)
swap.adjust_devices(@planned_devices)
graph_generator.devicegraph(planned_devices, clean_graph)
end
def graph_generator
@graph_generator ||= Proposal::DevicegraphGenerator.new(settings, disk_analyzer)
end
# Copy of #initial_devicegraph without all the partitions that must be wiped out
# according to the settings. Empty partition tables are deleted from candidate
# devices.
#
# @return [Y2Storage::Devicegraph]
def clean_graph
return @clean_graph if @clean_graph
new_devicegraph = initial_devicegraph.dup
# TODO: remember the list of affected devices so we can restore their partition tables at
# the end of the process for those devices that were not used (as soon as libstorage-ng
# allows us to copy sub-graphs).
remove_empty_partition_tables(new_devicegraph)
@clean_graph = graph_generator.prepared(@planned_devices, new_devicegraph)
end
# Removes partition tables from candidate devices with empty partition table
#
# @note The devicegraph is modified.
#
# @param devicegraph [Y2Storage::Devicegraph]
# @return [Array<Integer>] sid of devices where partition table was deleted from
def remove_empty_partition_tables(devicegraph)
devices = candidate_devices_with_empty_partition_table(devicegraph)
devices.each(&:delete_partition_table)
devices.map(&:sid)
end
# All candidate devices with an empty partition table
#
# @param devicegraph [Y2Storage::Devicegraph]
# @return [Array<Y2Storage::BlkDevice>]
def candidate_devices_with_empty_partition_table(devicegraph)
device_names = settings.candidate_devices
devices = device_names.map { |n| devicegraph.find_by_name(n) }.compact
devices.select { |d| d.partition_table && d.partitions.empty? }
end
# Candidate devices to make a proposal
#
# The candidate devices are calculated when current settings do not contain any
# candidate device. See {#fallback_candidates}
#
# @return [Array<String>] e.g. ["/dev/sda", "/dev/sdc"]
def candidate_devices
settings.candidate_devices || fallback_candidates
end
# Candidate devices to make a proposal, full version
#
# Unlike {#candidate_devices}, that only returns a list of device names, this method
# returns a list of proper full-featured objects representing those devices.
#
# @return [Array<BlkDevice>]
def candidate_objects
candidate_devices.map { |n| initial_devicegraph.find_by_name(n) }.compact
end
# Candidate devices to use when the current settings do not specify any, i.e. in the initial
# attempt, before the user has had any opportunity to select the candidate devices
#
# The possible candidate devices are sorted, placing boot-optimized devices at the beginning and
# removable devices (like USB) at the end.
#
# @return [Array<String>] e.g. ["/dev/sda", "/dev/sdc"]
def fallback_candidates
# NOTE: sort_by it is not being used here because "the result is not guaranteed to be stable"
# see https://ruby-doc.org/core-2.5.0/Enumerable.html#method-i-sort_by
# In addition, a partition makes more sense here since we only are "grouping" available disks
# in three groups and arranging those groups.
candidates = disk_analyzer.candidate_disks
high_prio, rest = candidates.partition(&:boss?)
low_prio, rest = rest.partition { |d| maybe_removable?(d) }
candidates = high_prio + rest + low_prio
candidates.first(fallback_candidates_size).map(&:name)
end
# Number of disks to be included in {#fallback_candidates}
#
# Without this limit, the process to find the optimal layout can be very slow
# specially if LVM is enabled by default for the product. See bsc#1154070.
#
# @return [Integer]
def fallback_candidates_size
# Reasonable value set after research in bsc#1154070. Anyways, users with more disks will
# very likely dismiss the initial proposal and use the Guided Setup or the Expert Partitioner.
# Trying with more combinations of disks is often just wasting time.
disks = 5
return disks unless settings.allocate_mode?(:device)
[disks, proposed_volumes_sets.size].max
end
# Whether the given device is potentially a removable disk
#
# It's not always possible to detect whether a given device is physically removable or not (eg.
# a fixed device may be connected to the USB bus or an SD card may be internal), but this
# returns true if the device is suspicious enough so it's better to avoid it in the automatic
# proposal if possible.
#
# @param device [BlkDevice]
# @return [boolean]
def maybe_removable?(device)
return true if dev_is?(device, :sd_card?)
return true if dev_is?(device, :usb?)
return true if dev_is?(device, :firewire?)
false
end
# Checks whether the given device returns true for the given method
#
# @see #maybe_removable?
#
# @param device [BlkDevice]
# @param method [Symbol]
# @return [boolean]
def dev_is?(device, method)
return false unless device.respond_to?(method)
device.public_send(method)
end
# All proposed volumes sets from the settings
#
# @return [Array<VolumeSpecificationsSet>]
def proposed_volumes_sets
settings.volumes_sets.select(&:proposed?)
end
# Checks whether the current distribution of volumes sets into disks make any sense
#
# NOTE: This method is only intended to make a quick evaluation to early discard
# a combination of disks. A false result doesn't imply the proposal will succeed.
#
# @return [Boolean] true if there is some disk that is supposed to allocate volumes
# that are bigger than the size of the disk
def useless_volumes_sets?
candidate_objects.any? do |disk|
total = DiskSize.sum(proposed_volumes_sets.select { |s| s.device == disk.name }.map(&:min_size))
# We use ">=" because the whole space of the disk can never be used to allocate
# the volumes (the partition table takes space)
if total >= disk.size
log.info "Discarded combination of volumes sets for #{disk.name} (#{total} >= #{disk.size})"
true
end
end
end
end
end