diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73cbb09bfa..6f362641c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: - name: Git Checkout uses: actions/checkout@v2 - + - name: Install Ruby development files run: zypper --non-interactive install gcc gcc-c++ make openssl-devel ruby-devel npm augeas-devel @@ -117,6 +117,37 @@ jobs: - name: Rubocop run: /usr/bin/rubocop.*-1.24.1 + backend_doc: + runs-on: ubuntu-latest + env: + COVERAGE: 1 + + defaults: + run: + working-directory: ./service + + strategy: + fail-fast: false + matrix: + distro: [ "tumbleweed" ] + + container: + image: registry.opensuse.org/yast/head/containers_${{matrix.distro}}/yast-ruby + + steps: + + - name: Git Checkout + uses: actions/checkout@v2 + + - name: Install Ruby development files + run: zypper --non-interactive install gcc gcc-c++ make openssl-devel ruby-devel npm augeas-devel + + - name: Install RubyGems dependencies + run: bundle config set --local with 'development' && bundle install + + - name: Generate doc + run: bundle exec yardoc --fail-on-warning + cli_tests: runs-on: ubuntu-latest env: diff --git a/doc/dbus_api.md b/doc/dbus_api.md index 71c078de53..b322902fd4 100644 --- a/doc/dbus_api.md +++ b/doc/dbus_api.md @@ -113,93 +113,65 @@ Iface: o.o.YaST.Installer1.Software ## Storage -### Iface: o.o.YaST.Installer1.Storage +### org.opensuse.DInstaller.Storage1 -#### methods: - -- MarkForUse(array(o.o.YaST.Installer1.Storage.BlockDevice Device)) -> void - set objects for use of installation. it means erase content of that devices - example: - - MarkForUse([disk1,disk2partition2]) -> () - -- MarkForShrinking(array(o.o.YaST.Installer1.Storage.BlockDevice Device)) -> void - set objects to allow shrink of them. it means keep content and reduce its free space. - example: +#### Methods - MarkForShrink([disk1,disk2partition2]) -> () +- Probe -> void -#### Properties (all read only): +- Install -> void -- Drives -> array(o.o.YaST.Installer1.Storage.Drive) # an object\_path whose object implements this interface - List of all disks. - Example: +- Finish -> void - Drives -> [disk1, disk2] - -- Partitions -> array(o.o.YaST.Installer1.Storage.Partition) - List of all partitions. - Example: - - Disks -> [disk1partition1, disk1partition2, disk2partition1] - -- DevicesToUse -> array(o.o.YaST.Installer1.Storage.BlockDevice) - Devices that will be fully used by installation - Example: - - DevicesToUse -> [disk1,disk2partition2] - -- DevicesToShrink -> array(o.o.YaST.Installer1.Storage.BlockDevice) - Devices that will be shrinked to make space for installation - Example: - - DevicesToShrink -> [disk1,disk2partition2] - -#### Signals: +### org.opensuse.DInstaller.Proposal1 - PropertiesChanged ( only standard one from org.freedesktop.DBus.Properties interface ) +** Making space is not covered yet** -### Iface: o.o.YaST.Installer1.Storage.BlockDevice - -Inspired by Udisks2.Block - -#### Properties (all read only): - -- Device -> string DevPath - Block device name in /dev like "/dev/sda" - -- Size -> uint64 SizeInBytes - Size of devices in bytes - -- ReadOnly -> boolean - if device is read only - -### Iface: o.o.YaST.Installer1.Storage.Drive - -Inspired by Udisks2.Drive - -#### Properties (all read only): - -- Vendor -> string - Vendor of device or empty string if not known like "Fujitsu" - -- Model -> string - Device model or empty string if not known - -- Removable -> boolean - if device is removable like usb sticks - -- Partitions -> array(o.o.YaST.Installer1.Storage.Partition) - partitions on given drive - -### Iface: o.o.YaST.Installer1.Storage.Partition - -Inspired by Udisks2.Partition +#### Properties -#### Properties (all read only): +- AvailableDevices -> a(ssa{sv}) (r) + e.g., ["/dev/sda", "/dev/sda, 8.00 GiB, USB", {}] + +- CandidateDevices -> as (r) + +- LVM -> b (r) + +- EncryptionPassword -> s (r) + +- VolumeTemplates -> aa{sv} (r) + Struct keys and values: see Volumes + +- Volumes -> aa{sv} (r) + Struct keys and values: + - DeviceType -> s + e.g., "partition", "lvm_lv" + - Optional -> b + - Encrypted -> b + - MountPoint -> s + - FixedSizeLimits -> b + - AdaptativeSizes -> b + - MinSize -> s + - MaxSize -> s + - FsTypes -> as + e.g., ["Btrfs", "XFS"] + - FsType -> s + - Snapshots -> b + - SnapshotsConfigurable -> b + - SnapshotsAffectSizes -> b + - VolumesWithFallbackSizes -> as + e.g., ["/home", "/var"] + +- Actions -> aa{sv} (r) + Struct keys and values: + - Text -> s (r) + - Subvol -> b (r) + - Delete -> b (r) + +#### Methods + +- Calculate(aa{sv}) -> u (0 success, 1 fail) + Calculates a new proposal with the given properties (see proposal properties). -- Drive -> o.o.YaST.Installer1.Storage.Drive - where partitions live ## Users diff --git a/service/.rubocop.yml b/service/.rubocop.yml index 5f5264cc41..e9f3db15aa 100644 --- a/service/.rubocop.yml +++ b/service/.rubocop.yml @@ -8,6 +8,7 @@ AllCops: Exclude: - vendor/**/* - lib/dinstaller/dbus/y2dir/**/* + - d-installer.gemspec # a D-Bus method definition may take up more line lenght than usual Layout/LineLength: diff --git a/service/Gemfile.lock b/service/Gemfile.lock index 14fa82859e..1c1d354159 100644 --- a/service/Gemfile.lock +++ b/service/Gemfile.lock @@ -60,6 +60,9 @@ GEM simplecov-html (0.12.3) simplecov-lcov (0.8.0) simplecov_json_formatter (0.1.4) + webrick (1.7.0) + yard (0.9.28) + webrick (~> 1.7.0) PLATFORMS ruby @@ -73,6 +76,7 @@ DEPENDENCIES rspec (~> 3.11.0) simplecov (~> 0.21.2) simplecov-lcov (~> 0.8.0) + yard (~> 0.9.0) BUNDLED WITH 2.3.3 diff --git a/service/bin/d-installer b/service/bin/d-installer index 1621a3ed0b..405d830cb1 100755 --- a/service/bin/d-installer +++ b/service/bin/d-installer @@ -54,7 +54,7 @@ end def start_service(name) general_y2dir = File.expand_path("../lib/dinstaller/dbus/y2dir", __dir__) module_y2dir = File.expand_path("../lib/dinstaller/dbus/y2dir/#{name}", __dir__) - ENV["Y2DIR"] = "#{module_y2dir}:#{general_y2dir}" + ENV["Y2DIR"] = [ENV["Y2DIR"], module_y2dir, general_y2dir].compact.join(":") service_runner = DInstaller::DBus::ServiceRunner.new(name, logger: logger_for(name)) service_runner.run diff --git a/service/d-installer.gemspec b/service/d-installer.gemspec index 9c89900876..802f3f1462 100644 --- a/service/d-installer.gemspec +++ b/service/d-installer.gemspec @@ -40,6 +40,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec", "~> 3.11.0" spec.add_development_dependency "simplecov", "~> 0.21.2" spec.add_development_dependency "simplecov-lcov", "~> 0.8.0" + spec.add_development_dependency "yard", "~>0.9.0" spec.add_dependency "cfa", "~> 1.0.2" spec.add_dependency "cfa_grub2", "~> 2.0.0" spec.add_dependency "cheetah", "~> 1.0.0" diff --git a/service/lib/dinstaller/cockpit_manager.rb b/service/lib/dinstaller/cockpit_manager.rb index f6c6c329a7..a71b83d42d 100644 --- a/service/lib/dinstaller/cockpit_manager.rb +++ b/service/lib/dinstaller/cockpit_manager.rb @@ -48,7 +48,6 @@ def initialize(path: DEFAULT_PATH, file_handler: nil) # # If the given section does not exist, it returns an empty one # - # @param name [String] section name # @return [AugeasTree] def web_service data["WebService"] ||= CFA::AugeasTree.new @@ -78,7 +77,7 @@ def initialize(logger, prefix: "/") # # If all arguments are nil, the configuration is not modified and the process is not restarted. # - # @param config [Hash] + # @param options [Hash] # @option ssl [Boolean,nil] SSL is enabled # @option ssl_cert [String,nil] SSL/TLS certificate URL # @option ssl_key [String,nil] SSL/TLS key URL diff --git a/service/lib/dinstaller/config.rb b/service/lib/dinstaller/config.rb index 23a73da9d7..3da9c7ec68 100644 --- a/service/lib/dinstaller/config.rb +++ b/service/lib/dinstaller/config.rb @@ -100,7 +100,7 @@ def copy # Returns a new {Config} with the merge of the given ones # - # @params config [Config, Hash] + # @param config [Config, Hash] # @return [Config] new Configuration with the merge of the given ones def merge(config) Config.new(simple_merge(data, config.data)) diff --git a/service/lib/dinstaller/config_reader.rb b/service/lib/dinstaller/config_reader.rb index 3cfcb9931a..583ff4f379 100644 --- a/service/lib/dinstaller/config_reader.rb +++ b/service/lib/dinstaller/config_reader.rb @@ -66,11 +66,11 @@ def config_from_file(path = nil) Config.from_file(path) end - # Return an {Array} with the different {Config} objects read from the different locations + # Return an arry with the different {Config} objects read from the different locations # # TODO: handle precedence correctly # - # @returm [Array] an array with all the configurations read from the system + # @return [Array] an array with all the configurations read from the system def configs return @configs if @configs diff --git a/service/lib/dinstaller/dbus/language_service.rb b/service/lib/dinstaller/dbus/language_service.rb index a72928a441..2f58b4145f 100644 --- a/service/lib/dinstaller/dbus/language_service.rb +++ b/service/lib/dinstaller/dbus/language_service.rb @@ -41,7 +41,7 @@ class LanguageService # @return [::DBus::Connection] attr_reader :bus - # @param config [Config] Configuration object + # @param _config [Config] Configuration object # @param logger [Logger] def initialize(_config, logger = nil) @logger = logger || Logger.new($stdout) diff --git a/service/lib/dinstaller/dbus/storage/manager.rb b/service/lib/dinstaller/dbus/storage/manager.rb index f03a882c95..5d43ad6e2b 100644 --- a/service/lib/dinstaller/dbus/storage/manager.rb +++ b/service/lib/dinstaller/dbus/storage/manager.rb @@ -39,7 +39,7 @@ class Manager < BaseObject # Constructor # - # @param backend [DInstaller::Software] + # @param backend [DInstaller::Storage::Manager] # @param logger [Logger] def initialize(backend, logger) super(PATH, logger: logger) diff --git a/service/lib/dinstaller/dbus/storage/proposal.rb b/service/lib/dinstaller/dbus/storage/proposal.rb index e5cced359a..17a4be441e 100644 --- a/service/lib/dinstaller/dbus/storage/proposal.rb +++ b/service/lib/dinstaller/dbus/storage/proposal.rb @@ -23,7 +23,8 @@ require "dinstaller/dbus/base_object" require "dinstaller/dbus/with_service_status" require "dinstaller/dbus/interfaces/service_status" -require "dinstaller/storage/proposal" +require "dinstaller/storage/proposal_settings" +require "dinstaller/storage/volume" module DInstaller module DBus @@ -52,66 +53,98 @@ def initialize(backend, logger) private_constant :STORAGE_PROPOSAL_INTERFACE dbus_interface STORAGE_PROPOSAL_INTERFACE do - dbus_reader :lvm, "b", dbus_name: "LVM" + dbus_reader :available_devices, "a(ssa{sv})" dbus_reader :candidate_devices, "as" - # The first string is the name of the device (as expected by #Calculate for - # the setting CandidateDevices), the second one is the label to represent that device in - # the UI when further information is needed. - # - # TODO: this representation is a temporary solution. In the future we should likely - # return more complex structures, probably with an interface similar to - # com.redhat.Blivet0.Device or org.freedesktop.UDisks2.Block. - dbus_reader :available_devices, "a(ssa{sv})" + dbus_reader :lvm, "b", dbus_name: "LVM" + + dbus_reader :encryption_password, "s" + + # @see {#to_dbus_volume} + dbus_reader :volume_templates, "aa{sv}" + + # @see {#to_dbus_volume} + dbus_reader :volumes, "aa{sv}" + + dbus_reader :actions, "aa{sv}" # result: 0 success; 1 error dbus_method :Calculate, "in settings:a{sv}, out result:u" do |settings| - success = busy_while do - backend.calculate(to_proposal_properties(settings)) - end + success = busy_while { calculate(settings) } success ? 0 : 1 end - - dbus_reader :actions, "aa{sv}" end # List of disks available for installation # - # Each device is represented by an array containing id and UI label. See the documentation - # of the available_devices DBus reader. + # Each device is represented by an array containing the name of the device (as expected by + # {#calculate} for the setting CandidateDevices), the second one is the label to represent + # that device in the UI when further information is needed. # - # @see DInstaller::Storage::Proposal - # - # @return [Array] + # @return [Array] def available_devices backend.available_devices.map do |dev| [dev.name, backend.device_label(dev), {}] end end - # @see DInstaller::Storage::Proposal + # Devices used by the storage proposal + # + # @return [Array] + def candidate_devices + return [] unless backend.settings + + backend.settings.candidate_devices + end + + # Whether the proposal creates logical volumes + # + # @return [Boolean] def lvm - backend.lvm? - rescue DInstaller::Storage::Proposal::NoProposalError - false + return false unless backend.settings + + backend.settings.use_lvm? end - # @see DInstaller::Storage::Proposal - def candidate_devices - backend.candidate_devices - rescue DInstaller::Storage::Proposal::NoProposalError - [] + # Whether the proposal encrypts devices + # + # @return [Boolean] + def encryption_password + return "" unless backend.settings + + backend.settings.encryption_password + end + + # Volumes used as template for creating a new volume + # + # @return [Hash] + def volume_templates + backend.volume_templates.map { |v| to_dbus_volume(v) } + end + + # Volumes used to calculate the storage proposal + # + # @return [Hash] + def volumes + backend.calculated_volumes.map { |v| to_dbus_volume(v) } end # List of sorted actions in D-Bus format # - # @see #to_dbus + # @see #to_dbus_action # # @return [Array] def actions - backend.actions.all.map { |a| action_to_dbus(a) } + backend.actions.map { |a| to_dbus_action(a) } + end + + # Calculates a new proposal + # + # @param dbus_settings [DInstaller::Storage::ProposalSettings] + def calculate(dbus_settings) + backend.calculate(to_proposal_settings(dbus_settings)) end private @@ -122,42 +155,114 @@ def actions # @return [Logger] attr_reader :logger - # Equivalence between properties names in D-Bus and backend. - PROPOSAL_PROPERTIES = { - "LVM" => "use_lvm", - "CandidateDevices" => "candidate_devices" + # Registers callback to be called when the proposal is calculated + def register_callbacks + backend.on_calculate do + properties = interfaces_and_properties[STORAGE_PROPOSAL_INTERFACE] + dbus_properties_changed(STORAGE_PROPOSAL_INTERFACE, properties, []) + end + end + + # Relationship between D-Bus settings and ProposalSettings + # + # For each D-Bus setting there is a list with the setter to use and the conversion from a + # D-Bus value to the value expected by the ProposalSettings setter. + SETTINGS_CONVERSIONS = { + "CandidateDevices" => ["candidate_devices=", proc { |v| v }], + "LVM" => ["use_lvm=", proc { |v| v }], + "EncryptionPassword" => ["encryption_password=", proc { |v| v }], + "Volumes" => ["volumes=", proc { |v, o| o.send(:to_proposal_volumes, v) }] }.freeze - private_constant :PROPOSAL_PROPERTIES + private_constant :SETTINGS_CONVERSIONS - # Registers callback to be called when properties change - def register_callbacks - backend.add_on_change_listener do - dbus_properties_changed(STORAGE_PROPOSAL_INTERFACE, { "LVM" => lvm, - "CandidateDevices" => candidate_devices, - "AvailableDevices" => available_devices, - "Actions" => actions }, []) + # Converts settings from D-Bus format to ProposalSettings + # + # @param dbus_settings [Hash] + # @return [DInstaller::Storage::ProposalSettings] + def to_proposal_settings(dbus_settings) + DInstaller::Storage::ProposalSettings.new.tap do |proposal_settings| + dbus_settings.each do |dbus_property, dbus_value| + setter, value_converter = SETTINGS_CONVERSIONS[dbus_property] + proposal_settings.public_send(setter, value_converter.call(dbus_value, self)) + end end end - # Converts settings from D-Bus to backend names + # Relationship between D-Bus volumes and Volumes + # + # For each D-Bus volume setting there is a list with the setter to use and the conversion + # from a D-Bus value to the value expected by the Volume setter. + VOLUME_CONVERSIONS = { + "MountPoint" => ["mount_point=", proc { |v| v }], + "DeviceType" => ["device_type=", proc { |v| v.to_sym }], + "Encrypted" => ["encrypted=", proc { |v| v }], + "FsType" => ["fs_type=", proc { |v, o| o.send(:to_fs_type, v) }], + "MinSize" => ["min_size=", proc { |v| Y2Storage::DiskSize.new(v) }], + "MaxSize" => ["max_size=", proc { |v| Y2Storage::DiskSize.new(v) }], + "FixedSizeLimits" => ["fixed_size_limits=", proc { |v| v }], + "Snapshots" => ["snapshots=", proc { |v| v }] + }.freeze + private_constant :VOLUME_CONVERSIONS + + # Converts volumes from D-Bus format to a list of Volumes # - # @example - # settings = { "LVM" => true, "CandidateDevices" => ["/dev/sda"] } - # to_proposal_settings(settings) #=> - # { "use_lvm" => true, "candidate_devices" => ["/dev/sda"] } + # @param dbus_volumes [Array] + # @return [Array] + def to_proposal_volumes(dbus_volumes) + dbus_volumes.map { |v| to_proposal_volume(v) } + end + + # Converts a volume from D-Bus format to Volume # - # @param settings [Hash] - def to_proposal_properties(settings) - settings.each_with_object({}) do |e, h| - h[PROPOSAL_PROPERTIES[e.first]] = e.last + # @param dbus_volume [Hash] + # @return [DInstaller::Storage::Volume] + def to_proposal_volume(dbus_volume) + DInstaller::Storage::Volume.new.tap do |volume| + dbus_volume.each do |dbus_property, dbus_value| + setter, value_converter = VOLUME_CONVERSIONS[dbus_property] + volume.public_send(setter, value_converter.call(dbus_value, self)) + end end end + # Converts a filesystem type from D-Bus format to a real filesystem type object + # + # @param dbus_fs_type [String] + # @return [Y2Storage::Filesystems::Type] + def to_fs_type(dbus_fs_type) + Y2Storage::Filesystems::Type.all.find { |t| t.to_human_string == dbus_fs_type } + end + + # Converts a Volume to D-Bus format + # + # @param volume {DInstaller::Storage::Volume} + # @return [Hash] + def to_dbus_volume(volume) + dbus_volume = { + "MountPoint" => volume.mount_point, + "Optional" => volume.optional, + "DeviceType" => volume.device_type.to_s, + "Encrypted" => volume.encrypted, + "FsTypes" => volume.fs_types.map(&:to_human_string), + "FsType" => volume.fs_type&.to_human_string, + "MinSize" => volume.min_size&.to_i, + "MaxSize" => volume.max_size&.to_i, + "FixedSizeLimits" => volume.fixed_size_limits, + "AdaptiveSizes" => volume.adaptive_sizes?, + "Snapshots" => volume.snapshots, + "SnapshotsConfigurable" => volume.snapshots_configurable, + "SnapshotsAffectSizes" => volume.snapshots_affect_sizes?, + "SizeRelevantVolumes" => volume.size_relevant_volumes + } + + dbus_volume.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? } + end + # Converts an action to D-Bus format # # @param action [Y2Storage::CompoundAction] # @return [Hash] - def action_to_dbus(action) + def to_dbus_action(action) { "Text" => action.sentence, "Subvol" => action.device_is?(:btrfs_subvolume), diff --git a/service/lib/dinstaller/dbus/users_service.rb b/service/lib/dinstaller/dbus/users_service.rb index 11cbcd68b4..7a79464010 100644 --- a/service/lib/dinstaller/dbus/users_service.rb +++ b/service/lib/dinstaller/dbus/users_service.rb @@ -41,7 +41,7 @@ class UsersService # @return [::DBus::Connection] attr_reader :bus - # @param config [Config] Configuration object + # @param _config [Config] Configuration object # @param logger [Logger] def initialize(_config, logger = nil) @logger = logger || Logger.new($stdout) diff --git a/service/lib/dinstaller/dbus/y2dir/modules/Autologin.rb b/service/lib/dinstaller/dbus/y2dir/modules/Autologin.rb index 19c78257e1..599323cccf 100644 --- a/service/lib/dinstaller/dbus/y2dir/modules/Autologin.rb +++ b/service/lib/dinstaller/dbus/y2dir/modules/Autologin.rb @@ -97,8 +97,8 @@ def Read end # Write autologin settings - # @param [Boolean] write_only when true, suseconfig script will not be run - # @return written anything? + # @param _write_only [Boolean] when true, suseconfig script will not be run + # @return [Boolean] def Write(_write_only) return false if !available || !@modified diff --git a/service/lib/dinstaller/progress.rb b/service/lib/dinstaller/progress.rb index ed2362e28a..3881bdadd8 100644 --- a/service/lib/dinstaller/progress.rb +++ b/service/lib/dinstaller/progress.rb @@ -75,7 +75,7 @@ def initialize(id, description) # Constructor # - # @param toal_steps [Integer] total number of steps + # @param total_steps [Integer] total number of steps def initialize(total_steps) @total_steps = total_steps @current_step = nil diff --git a/service/lib/dinstaller/questions_manager.rb b/service/lib/dinstaller/questions_manager.rb index a6a8802ece..ee64c92106 100644 --- a/service/lib/dinstaller/questions_manager.rb +++ b/service/lib/dinstaller/questions_manager.rb @@ -30,7 +30,7 @@ class QuestionsManager # Constructor # - # @params logger [Logger] + # @param logger [Logger] def initialize(logger) @logger = logger @questions = [] diff --git a/service/lib/dinstaller/service_status_recorder.rb b/service/lib/dinstaller/service_status_recorder.rb index 622ca7bafe..ceb937a8ec 100644 --- a/service/lib/dinstaller/service_status_recorder.rb +++ b/service/lib/dinstaller/service_status_recorder.rb @@ -32,8 +32,10 @@ def initialize # Saves the status of the given service and runs the callbacks if the status has changed # + # @see ServiceStatus + # # @param service_name [String] - # @param status [String] see {ServiceStatus} + # @param status [String] def save(service_name, status) return if @statuses[service_name] == status diff --git a/service/lib/dinstaller/storage/actions.rb b/service/lib/dinstaller/storage/actions.rb index 928d7111b1..8e22e0b4d9 100644 --- a/service/lib/dinstaller/storage/actions.rb +++ b/service/lib/dinstaller/storage/actions.rb @@ -26,8 +26,10 @@ module Storage # Backend class to get the list of actions over the storage devices class Actions # @param logger [Logger] - def initialize(logger) + # param actiongraph [Y2Storage::Actiongraph] + def initialize(logger, actiongraph) @logger = logger + @actiongraph = actiongraph end # All actions properly sorted @@ -39,9 +41,12 @@ def all private - # @param [Logger] + # @return [Logger] attr_reader :logger + # @return [Y2Storage::Actiongraph] + attr_reader :actiongraph + # Sorted main actions (everything except subvolume actions) # # @return [Array] @@ -62,10 +67,6 @@ def subvolume_actions # # @return [Array] def actions - actiongraph = Y2Storage::StorageManager.instance.staging.actiongraph - - return [] unless actiongraph - actiongraph.compound_actions end diff --git a/service/lib/dinstaller/storage/callbacks/activate_luks.rb b/service/lib/dinstaller/storage/callbacks/activate_luks.rb index 2110c5040c..3cf7664ee6 100644 --- a/service/lib/dinstaller/storage/callbacks/activate_luks.rb +++ b/service/lib/dinstaller/storage/callbacks/activate_luks.rb @@ -82,7 +82,7 @@ def question(info, attempt) # Generates a formatted representation of the size # - # @param size [Y2Storage::DiskSize] + # @param value [Y2Storage::DiskSize] # @return [String] def formatted_size(value) Y2Storage::DiskSize.new(value).to_human_string diff --git a/service/lib/dinstaller/storage/manager.rb b/service/lib/dinstaller/storage/manager.rb index 14c97942ad..613c900a04 100644 --- a/service/lib/dinstaller/storage/manager.rb +++ b/service/lib/dinstaller/storage/manager.rb @@ -74,7 +74,7 @@ def install end end - # Umounts the target file system + # Unmounts the target file system def finish start_progress(3) @@ -83,7 +83,7 @@ def finish progress.step("Installing bootloader") do ::Bootloader::FinishClient.new.write end - progress.step("Umounting storage devices") do + progress.step("Unmounting storage devices") do Yast::WFM.CallFunction("umount_finish", ["Write"]) end end diff --git a/service/lib/dinstaller/storage/proposal.rb b/service/lib/dinstaller/storage/proposal.rb index f98758af58..cc605e30ff 100644 --- a/service/lib/dinstaller/storage/proposal.rb +++ b/service/lib/dinstaller/storage/proposal.rb @@ -19,36 +19,48 @@ # 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/guided_proposal" -require "y2storage/proposal_settings" +require "y2storage" require "y2storage/dialogs/guided_setup/helpers/disk" require "dinstaller/with_progress" +require "dinstaller/storage/actions" +require "dinstaller/storage/proposal_settings" +require "dinstaller/storage/proposal_settings_converter" +require "dinstaller/storage/volume_converter" module DInstaller module Storage # Backend class to calculate a storage proposal + # + # @example + # proposal = Proposal.new(logger, config) + # proposal.on_calculate { puts "proposal calculated" } + # proposal.calculated_volumes #=> [] + # + # settings = ProposalSettings.new + # + # proposal.calculate(settings) #=> true + # proposal.calculated_volumes #=> [Volume, Volume] class Proposal include WithProgress - class NoProposalError < StandardError; end + # Settings that were used to calculate the proposal + # + # @return [ProposalSettings, nil] + attr_reader :settings # Constructor # # @param logger [Logger] - # @param config [Config] + # @param config [Config] D-Installer config def initialize(logger, config) @logger = logger @config = config - @listeners = [] - end - - def add_on_change_listener(&block) - @listeners << block + @on_calculate_callbacks = [] end - def changed! - @listeners.each(&:call) + # Stores callbacks to be call after calculating a proposal + def on_calculate(&block) + @on_calculate_callbacks << block end # Available devices for installation @@ -76,57 +88,60 @@ def device_label(device) disk_helper.label(device) end - # Name of devices where to perform the installation + # Volume definitions to be used as templates in the interface # - # @raise [NoProposalError] if no proposal yet + # Based on the configuration and/or on Y2Storage internals, these volumes may really + # exist or not in the real context of the proposal and its settings. # - # @return [Array] - def candidate_devices - raise NoProposalError unless proposal - - proposal.settings.candidate_devices + # @return [Array] + def volume_templates + VolumesGenerator.new(default_specs).volumes end - # Whether the proposal should create LVM devices + # Volumes from the specs used during the calculation of the storage proposal # - # @raise [NoProposalError] if no proposal yet + # Not to be confused with settings.volumes, which are used as starting point for creating the + # volume specs for the storage proposal. # - # @return [Boolean] - def lvm? - raise NoProposalError unless proposal + # @return [Array] + def calculated_volumes + return [] unless proposal + + generator = VolumesGenerator.new(specs_from_proposal, + planned_devices: proposal.planned_devices) + volumes = generator.volumes(only_proposed: true) + + # FIXME: setting this should be a responsibility of VolumesGenerator or any other component, + # but this is good enough until we implement fine-grained control on encryption + volumes.each { |v| v.encrypted = proposal.settings.use_encryption } - proposal.settings.use_lvm + volumes end # Calculates a new proposal # - # @param settings [Hash] settings to calculate the proposal - # (e.g., { "use_lvm" => true, "candidate_devices" => ["/dev/sda"]}). Note that keys should - # match with a public setter. - # + # @param settings [ProposalSettings] settings to calculate the proposal # @return [Boolean] whether the proposal was correctly calculated - def calculate(settings = {}) - proposal_settings = generate_proposal_settings(settings) + def calculate(settings = nil) + @settings = settings || ProposalSettings.new + @settings.freeze + proposal_settings = to_y2storage_settings(@settings) - @proposal = Y2Storage::GuidedProposal.initial( - settings: proposal_settings, - devicegraph: probed_devicegraph, - disk_analyzer: disk_analyzer - ) - save - changed! + @proposal = new_proposal(proposal_settings) + storage_manager.proposal = proposal + + @on_calculate_callbacks.each(&:call) !proposal.failed? end - # Storage actions manager - # - # @fixme this method should directly return the actions + # Storage actions # - # @return [Storage::Actions] + # @return [Array] def actions - # FIXME: this class could receive the storage manager instance - @actions ||= Actions.new(logger) + return [] unless proposal&.devices + + Actions.new(logger, proposal.devices.actiongraph).all end private @@ -137,40 +152,63 @@ def actions # @return [Config] attr_reader :config - # @return [Y2Storage::InitialGuidedProposal] + # @return [Y2Storage::MinGuidedProposal] attr_reader :proposal - # Generates proposal settings from the given values + # Instantiates and executes a Y2Storage proposal with the given settings # - # @param settings [Hash] - # @return [Y2Storage::ProposalSettings] - def generate_proposal_settings(settings) - proposal_settings = Y2Storage::ProposalSettings.new_for_current_product + # @param proposal_settings [Y2Storage::ProposalSettings] + # @return [Y2Storage::GuidedProposal] + def new_proposal(proposal_settings) + guided = Y2Storage::MinGuidedProposal.new( + settings: proposal_settings, + devicegraph: probed_devicegraph, + disk_analyzer: disk_analyzer + ) + guided.propose + guided + end - config_volumes = read_config_volumes - # If no volumes are specified, just leave the default ones (hardcoded at Y2Storage) - proposal_settings.volumes = config_volumes unless config_volumes.empty? + # Volume specs to use by default + # + # These specs are used for generating volume templates and also for calculating the storage + # proposal settings. + # + # @see #volume_templates + # @see ProposalSettingsGenerator + # + # @return [Array] + def default_specs + specs = specs_from_config + return specs if specs.any? - settings.each { |k, v| proposal_settings.public_send("#{k}=", v) } + Y2Storage::ProposalSettings.new_for_current_product.volumes + end - proposal_settings + # Volume specs from the D-Installer config file + # + # @return [Array] + def specs_from_config + config_volumes = config.data.fetch("storage", {}).fetch("volumes", []) + config_volumes.map { |v| Y2Storage::VolumeSpecification.new(v) } end - # Reads the list of volumes from the D-Installer configuration + # Volume specs from the setting used for the storage proposal # # @return [Array] - def read_config_volumes - vols = config.data.fetch("storage", {}).fetch("volumes", []) - vols.map { |v| Y2Storage::VolumeSpecification.new(v) } + def specs_from_proposal + return [] unless proposal + + proposal.settings.volumes end - # Saves the proposal or restores initial devices if a proposal was not calculated - def save - if proposal.failed? - storage_manager.staging = probed_devicegraph.dup - else - storage_manager.proposal = proposal - end + # Converts a DInstaller::Storage::ProposalSettings object to its equivalent + # Y2Storage::ProposalSettings one + # + # @param settings [ProposalSettings] + # @return [Y2Storage::ProposalSettings] + def to_y2storage_settings(settings) + ProposalSettingsConverter.new(default_specs: default_specs).to_y2storage(settings) end # @return [Y2Storage::DiskAnalyzer] @@ -195,6 +233,46 @@ def probed_devicegraph def storage_manager Y2Storage::StorageManager.instance end + + # Helper class to generate volumes from volume specs + class VolumesGenerator + # Constructor + # + # @param specs [Array] + # @param planned_devices [Array] + def initialize(specs, planned_devices: []) + @specs = specs + @planned_devices = planned_devices + end + + # Generates volumes + # + # @param only_proposed [Boolean] Whether to generate volumes only for specs with proposed + # equal to true. + # @return [Array] + def volumes(only_proposed: false) + specs = self.specs + specs = specs.select(&:proposed?) if only_proposed + specs.map { |s| converter.to_dinstaller(s, devices: planned_devices) } + end + + private + + # Volume specs used for generating volumes + # + # @return [Array] + attr_reader :specs + + # Planned devices used for completing some volume settings + # + # @return [Array] + attr_reader :planned_devices + + # Object to perform the conversion of the volumes + def converter + @converter ||= VolumeConverter.new(default_specs: specs) + end + end end end end diff --git a/service/lib/dinstaller/storage/proposal_settings.rb b/service/lib/dinstaller/storage/proposal_settings.rb new file mode 100644 index 0000000000..c9799c37f3 --- /dev/null +++ b/service/lib/dinstaller/storage/proposal_settings.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Copyright (c) [2022] 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/secret_attributes" + +module DInstaller + module Storage + # Settings used to calculate a D-Installer proposal + class ProposalSettings + include Y2Storage::SecretAttributes + + # Whether to use LVM + # + # @return [Boolean] + attr_accessor :use_lvm + alias_method :use_lvm?, :use_lvm + + # @!attribute encryption_password + # Password to use when creating new encryption devices + # @return [String] + secret_attr :encryption_password + + # Device names of the disks that can be used for the installation. If nil, the proposal will + # try find suitable devices + # + # @return [Array, nil] + attr_accessor :candidate_devices + + # Set of volumes to create + # + # Only these properties will be honored: mount_point, fs_type, fixed_size_limits, min_size, + # max_size, snapshots + # + # @return [Array] + attr_accessor :volumes + + def initialize + @use_lvm = false + @volumes = [] + end + end + end +end diff --git a/service/lib/dinstaller/storage/proposal_settings_converter.rb b/service/lib/dinstaller/storage/proposal_settings_converter.rb new file mode 100644 index 0000000000..2815a2e290 --- /dev/null +++ b/service/lib/dinstaller/storage/proposal_settings_converter.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +# Copyright (c) [2022] 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" +require "dinstaller/storage/proposal_settings" +require "dinstaller/storage/volume_converter" + +module DInstaller + module Storage + # Utility class offering methods to convert between Y2Storage::ProposalSettings objects and + # DInstaller::ProposalSettings ones + class ProposalSettingsConverter + # Constructor + # + # @param default_specs [Array] + def calculate_volume_specs + return default_specs if settings.volumes.none? + + included_specs + missing_specs + end + + # Volume specs representing the volumes included in the settings + # + # @return [Array] + def included_specs + settings.volumes.map { |v| volume_converter.to_y2storage(v) } + end + + # Volume specs that do not match any of the volumes in the settings + # + # @return [Array] + def missing_specs + specs = default_specs.select do |spec| + settings.volumes.none? { |v| v.mounted_at?(spec.mount_point) } + end + specs.each do |spec| + next unless spec.proposed_configurable + + spec.proposed = false + end + + specs + end + + # Object to perform the conversion of the volumes + def volume_converter + @volume_converter ||= VolumeConverter.new(default_specs: default_specs) + end + end + end + end +end diff --git a/service/lib/dinstaller/storage/volume.rb b/service/lib/dinstaller/storage/volume.rb new file mode 100644 index 0000000000..2e5c509159 --- /dev/null +++ b/service/lib/dinstaller/storage/volume.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +# Copyright (c) [2022] 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 "pathname" + +module DInstaller + module Storage + # A volume is used by the D-Installer proposal to communicate to the D-Bus layer about the + # characteristics of a volume to create in the system. A volume only provides the meaningful + # options from D-Bus and D-Installer point of views. + # + # The D-Installer proposal calculates a storage proposal with volume specifications created from + # the volumes. + class Volume + # Mount path + # + # @return [String, nil] nil if undetermined + attr_accessor :mount_point + + # Whether the volume is optional + # + # @note An optional volume is not automatically added by the storage proposal in any case. + # This value comes from a volume spec, see {#load_spec_values}. + # + # @return [Boolean] + attr_reader :optional + alias_method :optional?, :optional + + # Type of device + # + # @note This value is not transferred to the storage proposal yet. + # + # @return [:partition, :lvm_lv, nil] nil if undetermined + attr_accessor :device_type + + # Whether the volume should be encrypted + # + # @note This value is not transferred to the storage proposal yet. + # + # @return [Boolean, nil] nil if undetermined + attr_accessor :encrypted + + # Possible filesystem types for the volume + # + # @note This value comes from a volume spec, see {#load_spec_values}. + # + # @return [Array] + attr_reader :fs_types + + # Filesystem for the volume + # + # @return [Y2Storage::Filesystems::Type, nil] nil if undetermined + attr_accessor :fs_type + + # Min size for the volume + # + # @return [Y2Storage::DiskSize, nil] nil if undetermined + attr_accessor :min_size + + # Max size for the volume + # + # @return [Y2Storage::DiskSize, nil] nil if undetermined + attr_accessor :max_size + + # Whether the sizes should not be automatically calculated + # + # @return [Boolean, nil] nil if undetermined + attr_accessor :fixed_size_limits + + # Related volumes that may affect the calculation of the automatic size limits + # + # @note This is set by calling to {#assign_size_relevant_volumes} method. + # + # @return [Array] + attr_reader :size_relevant_volumes + + # Whether the volume is snapshots + # + # @return [Boolean, nil] nil if undetermined + attr_accessor :snapshots + + # Whether snapshots option can be configured + # + # @note This value comes from a volume spec, see {#load_spec_values}. + # + # @return [Boolean] + attr_reader :snapshots_configurable + alias_method :snapshots_configurable?, :snapshots_configurable + + # Constructor + # + # The volume is populated with some of the spec values. + # + # @param spec [Y2Storage::VolumeSpecification, nil] + def initialize(spec = nil) + @optional = true + @snapshots_configurable = false + @fs_types = [] + @size_relevant_volumes = [] + + load_spec_values(spec) if spec + end + + # Sets the mount points that affects the sizes of the volume + # + # @param specs [Array] + def assign_size_relevant_volumes(specs) + # FIXME: this should be a responsibility of the Proposal (since it's calculated by + # Proposal::DevicesPlanner) + @size_relevant_volumes = specs.select { |s| fallback?(s) }.map(&:mount_point) + end + + # Whether it makes sense to have automatic size limits for the volume + # + # @return [Boolean] + def adaptive_sizes? + # FIXME: this should be a responsibility of the Proposal (since it's calculated by + # Proposal::DevicesPlanner) + snapshots_affect_sizes? || size_relevant_volumes.any? + end + + # Whether snapshots affect the automatic calculation of the size limits + # + # @return [Boolean] + def snapshots_affect_sizes? + # FIXME: this should be a responsibility of the Proposal (since it's calculated by + # Proposal::DevicesPlanner) + return false unless snapshots || snapshots_configurable + + return true if snapshots_size && !snapshots_size.zero? + + snapshots_percentage && !snapshots_percentage.zero? + end + + # Whether the mount point of the volume matches the given one + # + # @param path [String, nil] mount point to check + # @return [Boolean] + def mounted_at?(path) + return false if mount_point.nil? || path.nil? + + Pathname.new(mount_point).cleanpath == Pathname.new(path).cleanpath + end + + private + + # Size required for snapshots + # + # @return [Y2Storage::DiskSize, nil] + attr_reader :snapshots_size + + # Percentage of space required for snapshots + # + # @return [Integer, nil] + attr_reader :snapshots_percentage + + # Loads meaningful values from a given volume spec + # + # @param spec [Y2Storage::VolumeSpecification] + def load_spec_values(spec) + @mount_point = spec.mount_point + @optional = spec.proposed_configurable? + @min_size = spec.min_size + @max_size = spec.min_size + @fs_types = spec.fs_types + @fs_type = spec.fs_type + @snapshots = spec.snapshots? + @snapshots_configurable = spec.snapshots_configurable? + @snapshots_size = spec.snapshots_size + @snapshots_percentage = spec.snapshots_percentage + @fixed_size_limits = spec.ignore_fallback_sizes? + end + + # Whether the given spec has the volume as fallback for sizes + # + # @param spec [Y2Storage::VolumeSpecification] + # @return [Boolean] + def fallback?(spec) + mounted_at?(spec.fallback_for_min_size) || + mounted_at?(spec.fallback_for_max_size) || + mounted_at?(spec.fallback_for_max_size_lvm) + end + end + end +end diff --git a/service/lib/dinstaller/storage/volume_converter.rb b/service/lib/dinstaller/storage/volume_converter.rb new file mode 100644 index 0000000000..ca60ab1b9b --- /dev/null +++ b/service/lib/dinstaller/storage/volume_converter.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +# Copyright (c) [2022] 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" +require "dinstaller/storage/volume" + +module DInstaller + module Storage + # Utility class offering methods to convert between Y2Storage::VolumeSpecification objects and + # DInstaller::Volume ones + class VolumeConverter + # Constructor + # + # @param default_specs [Array true }) + + spec.mount_point = volume.mount_point + spec.proposed = true + spec.fs_type = volume.fs_type if volume.fs_type + spec.snapshots = volume.snapshots if configure_snapshots?(spec) + + configure_sizes(spec) + end + + private + + # @see VolumeConverter#to_y2storage + attr_reader :volume + + # @see VolumeConverter#initialize + attr_reader :default_specs + + # Whether snapshots should be configured + # + # @param spec [Y2Storage::VolumeSpecification] + # @return [Boolean] + def configure_snapshots?(spec) + spec.snapshots_configurable? && !volume.snapshots.nil? + end + + # Whether size limits are fixed + # + # @param spec [Y2Storage::VolumeSpecification] + # @return [Boolean] + def fixed_size_limits?(spec) + # A volume from D-Bus is not created from a spec, so it does not contain relevant + # information to know whether the volume has adaptive sizes. Let's use a new volume + # created from spec to check about adaptive sizes. + !Volume.new(spec).adaptive_sizes? || !!volume.fixed_size_limits + end + + # Configures size related attributes + # + # @param spec [Y2Storage::VolumeSpecification] The spec is modified + def configure_sizes(spec) + fixed_size_limits = fixed_size_limits?(spec) + + spec.ignore_fallback_sizes = fixed_size_limits + spec.ignore_snapshots_sizes = fixed_size_limits + return spec unless fixed_size_limits + + spec.min_size = volume.min_size if volume.min_size + spec.max_size = volume.max_size if volume.max_size + spec + end + end + + # Internal class to generate a DInstaller volume + class ToDInstaller + # Constructor + # + # @param spec see {#spec} + # @param default_specs see #{default_specs} + # @param devices see {#devices} + def initialize(spec, default_specs, devices) + @spec = spec + @default_specs = default_specs + @devices = devices + end + + # @see VolumeConverter#to_y2storage + def convert + Volume.new(spec).tap do |volume| + volume.assign_size_relevant_volumes(default_specs) + planned = devices.find { |d| planned_device_match?(d, volume) } + if planned + volume.device_type = planned.respond_to?(:lv_type) ? :lvm_lv : :partition + volume.min_size = planned.min + volume.max_size = planned.max + end + end + end + + private + + # @see VolumeConverter#to_y2storage + attr_reader :spec + + # @see VolumeConverter#initialize + attr_reader :default_specs + + # @see VolumeConverter#initialize + attr_reader :devices + + # Whether the given planned device corresponds to the volume + def planned_device_match?(device, volume) + device.respond_to?(:mount_point) && volume.mounted_at?(device.mount_point) + end + end + end + end +end diff --git a/service/test/dinstaller/dbus/storage/proposal_test.rb b/service/test/dinstaller/dbus/storage/proposal_test.rb index 6754d763b2..b2f99d21a4 100644 --- a/service/test/dinstaller/dbus/storage/proposal_test.rb +++ b/service/test/dinstaller/dbus/storage/proposal_test.rb @@ -23,6 +23,9 @@ require "dinstaller/dbus/storage/proposal" require "dinstaller/dbus/interfaces/service_status" require "dinstaller/storage/proposal" +require "dinstaller/storage/proposal_settings" +require "dinstaller/storage/volume" +require "y2storage" describe DInstaller::DBus::Storage::Proposal do subject { described_class.new(backend, logger) } @@ -30,9 +33,11 @@ let(:logger) { Logger.new($stdout, level: :warn) } let(:backend) do - instance_double(DInstaller::Storage::Proposal, add_on_change_listener: nil) + instance_double(DInstaller::Storage::Proposal, on_calculate: nil, settings: settings) end + let(:settings) { nil } + let(:service_status_interface) do DInstaller::DBus::Interfaces::ServiceStatus::SERVICE_STATUS_INTERFACE end @@ -51,4 +56,359 @@ subject end end + + describe "#available_devices" do + before do + allow(backend).to receive(:available_devices).and_return(devices) + end + + context "if there is no available devices" do + let(:devices) { [] } + + it "returns an empty list" do + expect(subject.available_devices).to eq([]) + end + end + + context "if there are available devices" do + before do + allow(backend).to receive(:device_label).with(device1).and_return("Device 1") + allow(backend).to receive(:device_label).with(device2).and_return("Device 2") + end + + let(:devices) { [device1, device2] } + + let(:device1) { instance_double(Y2Storage::Disk, name: "/dev/vda") } + let(:device2) { instance_double(Y2Storage::Disk, name: "/dev/vdb") } + + it "retuns the device name and label for each device" do + result = subject.available_devices + + expect(result).to contain_exactly( + ["/dev/vda", "Device 1", {}], + ["/dev/vdb", "Device 2", {}] + ) + end + end + end + + describe "#candidate_devices" do + context "if a proposal has not been calculated yet" do + let(:settings) { nil } + + it "returns an empty list" do + expect(subject.candidate_devices).to eq([]) + end + end + + context "if a proposal has been calculated" do + let(:settings) do + instance_double(DInstaller::Storage::ProposalSettings, + candidate_devices: ["/dev/vda", "/dev/vdb"]) + end + + it "return the candidate devices" do + expect(subject.candidate_devices).to contain_exactly("/dev/vda", "/dev/vdb") + end + end + end + + describe "#lvm" do + context "if a proposal has not been calculated yet" do + let(:settings) { nil } + + it "returns false" do + expect(subject.lvm).to eq(false) + end + end + + context "if a proposal has been calculated" do + let(:settings) do + instance_double(DInstaller::Storage::ProposalSettings, use_lvm?: true) + end + + it "return whether LVM was used" do + expect(subject.lvm).to eq(true) + end + end + end + + describe "#encryption_password" do + context "if a proposal has not been calculated yet" do + let(:settings) { nil } + + it "returns an empty string" do + expect(subject.encryption_password).to eq("") + end + end + + context "if a proposal has been calculated" do + let(:settings) do + instance_double(DInstaller::Storage::ProposalSettings, encryption_password: "n0ts3cr3t") + end + + it "return the encryption password used by the proposal" do + expect(subject.encryption_password).to eq("n0ts3cr3t") + end + end + end + + describe "#volume_templates" do + before do + allow(backend).to receive(:volume_templates).and_return(templates) + end + + context "if there are no volume templates" do + let(:templates) { [] } + + it "returns an empty list" do + expect(subject.volume_templates).to eq([]) + end + end + + context "if there are volume templates" do + let(:templates) { [volume1_template, volume2_template] } + + let(:volume1_template) do + DInstaller::Storage::Volume.new(Y2Storage::VolumeSpecification.new({})).tap do |volume| + volume.mount_point = "/test" + volume.device_type = :partition + volume.encrypted = true + volume.fs_type = Y2Storage::Filesystems::Type::EXT3 + volume.min_size = Y2Storage::DiskSize.new(1024) + volume.max_size = Y2Storage::DiskSize.new(2048) + volume.fixed_size_limits = true + volume.snapshots = true + end + end + + let(:volume2_template) { DInstaller::Storage::Volume.new } + + before do + allow(volume1_template).to receive(:size_relevant_volumes).and_return(["/home"]) + allow(volume1_template).to receive(:fs_types) + .and_return([Y2Storage::Filesystems::Type::EXT3]) + end + + it "returns a list with a hash for each volume template" do + expect(subject.volume_templates.size).to eq(2) + expect(subject.volume_templates).to all(be_a(Hash)) + + template1, template2 = subject.volume_templates + + expect(template1).to eq({ + "MountPoint" => "/test", + "Optional" => false, + "DeviceType" => "partition", + "Encrypted" => true, + "FsTypes" => ["Ext3"], + "FsType" => "Ext3", + "MinSize" => 1024, + "MaxSize" => 2048, + "FixedSizeLimits" => true, + "AdaptiveSizes" => true, + "Snapshots" => true, + "SnapshotsConfigurable" => false, + "SnapshotsAffectSizes" => false, + "SizeRelevantVolumes" => ["/home"] + }) + + expect(template2).to eq({ + "Optional" => true, + "AdaptiveSizes" => false, + "SnapshotsConfigurable" => false, + "SnapshotsAffectSizes" => false + }) + end + end + end + + describe "#volumes" do + before do + allow(backend).to receive(:calculated_volumes).and_return(calculated_volumes) + end + + context "if there are no calulated volumes" do + let(:calculated_volumes) { [] } + + it "returns an empty list" do + expect(subject.volumes).to eq([]) + end + end + + context "if there are calculated volume" do + let(:calculated_volumes) { [calculated_volume1, calculated_volume2] } + + let(:calculated_volume1) do + DInstaller::Storage::Volume.new.tap do |volume| + volume.mount_point = "/test1" + end + end + + let(:calculated_volume2) do + DInstaller::Storage::Volume.new.tap do |volume| + volume.mount_point = "/test2" + end + end + + it "returns a list with a hash for each volume" do + expect(subject.volumes.size).to eq(2) + expect(subject.volumes).to all(be_a(Hash)) + + volume1, volume2 = subject.volumes + + expect(volume1).to include("MountPoint" => "/test1") + expect(volume2).to include("MountPoint" => "/test2") + end + end + end + + describe "#actions" do + before do + allow(backend).to receive(:actions).and_return(actions) + end + + context "if there are no actions" do + let(:actions) { [] } + + it "returns an empty list" do + expect(subject.actions).to eq([]) + end + end + + context "if there are actions" do + let(:actions) { [action1, action2] } + + let(:action1) do + instance_double(Y2Storage::CompoundAction, + sentence: "test1", device_is?: false, delete?: false) + end + + let(:action2) do + instance_double(Y2Storage::CompoundAction, + sentence: "test2", device_is?: true, delete?: true) + end + + it "returns a list with a hash for each action" do + expect(subject.actions.size).to eq(2) + expect(subject.actions).to all(be_a(Hash)) + + action1, action2 = subject.actions + + expect(action1).to eq({ + "Text" => "test1", + "Subvol" => false, + "Delete" => false + }) + + expect(action2).to eq({ + "Text" => "test2", + "Subvol" => true, + "Delete" => true + }) + end + end + end + + describe "#calculate" do + let(:dbus_settings) do + { + "CandidateDevices" => ["/dev/vda"], + "LVM" => true, + "EncryptionPassword" => "n0ts3cr3t", + "Volumes" => dbus_volumes + } + end + + let(:dbus_volumes) do + [ + { "MountPoint" => "/" }, + { "MountPoint" => "swap" } + ] + end + + it "calculates a proposal with settings having values from D-Bus" do + expect(backend).to receive(:calculate) do |settings| + expect(settings).to be_a(DInstaller::Storage::ProposalSettings) + expect(settings.candidate_devices).to contain_exactly("/dev/vda") + expect(settings.use_lvm?).to eq(true) + expect(settings.encryption_password).to eq("n0ts3cr3t") + expect(settings.volumes).to contain_exactly( + an_object_having_attributes(mount_point: "/"), + an_object_having_attributes(mount_point: "swap") + ) + end + + subject.calculate(dbus_settings) + end + + context "when the D-Bus settings does not include some values" do + let(:dbus_settings) { {} } + + it "calculates a proposal with settings having default values for the missing settings" do + expect(backend).to receive(:calculate) do |settings| + expect(settings).to be_a(DInstaller::Storage::ProposalSettings) + expect(settings.candidate_devices).to be_nil + expect(settings.use_lvm?).to eq(false) + expect(settings.encryption_password).to be_nil + expect(settings.volumes).to be_empty + end + + subject.calculate(dbus_settings) + end + end + + context "when the D-Bus settings includes a volume" do + let(:dbus_volumes) { [dbus_volume1] } + + let(:dbus_volume1) do + { + "DeviceType" => "lvm_lv", + "Encrypted" => true, + "MountPoint" => "/", + "FixedSizeLimits" => true, + "MinSize" => 1024, + "MaxSize" => 2048, + "FsType" => "Ext3", + "Snapshots" => true + } + end + + it "calculates a proposal with settings having a volume with values from D-Bus" do + expect(backend).to receive(:calculate) do |settings| + volume = settings.volumes.first + + expect(volume.device_type).to eq(:lvm_lv) + expect(volume.encrypted).to eq(true) + expect(volume.mount_point).to eq("/") + expect(volume.fixed_size_limits).to eq(true) + expect(volume.min_size.to_i).to eq(1024) + expect(volume.max_size.to_i).to eq(2048) + expect(volume.snapshots).to eq(true) + end + + subject.calculate(dbus_settings) + end + + context "and the D-Bus volume does not include some values" do + let(:dbus_volume1) { { "MountPoint" => "/" } } + + it "calculates a proposal with settings having a volume with missing values" do + expect(backend).to receive(:calculate) do |settings| + volume = settings.volumes.first + + expect(volume.device_type).to be_nil + expect(volume.encrypted).to be_nil + expect(volume.mount_point).to eq("/") + expect(volume.fixed_size_limits).to be_nil + expect(volume.min_size).to be_nil + expect(volume.max_size).to be_nil + expect(volume.snapshots).to be_nil + end + + subject.calculate(dbus_settings) + end + end + end + end end diff --git a/service/test/dinstaller/storage/proposal_test.rb b/service/test/dinstaller/storage/proposal_test.rb index a395bac720..ca8719c505 100644 --- a/service/test/dinstaller/storage/proposal_test.rb +++ b/service/test/dinstaller/storage/proposal_test.rb @@ -20,81 +20,276 @@ # find current contact information at www.suse.com. require_relative "../../test_helper" +require_relative "storage_helpers" require "dinstaller/storage/proposal" require "dinstaller/config" describe DInstaller::Storage::Proposal do + include DInstaller::RSpec::StorageHelpers + before { mock_storage } + subject(:proposal) { described_class.new(logger, config) } let(:logger) { Logger.new($stdout, level: :warn) } let(:config) { DInstaller::Config.new(config_data) } - let(:y2storage_proposal) { instance_double(Y2Storage::GuidedProposal, failed?: failed) } - let(:y2storage_manager) do - instance_double(Y2Storage::StorageManager, probed: nil, probed_disk_analyzer: nil) + let(:config_data) do + { "storage" => { "volumes" => config_volumes } } + end + + let(:config_volumes) do + [ + { + "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 GiB", + "snapshots" => true, "snapshots_percentage" => "300" + }, + { + "mount_point" => "/two", "fs_type" => "xfs", "min_size" => "5 GiB", + "proposed_configurable" => true, "fallback_for_min_size" => "/" + } + ] end - let(:failed) { false } - let(:config_data) { {} } describe "#calculate" do - before do - allow(Y2Storage::StorageManager).to receive(:instance).and_return y2storage_manager - allow(Y2Storage::GuidedProposal).to receive(:initial).and_return y2storage_proposal - allow(y2storage_manager).to receive(:proposal=) + let(:y2storage_proposal) do + instance_double(Y2Storage::MinGuidedProposal, propose: true, failed?: false) end - context "when there is no 'volumes' section in the config" do - let(:config_data) { {} } + before { allow(Y2Storage::StorageManager.instance).to receive(:proposal=) } - it "calculates the Y2Storage proposal with a default set of VolumeSpecification" do - expect(Y2Storage::GuidedProposal).to receive(:initial) do |**args| - expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) + RSpec.shared_examples "y2storage proposal with no candidates" do + it "runs the Y2Storage proposal with no candidate devices" do + expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| + expect(args[:settings].candidate_devices).to be_nil + y2storage_proposal + end + + proposal.calculate + end + end + + RSpec.shared_examples "y2storage proposal from config" do + it "runs the Y2Storage proposal with a set of VolumeSpecification based on the config" do + expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| vols = args[:settings].volumes - expect(vols).to_not be_empty expect(vols).to all(be_a(Y2Storage::VolumeSpecification)) + expect(vols.map(&:mount_point)).to contain_exactly("/", "/two") y2storage_proposal end proposal.calculate end + + include_examples "y2storage proposal with no candidates" end - context "when there is a 'volumes' section in the config" do - let(:config_data) do - { "storage" => { "volumes" => [{ "mount_point" => "/one" }, { "mount_point" => "/two" }] } } + it "runs all the callbacks" do + var1 = 5 + var2 = 5 + proposal.on_calculate do + var1 += 1 end + proposal.on_calculate { var2 *= 2 } + + expect(var1).to eq 5 + expect(var2).to eq 5 + proposal.calculate + expect(var1).to eq 6 + expect(var2).to eq 10 + end - it "calculates the Y2Storage with the correct set of VolumeSpecification" do - expect(Y2Storage::GuidedProposal).to receive(:initial) do |**args| + context "with undefined settings and no storage section in the config" do + let(:config_data) { {} } + + it "runs the Y2Storage proposal with a default set of VolumeSpecification" do + expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) vols = args[:settings].volumes + expect(vols).to_not be_empty expect(vols).to all(be_a(Y2Storage::VolumeSpecification)) - expect(vols.map(&:mount_point)).to contain_exactly("/one", "/two") y2storage_proposal end proposal.calculate end + + include_examples "y2storage proposal with no candidates" + end + + context "with undefined settings" do + include_examples "y2storage proposal from config" + end + + context "with the default settings" do + let(:settings) { DInstaller::Storage::ProposalSettings.new } + + include_examples "y2storage proposal from config" + end + + context "with settings defining a list of candidate devices" do + let(:settings) do + settings = DInstaller::Storage::ProposalSettings.new + settings.candidate_devices = devices + settings + end + + context "if the defined list is empty" do + let(:devices) { [] } + + include_examples "y2storage proposal with no candidates" + end + + context "if the defined list contains valid device names" do + let(:devices) { ["/dev/sda"] } + + it "runs the Y2Storage proposal with the specified candidate devices" do + expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| + expect(args[:settings].candidate_devices).to eq devices + y2storage_proposal + end + + proposal.calculate(settings) + end + end end - context "when the Y2Storage proposal successes" do - let(:failed) { false } + context "when the Y2Storage proposal succeeds" do + it "returns true and saves the successful proposal" do + manager = Y2Storage::StorageManager.instance + expect(manager).to receive(:proposal=).and_call_original - it "saves the proposal" do - expect(y2storage_manager).to receive(:proposal=).with y2storage_proposal - proposal.calculate + expect(proposal.calculate).to eq true + expect(manager.proposal.failed?).to eq false end end context "when the Y2Storage proposal fails" do - let(:failed) { true } + let(:config_volumes) do + # Enforce an impossible root of 10 TiB + [{ "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 TiB" }] + end + + it "returns false and saves the failed proposal" do + manager = Y2Storage::StorageManager.instance + expect(manager).to receive(:proposal=).and_call_original + + expect(proposal.calculate).to eq false + expect(manager.proposal.failed?).to eq true + end + end + end - it "does not save the proposal" do - allow(y2storage_manager).to receive(:staging=) - expect(y2storage_manager).to_not receive(:proposal=) + describe "#actions" do + before do + allow(subject).to receive(:proposal).and_return(y2storage_proposal) + end + + context "when there is no proposal" do + let(:y2storage_proposal) { nil } + + it "returns an empty list" do + expect(subject.actions).to eq([]) + end + end + + context "when there is a proposal" do + let(:y2storage_proposal) { instance_double(Y2Storage::MinGuidedProposal, devices: devices) } + + context "and the proposal failed" do + let(:devices) { nil } + + it "returns an empty list" do + expect(subject.actions).to eq([]) + end + end + + context "and the proposal was successful" do + let(:devices) { instance_double(Y2Storage::Devicegraph, actiongraph: actiongraph) } + let(:actiongraph) { instance_double(Y2Storage::Actiongraph, compound_actions: actions) } + + let(:actions) { [fs_action, subvol_action] } + let(:fs_action) { instance_double(Y2Storage::CompoundAction, delete?: false) } + let(:subvol_action) { instance_double(Y2Storage::CompoundAction, delete?: false) } + + before do + allow(fs_action).to receive(:device_is?).with(:btrfs_subvolume).and_return false + allow(subvol_action).to receive(:device_is?).with(:btrfs_subvolume).and_return true + end + + it "returns the actions from the actiongraph" do + expect(proposal.actions).to contain_exactly(fs_action, subvol_action) + end + end + end + end + + describe "#volume_templates" do + it "returns a list with the default volumes from the configuration" do + templates = proposal.volume_templates + expect(templates).to all be_a(DInstaller::Storage::Volume) + expect(templates.map(&:mount_point)).to contain_exactly("/", "/two") + end + + context "with no storage section in the configuration" do + let(:config_data) { {} } + + it "returns settings with a fallback list of volumes" do + templates = proposal.volume_templates + expect(templates).to_not be_empty + expect(templates).to all be_a(DInstaller::Storage::Volume) + end + end + + context "with volumes that are disabled by default" do + let(:config_volumes) do + [ + { "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 GiB" }, + { "mount_point" => "/enabled", "min_size" => "5 GiB" }, + { "mount_point" => "/disabled", "proposed" => false, "min_size" => "5 GiB" } + ] + end + + it "returns a set including enabled and disabled volumes" do + expect(proposal.volume_templates.map(&:mount_point)).to contain_exactly( + "/", "/enabled", "/disabled" + ) + end + end + end + + describe "#calculated_volumes" do + it "returns an empty array if #calculate has not being called" do + expect(proposal.calculated_volumes).to eq [] + end + + context "with volumes that are disabled by default" do + let(:config_volumes) do + [ + { "mount_point" => "/", "fs_type" => "btrfs", "min_size" => "10 GiB" }, + { "mount_point" => "/enabled", "min_size" => "5 GiB" }, + { "mount_point" => "/disabled", "proposed" => false, "min_size" => "5 GiB" } + ] + end + + # Note that calling #calculate without settings means "reset to default volumes" + it "returns only the volumes enabled by default if #calculate was called with no settings" do proposal.calculate + expect(proposal.calculated_volumes.map(&:mount_point)).to contain_exactly("/", "/enabled") end end end + + describe "#settings" do + let(:settings) { DInstaller::Storage::ProposalSettings.new } + + it "returns nil if #calculate has not being called" do + expect(proposal.settings).to be_nil + end + + it "returns the settings previously passed to #calculate" do + proposal.calculate(settings) + expect(proposal.settings).to eq settings + end + end end diff --git a/service/test/dinstaller/storage/proposal_volumes_test.rb b/service/test/dinstaller/storage/proposal_volumes_test.rb new file mode 100644 index 0000000000..039a9c7b7c --- /dev/null +++ b/service/test/dinstaller/storage/proposal_volumes_test.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +# Copyright (c) [2022] 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_relative "../../test_helper" +require_relative "storage_helpers" +require "dinstaller/storage/proposal" +require "dinstaller/config" + +describe DInstaller::Storage::Proposal do + include DInstaller::RSpec::StorageHelpers + before do + mock_storage + allow(Y2Storage::StorageManager.instance).to receive(:proposal=) + end + + subject(:proposal) { described_class.new(logger, config) } + + let(:logger) { Logger.new($stdout, level: :warn) } + let(:config) { DInstaller::Config.new(config_data) } + + let(:config_data) do + { "storage" => { "volumes" => config_volumes } } + end + let(:settings) do + settings = DInstaller::Storage::ProposalSettings.new + settings.volumes = volumes + settings + end + + let(:config_volumes) do + [ + { + "mount_point" => "/", "fs_type" => "btrfs", "fs_types" => ["btrfs", "ext4"], + "snapshots" => true, "snapshots_configurable" => true, + "min_size" => "10 GiB", "snapshots_percentage" => "300" + }, + { + "mount_point" => "/two", "fs_type" => "xfs", "fs_types" => ["xfs", "ext4"], + "min_size" => "5 GiB", "proposed_configurable" => true, "fallback_for_min_size" => "/" + } + ] + end + + let(:y2storage_proposal) do + instance_double(Y2Storage::MinGuidedProposal, propose: true, failed?: false) + end + + # Constructs a DInstaller volume with the given set of attributes + # + # @param attrs [Hash] set of attributes and their values (sizes can be provided as strings) + # @return [DInstaller::Storage::Volume] + def test_vol(attrs = {}) + vol = DInstaller::Storage::Volume.new + attrs.each do |attr, value| + if [:min_size, :max_size].include?(attr.to_sym) + # DiskSize.new can take a DiskSize, a string or a number + value = Y2Storage::DiskSize.new(value) + end + vol.public_send(:"#{attr}=", value) + end + vol + end + + # Returns the correct Y2Storage::Filesystem type object for the given filesystem type + # + # @param type [String, Symbol, Y2Storage::Filesystems::Type] + # @return [Y2Storage::Filesystems::Type] + def fs_type(type) + return type if type.is_a?(Y2Storage::Filesystems::Type) + + Y2Storage::Filesystems::Type.find(type.downcase.to_sym) + end + + # Sets the expectation for a Y2Storage::MinGuidedProposal to be created with the + # given set of Y2Storage::VolumeSpecification objects and returns proposal mocked as + # 'y2storage_proposal' + # + # @param specs [Hash] arguments to check on each VolumeSpecification object + def expect_proposal_with_expects(*specs) + expect(Y2Storage::MinGuidedProposal).to receive(:new) do |**args| + expect(args[:settings]).to be_a(Y2Storage::ProposalSettings) + expect(args[:settings].volumes).to all(be_a(Y2Storage::VolumeSpecification)) + expect(args[:settings].volumes).to contain_exactly( + *specs.map { |spec| an_object_having_attributes(spec) } + ) + + y2storage_proposal + end + end + + # Ideas for more tests: + # - Passing a fs_type not included in fs_types (even trying to redefine fs_types) + # - Trying to hack "optional" to disable a mandatory volume + + context "when the settings customize volumes from the config" do + let(:volumes) do + [ + test_vol( + mount_point: "/", fixed_size_limits: true, min_size: "7 GiB", max_size: "9 GiB", + # Attributes that cannot be (re)defined here + encrypted: true, device_type: :lvm_lv + ), + test_vol( + mount_point: "/two", max_size: "unlimited", fs_type: fs_type(:ext4), + # Attributes that cannot be (re)defined here + fixed_size_limits: false, snapshots: true + ) + ] + end + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { + mount_point: "/", proposed: true, fs_type: fs_type(:btrfs), snapshots: true, + ignore_fallback_sizes: true, ignore_snapshots_sizes: true, + min_size: Y2Storage::DiskSize.GiB(7), max_size: Y2Storage::DiskSize.GiB(9) + }, + { + mount_point: "/two", proposed: true, fs_type: fs_type(:ext4), snapshots: false, + min_size: Y2Storage::DiskSize.GiB(5), max_size: Y2Storage::DiskSize.unlimited + } + ) + + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns the correct set of volumes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", optional: false, encrypted: false, device_type: :partition, + fs_type: fs_type(:btrfs), fs_types: [fs_type(:btrfs), fs_type(:ext4)], + snapshots: true, fixed_size_limits: true, size_relevant_volumes: ["/two"], + min_size: Y2Storage::DiskSize.GiB(7), max_size: Y2Storage::DiskSize.GiB(9) + ), + an_object_having_attributes( + mount_point: "/two", optional: true, encrypted: false, device_type: :partition, + fs_type: fs_type(:ext4), fs_types: [fs_type(:xfs), fs_type(:ext4)], + snapshots: false, fixed_size_limits: true, size_relevant_volumes: [], + min_size: Y2Storage::DiskSize.GiB(5), max_size: Y2Storage::DiskSize.unlimited + ) + ) + end + end + end + + context "if the settings redefine mandatory volumes and omit the optional" do + let(:volumes) { [test_vol(mount_point: "/", snapshots: false)] } + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { mount_point: "/", proposed: true, snapshots: false }, + { mount_point: "/two", proposed: false } + ) + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns the correct set of mandatory volumes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", optional: false, snapshots: false, + fs_type: fs_type(:btrfs), fs_types: [fs_type(:btrfs), fs_type(:ext4)], + fixed_size_limits: false, size_relevant_volumes: ["/two"], + min_size: Y2Storage::DiskSize.GiB(15), max_size: Y2Storage::DiskSize.unlimited + ) + ) + end + end + end + + context "if the settings omit the mandatory volumes and add some others" do + let(:volumes) { [test_vol(mount_point: "/var", min_size: "5 GiB")] } + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { mount_point: "/", proposed: true, snapshots: true }, + { mount_point: "/two", proposed: false }, + { mount_point: "/var", proposed: true, max_size: Y2Storage::DiskSize.unlimited } + ) + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns the correct set of volumes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", optional: false, snapshots: true, + fs_type: fs_type(:btrfs), fs_types: [fs_type(:btrfs), fs_type(:ext4)] + ), + an_object_having_attributes(mount_point: "/var", optional: true) + ) + end + end + end + + context "when dynamic sizes are used and they are affected by other volumes" do + let(:volumes) { [test_vol(mount_point: "/", snapshots: false, min_size: "4 GiB")] } + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { + mount_point: "/", proposed: true, snapshots: false, + ignore_fallback_sizes: false, ignore_snapshots_sizes: false, + min_size: Y2Storage::DiskSize.GiB(10) + }, + { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } + ) + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns a set of volumes with fixed limits and adjusted sizes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", snapshots: false, fixed_size_limits: false, + size_relevant_volumes: ["/two"], min_size: Y2Storage::DiskSize.GiB(15) + ) + ) + end + end + end + + context "when dynamic sizes are used and they are affected by snapshots" do + let(:volumes) { [test_vol(mount_point: "/"), test_vol(mount_point: "/two")] } + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { + mount_point: "/", proposed: true, snapshots: true, + ignore_fallback_sizes: false, ignore_snapshots_sizes: false, + min_size: Y2Storage::DiskSize.GiB(10) + }, + { mount_point: "/two", proposed: true, fallback_for_min_size: "/" } + ) + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns a set of volumes with fixed limits and adjusted sizes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", snapshots: true, fixed_size_limits: false, + min_size: Y2Storage::DiskSize.GiB(40) + ), + an_object_having_attributes(mount_point: "/two") + ) + end + end + end + + context "when dynamic sizes are used and they are affected by snapshots and other volumes" do + let(:volumes) { [test_vol(mount_point: "/", min_size: "6 GiB")] } + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { + mount_point: "/", proposed: true, snapshots: true, + ignore_fallback_sizes: false, ignore_snapshots_sizes: false, + min_size: Y2Storage::DiskSize.GiB(10) + }, + { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } + ) + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns a set of volumes with fixed limits and adjusted sizes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", snapshots: true, fixed_size_limits: false, + size_relevant_volumes: ["/two"], min_size: Y2Storage::DiskSize.GiB(60) + ) + ) + end + end + end + + context "when fixed sizes are enforced" do + let(:volumes) do + [test_vol(mount_point: "/", fixed_size_limits: true, min_size: "6 GiB")] + end + + describe "#calculate" do + it "runs the Y2Storage proposal with the correct set of VolumeSpecification" do + expect_proposal_with_expects( + { + mount_point: "/", proposed: true, snapshots: true, + ignore_fallback_sizes: true, ignore_snapshots_sizes: true, + min_size: Y2Storage::DiskSize.GiB(6) + }, + { mount_point: "/two", proposed: false, fallback_for_min_size: "/" } + ) + proposal.calculate(settings) + end + end + + describe "#calculated_volumes" do + it "returns a set of volumes with fixed limits and adjusted sizes" do + proposal.calculate(settings) + + expect(proposal.calculated_volumes).to contain_exactly( + an_object_having_attributes( + mount_point: "/", snapshots: true, fixed_size_limits: true, + size_relevant_volumes: ["/two"], min_size: Y2Storage::DiskSize.GiB(6) + ) + ) + end + end + end +end diff --git a/service/test/dinstaller/storage/storage_helpers.rb b/service/test/dinstaller/storage/storage_helpers.rb new file mode 100644 index 0000000000..84fffaff51 --- /dev/null +++ b/service/test/dinstaller/storage/storage_helpers.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Copyright (c) [2022] 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 "rspec" +require "y2storage" + +module DInstaller + module RSpec + # RSpec extension to add Y2Storage specific helpers + module StorageHelpers + # See {#mock_storage_probing} and {#mock_hwinfo} + def mock_storage(devicegraph: "empty-hd-50GiB.yaml", hwinfo: Y2Storage::HWInfoDisk.new) + mock_hwinfo(hwinfo) + mock_storage_probing(devicegraph) + end + + # Mocks the Y2Storage probing process using the devicegraph described by the + # provided fixture file + # + # @param devicegraph_file [String] name of the XML or Yaml file in the fixtures directory + def mock_storage_probing(devicegraph_file) + manager = Y2Storage::StorageManager.create_test_instance + + path = File.join(FIXTURES_PATH, devicegraph_file) + if path.end_with?(".xml") + manager.probe_from_xml(path) + else + manager.probe_from_yaml(path) + end + end + + # Mocks all Y2Storage calls to HWInfo with the information provided by the given struct + # + # @param hwinfo [Y2Storage::HWInfoDisk] disk information used for all the calls + def mock_hwinfo(hwinfo) + allow_any_instance_of(Y2Storage::BlkDevice).to receive(:hwinfo).and_return(hwinfo) + end + end + end +end diff --git a/service/test/fixtures/empty-hd-50GiB.yaml b/service/test/fixtures/empty-hd-50GiB.yaml new file mode 100644 index 0000000000..388d31114d --- /dev/null +++ b/service/test/fixtures/empty-hd-50GiB.yaml @@ -0,0 +1,4 @@ +--- +- disk: + name: /dev/sda + size: 50 GiB