diff --git a/service/etc/agama.yaml b/service/etc/agama.yaml index ef44acbd72..bf48558f45 100644 --- a/service/etc/agama.yaml +++ b/service/etc/agama.yaml @@ -62,6 +62,7 @@ ALP-Dolomite: patterns: null storage: + space_policy: delete encryption: method: luks2 pbkd_function: pbkdf2 @@ -150,6 +151,7 @@ Tumbleweed: patterns: null storage: + space_policy: delete volumes: - "/" - "swap" @@ -274,6 +276,7 @@ Leap16: patterns: null storage: + space_policy: delete encryption: method: luks2 pbkd_function: pbkdf2 diff --git a/service/lib/agama/dbus/storage/manager.rb b/service/lib/agama/dbus/storage/manager.rb index 89051a5d5c..57cf3261a5 100644 --- a/service/lib/agama/dbus/storage/manager.rb +++ b/service/lib/agama/dbus/storage/manager.rb @@ -64,7 +64,6 @@ def initialize(backend, logger) register_progress_callbacks register_service_status_callbacks register_iscsi_callbacks - register_software_callbacks add_s390_interfaces if Yast::Arch.s390 end @@ -149,7 +148,7 @@ def calculate_proposal(dbus_settings) logger.info( "Calculating storage proposal from D-Bus.\n " \ "D-Bus settings: #{dbus_settings}\n" \ - "Agama settings: #{settings}" + "Agama settings: #{settings.inspect}" ) success = proposal.calculate(settings) @@ -164,8 +163,8 @@ def calculate_proposal(dbus_settings) dbus_reader :result, "o" - dbus_method :DefaultVolume, "in mount_path:s , out volume:a{sv}" do |mount_path| - default_volume(mount_path) + dbus_method :DefaultVolume, "in mount_path:s, out volume:a{sv}" do |mount_path| + [default_volume(mount_path)] end # result: 0 success; 1 error @@ -298,12 +297,6 @@ def register_iscsi_callbacks end end - def register_software_callbacks - backend.software.on_product_selected do |_product| - backend.proposal.invalidate - end - end - def storage_properties_changed properties = interfaces_and_properties[STORAGE_INTERFACE] dbus_properties_changed(STORAGE_INTERFACE, properties, []) diff --git a/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb b/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb index 4573d2cf81..4e7d7b1356 100644 --- a/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb +++ b/service/lib/agama/dbus/storage/proposal_settings_conversion/from_dbus.rb @@ -81,7 +81,7 @@ def convert # @param target [Agama::Storage::ProposalSettings] # @param value [String] def boot_device_conversion(target, value) - target.boot_device = value + target.boot_device = value.empty? ? nil : value end # @param target [Agama::Storage::ProposalSettings] diff --git a/service/lib/agama/storage/manager.rb b/service/lib/agama/storage/manager.rb index f90d686c9f..54e812e358 100644 --- a/service/lib/agama/storage/manager.rb +++ b/service/lib/agama/storage/manager.rb @@ -188,22 +188,12 @@ def probe_devices self.deprecated_system = false end - # Calculates the proposal - # - # It reuses the settings from the previous proposal, if any. + # Calculates the proposal using the settings from the config file. def calculate_proposal - settings = proposal.settings || read_proposal_settings - + settings = ProposalSettingsReader.new(config).read proposal.calculate(settings) end - # Reads the default proposal settings from the config file. - # - # @return [ProposalSettings] - def read_proposal_settings - ProposalSettingsReader.new(config).read - end - # Adds the required packages to the list of resolvables to install def add_packages devicegraph = Y2Storage::StorageManager.instance.staging diff --git a/service/lib/agama/storage/proposal.rb b/service/lib/agama/storage/proposal.rb index 3bfbd48306..58a353781e 100644 --- a/service/lib/agama/storage/proposal.rb +++ b/service/lib/agama/storage/proposal.rb @@ -38,34 +38,11 @@ def initialize(config, logger: nil) # Whether the proposal was successfully calculated. # - # @note: The proposal must not be invalidated. - # # @return [Boolean] def success? calculated? && !proposal.failed? end - # Whether the proposal was already calculated. - # - # @note: The proposal must not be invalidated. - # - # @return [Boolean] - def calculated? - !invalidated? && !proposal.nil? - end - - # Whether the proposal was invalidated. - # - # @return [Boolean] - def invalidated? - !!@invalidated - end - - # Invalidates the current proposal. - def invalidate - @invalidated = true - end - # Stores callbacks to be call after calculating a proposal. def on_calculate(&block) @on_calculate_callbacks << block @@ -91,7 +68,6 @@ def calculate(settings) calculate_proposal(settings) @on_calculate_callbacks.each(&:call) - @invalidated = false success? end @@ -101,8 +77,7 @@ def calculate(settings) # Note that this settings might differ from the {#original_settings}. For example, the sizes # of some volumes could be adjusted if auto size is set. # - # @return [ProposalSettings, nil] nil if no proposal has been calculated yet or the proposal - # was invalidated. + # @return [ProposalSettings, nil] nil if no proposal has been calculated yet. def settings return nil unless calculated? @@ -154,11 +129,16 @@ def issues # @return [Y2Storage::MinGuidedProposal, nil] def proposal - return nil if invalidated? - storage_manager.proposal end + # Whether the proposal was already calculated. + # + # @return [Boolean] + def calculated? + !proposal.nil? + end + # Instantiates and executes a Y2Storage proposal with the given settings # # @param settings [Y2Storage::ProposalSettings] @@ -205,6 +185,7 @@ def boot_device_issue # @return [Issue, nil] def missing_devices_issue # At this moment, only the boot device is checked. + return unless settings.boot_device return if available_devices.map(&:name).include?(settings.boot_device) Issue.new("Selected device is not found in the system", diff --git a/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb b/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb index fa732954a7..0747e291a6 100644 --- a/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb +++ b/service/lib/agama/storage/proposal_settings_conversion/to_y2storage.rb @@ -84,6 +84,8 @@ def lvm_conversion(target) target.lvm = lvm target.separate_vgs = lvm + # Prevent VG reuse + target.lvm_vg_reuse = false end # @param target [Y2Storage::ProposalSettings] @@ -135,8 +137,12 @@ def missing_volumes # @param target [Y2Storage::ProposalSettings] def fallbacks_conversion(target) target.volumes.each do |spec| - spec.fallback_for_min_size = find_min_size_fallback(spec.mount_point) - spec.fallback_for_max_size = find_max_size_fallback(spec.mount_point) + min_size_fallback = find_min_size_fallback(spec.mount_point) + max_size_fallback = find_max_size_fallback(spec.mount_point) + + spec.fallback_for_min_size = min_size_fallback + spec.fallback_for_max_size = max_size_fallback + spec.fallback_for_max_size_lvm = max_size_fallback end end diff --git a/service/lib/agama/storage/volume_conversion/to_y2storage.rb b/service/lib/agama/storage/volume_conversion/to_y2storage.rb index c02fc39174..605da35778 100644 --- a/service/lib/agama/storage/volume_conversion/to_y2storage.rb +++ b/service/lib/agama/storage/volume_conversion/to_y2storage.rb @@ -70,8 +70,12 @@ def sizes_conversion(target) # And note that the final range of sizes used by the Y2Storage proposal is calculated by # Y2Storage according the range configured here and other sizes like fallback sizes or # the size for snapshots. - target.min_size = auto ? volume.outline.base_min_size : volume.min_size - target.max_size = auto ? volume.outline.base_max_size : volume.max_size + min_size = auto ? volume.outline.base_min_size : volume.min_size + max_size = auto ? volume.outline.base_max_size : volume.max_size + + target.min_size = min_size + target.max_size = max_size + target.max_size_lvm = max_size end # @param target [Y2Storage::VolumeSpecification] diff --git a/service/lib/agama/storage/volume_templates_builder.rb b/service/lib/agama/storage/volume_templates_builder.rb index 149c99d23e..a5f3e3cedc 100644 --- a/service/lib/agama/storage/volume_templates_builder.rb +++ b/service/lib/agama/storage/volume_templates_builder.rb @@ -141,7 +141,10 @@ def btrfs(data) def subvolume(data) return Y2Storage::SubvolSpecification.new(data) if data.is_a?(String) - attrs = { copy_on_write: fetch(data, :copy_on_write), archs: fetch(data, :archs) }.compact + archs = fetch(data, :archs, "").gsub(/\s+/, "").split(",") + archs = nil if archs.none? + + attrs = { copy_on_write: fetch(data, :copy_on_write), archs: archs }.compact Y2Storage::SubvolSpecification.new(fetch(data, :path), **attrs) end diff --git a/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb b/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb index a9d12abefb..bd43e1362c 100644 --- a/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb +++ b/service/test/agama/dbus/storage/proposal_settings_conversion/from_dbus_test.rb @@ -107,6 +107,16 @@ end end + context "when an empty boot device is provided from D-Bus" do + let(:dbus_settings) { { "BootDevice" => "" } } + + it "sets the boot device to nil" do + settings = subject.convert + + expect(settings.boot_device).to be_nil + end + end + context "when volumes are not provided from D-Bus" do let(:dbus_settings) { { "Volumes" => [] } } diff --git a/service/test/agama/storage/manager_test.rb b/service/test/agama/storage/manager_test.rb index 9658538c83..0e3c52c2aa 100644 --- a/service/test/agama/storage/manager_test.rb +++ b/service/test/agama/storage/manager_test.rb @@ -157,6 +157,9 @@ allow(y2storage_manager).to receive(:activate) allow(iscsi).to receive(:probe) allow(y2storage_manager).to receive(:probe) + + allow_any_instance_of(Agama::Storage::ProposalSettingsReader).to receive(:read) + .and_return(config_settings) end let(:raw_devicegraph) do @@ -168,7 +171,9 @@ let(:iscsi) { Agama::Storage::ISCSI::Manager.new } let(:devices) { [disk1, disk2] } - let(:settings) { nil } + + let(:settings) { Agama::Storage::ProposalSettings.new } + let(:config_settings) { Agama::Storage::ProposalSettings.new } let(:disk1) { instance_double(Y2Storage::Disk, name: "/dev/vda") } let(:disk2) { instance_double(Y2Storage::Disk, name: "/dev/vdb") } @@ -179,7 +184,7 @@ let(:callback) { proc {} } - it "probes the storage devices and calculates a proposal" do + it "probes the storage devices and calculates a proposal with the default settings" do expect(config).to receive(:pick_product).with("ALP") expect(iscsi).to receive(:activate) expect(y2storage_manager).to receive(:activate) do |callbacks| @@ -187,7 +192,7 @@ end expect(iscsi).to receive(:probe) expect(y2storage_manager).to receive(:probe) - expect(proposal).to receive(:calculate) + expect(proposal).to receive(:calculate).with(config_settings) storage.probe end @@ -245,31 +250,6 @@ ) end end - - context "when there are settings from a previous proposal" do - let(:settings) { Agama::Storage::ProposalSettings.new } - - it "calculates a proposal using the previous settings" do - expect(proposal).to receive(:calculate).with(settings) - storage.probe - end - end - - context "when there are no settings from a previous proposal" do - let(:settings) { nil } - - let(:new_settings) { Agama::Storage::ProposalSettings.new } - - before do - allow_any_instance_of(Agama::Storage::ProposalSettingsReader).to receive(:read) - .and_return(new_settings) - end - - it "calculates a proposal using default settings from the config file" do - expect(proposal).to receive(:calculate).with(new_settings) - storage.probe - end - end end describe "#install" do diff --git a/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb b/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb index cc81b67c18..89c68a47f7 100644 --- a/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb +++ b/service/test/agama/storage/proposal_settings_conversion/to_y2storage_test.rb @@ -58,6 +58,7 @@ root_device: "/dev/sda", lvm: true, separate_vgs: true, + lvm_vg_reuse: false, encryption_password: "notsecret", encryption_method: Y2Storage::EncryptionMethod::LUKS2, encryption_pbkdf: Y2Storage::PbkdFunction::ARGON2ID, @@ -269,24 +270,28 @@ expect(y2storage_settings.volumes).to contain_exactly( an_object_having_attributes( - mount_point: "/", - fallback_for_min_size: nil, - fallback_for_max_size: nil + mount_point: "/", + fallback_for_min_size: nil, + fallback_for_max_size: nil, + fallback_for_max_size_lvm: nil ), an_object_having_attributes( - mount_point: "/home", - fallback_for_min_size: "/test", - fallback_for_max_size: nil + mount_point: "/home", + fallback_for_min_size: "/test", + fallback_for_max_size: nil, + fallback_for_max_size_lvm: nil ), an_object_having_attributes( - mount_point: "swap", - fallback_for_min_size: "/test", - fallback_for_max_size: "/test" + mount_point: "swap", + fallback_for_min_size: "/test", + fallback_for_max_size: "/test", + fallback_for_max_size_lvm: "/test" ), an_object_having_attributes( - mount_point: "/test", - fallback_for_min_size: nil, - fallback_for_max_size: nil + mount_point: "/test", + fallback_for_min_size: nil, + fallback_for_max_size: nil, + fallback_for_max_size_lvm: nil ) ) end diff --git a/service/test/agama/storage/proposal_test.rb b/service/test/agama/storage/proposal_test.rb index b6992257df..915172c24f 100644 --- a/service/test/agama/storage/proposal_test.rb +++ b/service/test/agama/storage/proposal_test.rb @@ -70,16 +70,6 @@ it "returns true" do expect(subject.success?).to eq(true) end - - context "but the proposal was invalidated" do - before do - subject.invalidate - end - - it "returns false" do - expect(subject.success?).to eq(false) - end - end end context "and the proposal failed" do @@ -92,18 +82,6 @@ end end - describe "#invalidated?" do - it "returns false if the proposal has not been invalidated yet" do - expect(subject.invalidated?).to eq(false) - end - - it "returns true if the proposal was invalidated" do - subject.invalidate - - expect(subject.invalidated?).to eq(true) - end - end - describe "#calculate" do it "calculates a new proposal with the given settings" do expect(Y2Storage::StorageManager.instance.proposal).to be_nil @@ -150,21 +128,6 @@ ) end end - - context "if the previous proposal was invalidated" do - before do - subject.calculate(achievable_settings) - subject.invalidate - end - - it "sets the new proposal as not invalidated" do - expect(subject.invalidated?).to eq(true) - - subject.calculate(achievable_settings) - - expect(subject.invalidated?).to eq(false) - end - end end describe "#settings" do @@ -201,16 +164,6 @@ ) ) end - - context "and the proposal was invalidated" do - before do - subject.invalidate - end - - it "returns nil" do - expect(proposal.settings).to be_nil - end - end end end @@ -278,6 +231,10 @@ expect(subject.issues).to include( an_object_having_attributes(description: /No device selected/) ) + + expect(subject.issues).to_not include( + an_object_having_attributes(description: /device is not found/) + ) end end diff --git a/service/test/agama/storage/volume_conversion/to_y2storage_test.rb b/service/test/agama/storage/volume_conversion/to_y2storage_test.rb index a1cb60ea91..ba8263aaca 100644 --- a/service/test/agama/storage/volume_conversion/to_y2storage_test.rb +++ b/service/test/agama/storage/volume_conversion/to_y2storage_test.rb @@ -72,6 +72,7 @@ ignore_snapshots_sizes: true, min_size: Y2Storage::DiskSize.GiB(5), max_size: Y2Storage::DiskSize.GiB(20), + max_size_lvm: Y2Storage::DiskSize.GiB(20), snapshots: true, snapshots_configurable?: true, snapshots_size: Y2Storage::DiskSize.GiB(10), @@ -96,7 +97,8 @@ ignore_fallback_sizes: false, ignore_snapshots_sizes: false, min_size: Y2Storage::DiskSize.GiB(10), - max_size: Y2Storage::DiskSize.GiB(50) + max_size: Y2Storage::DiskSize.GiB(50), + max_size_lvm: Y2Storage::DiskSize.GiB(50) ) end end diff --git a/service/test/agama/storage/volume_templates_builder_test.rb b/service/test/agama/storage/volume_templates_builder_test.rb index 9c604bca91..40a1149c84 100644 --- a/service/test/agama/storage/volume_templates_builder_test.rb +++ b/service/test/agama/storage/volume_templates_builder_test.rb @@ -256,8 +256,9 @@ context "when the list of subvolumes mixes strings and hashes" do let(:subvolumes) do [ - "root", "home", - { "path" => "boot", "archs" => "x86_64" }, + "root", + "home", + { "path" => "boot", "archs" => "x86_64, !ppc" }, { "path" => "var", "copy_on_write" => false }, "srv" ] @@ -265,11 +266,36 @@ it "creates the correct list of subvolumes" do subvols = builder.for("/").btrfs.subvolumes - expect(subvols.size).to eq 5 + expect(subvols).to all be_a(Y2Storage::SubvolSpecification) - expect(subvols.map(&:path)).to contain_exactly("home", "root", "srv", "boot", "var") - expect(subvols.map(&:archs)).to contain_exactly(nil, nil, nil, nil, "x86_64") - expect(subvols.map(&:copy_on_write)).to contain_exactly(true, true, true, true, false) + + expect(subvols).to contain_exactly( + an_object_having_attributes( + path: "root", + archs: nil, + copy_on_write: true + ), + an_object_having_attributes( + path: "home", + archs: nil, + copy_on_write: true + ), + an_object_having_attributes( + path: "boot", + archs: ["x86_64", "!ppc"], + copy_on_write: true + ), + an_object_having_attributes( + path: "var", + archs: nil, + copy_on_write: false + ), + an_object_having_attributes( + path: "srv", + archs: nil, + copy_on_write: true + ) + ) end end end diff --git a/web/src/client/storage.js b/web/src/client/storage.js index fc5c26ddaf..11c55f9936 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -224,52 +224,29 @@ class ProposalManager { } /** - * @typedef {object} Volume - * @property {string|undefined} [mountPoint] - * @property {string|undefined} [deviceType] - * @property {boolean|undefined} [optional] - * @property {boolean|undefined} [encrypted] - * @property {boolean|undefined} [fixedSizeLimits] - * @property {boolean|undefined} [adaptiveSizes] - * @property {number|undefined} [minSize] - * @property {number|undefined} [maxSize] - * @property {string[]} [fsTypes] - * @property {string|undefined} [fsType] - * @property {boolean|undefined} [snapshots] - * @property {boolean|undefined} [snapshotsConfigurable] - * @property {boolean|undefined} [snapshotsAffectSizes] - * @property {string[]} [sizeRelevantVolumes] - * - * @typedef {object} Action - * @property {string} text - * @property {boolean} subvol - * @property {boolean} delete - * - * @typedef {object} Result - * @property {string[]} candidateDevices + * @typedef {object} ProposalSettings + * @property {string} bootDevice * @property {boolean} lvm * @property {string} encryptionPassword * @property {Volume[]} volumes - * @property {Action[]} actions - */ - - /** - * Gets data associated to the proposal * - * @returns {Promise} + * @typedef {object} Volume + * @property {string} mountPath + * @property {string} fsType + * @property {number} minSize + * @property {number} [maxSize] + * @property {boolean} autoSize + * @property {boolean} snapshots + * @property {VolumeOutline} outline * - * @typedef {object} ProposalData - * @property {StorageDevice[]} availableDevices - * @property {Volume[]} volumeTemplates - * @property {Result|undefined} result + * @typedef {object} VolumeOutline + * @property {boolean} required + * @property {string[]} fsTypes + * @property {boolean} supportAutoSize + * @property {boolean} snapshotsConfigurable + * @property {boolean} snapshotsAffectSizes + * @property {string[]} sizeRelevantVolumes */ - async getData() { - const availableDevices = await this.getAvailableDevices(); - const volumeTemplates = await this.getVolumeTemplates(); - const result = await this.getResult(); - - return { availableDevices, volumeTemplates, result }; - } /** * Gets the list of available devices @@ -293,19 +270,39 @@ class ProposalManager { } /** - * Gets the list of volume templates for the selected product + * Gets the list of meaningful mount points for the selected product * - * @returns {Promise} + * @returns {Promise} */ - async getVolumeTemplates() { + async getProductMountPoints() { const proxy = await this.proxies.proposalCalculator; - return proxy.VolumeTemplates.map(this.buildVolume); + return proxy.ProductMountPoints; + } + + /** + * Obtains the default volume for the given mount path + * + * @param {string} mountPath + * @returns {Promise} + */ + async defaultVolume(mountPath) { + const proxy = await this.proxies.proposalCalculator; + return this.buildVolume(await proxy.DefaultVolume(mountPath)); } /** * Gets the values of the current proposal * - * @return {Promise} + * @return {Promise} + * + * @typedef {object} ProposalResult + * @property {ProposalSettings} settings + * @property {Action[]} actions + * + * @typedef {object} Action + * @property {string} text + * @property {boolean} subvol + * @property {boolean} delete */ async getResult() { const proxy = await this.proposalProxy(); @@ -322,10 +319,12 @@ class ProposalManager { }; return { - candidateDevices: proxy.CandidateDevices, - lvm: proxy.LVM, - encryptionPassword: proxy.EncryptionPassword, - volumes: proxy.Volumes.map(this.buildVolume), + settings: { + bootDevice: proxy.BootDevice, + lvm: proxy.LVM, + encryptionPassword: proxy.EncryptionPassword, + volumes: proxy.Volumes.map(this.buildVolume), + }, actions: proxy.Actions.map(buildAction) }; }; @@ -336,31 +335,23 @@ class ProposalManager { /** * Calculates a new proposal * - * @param {Settings} settings - * - * @typedef {object} Settings - * @property {string[]} [candidateDevices] - Devices to use for the proposal - * @property {string} [encryptionPassword] - Password for encrypting devices - * @property {boolean} [lvm] - Whether to calculate the proposal with LVM volumes - * @property {Volume[]} [volumes] - Volumes to create - * + * @param {ProposalSettings} settings * @returns {Promise} 0 on success, 1 on failure */ - async calculate({ candidateDevices, encryptionPassword, lvm, volumes }) { + async calculate({ bootDevice, encryptionPassword, lvm, volumes }) { const dbusVolume = (volume) => { return removeUndefinedCockpitProperties({ - MountPoint: { t: "s", v: volume.mountPoint }, - Encrypted: { t: "b", v: volume.encrypted }, + MountPath: { t: "s", v: volume.mountPath }, FsType: { t: "s", v: volume.fsType }, - MinSize: { t: "x", v: volume.minSize }, - MaxSize: { t: "x", v: volume.maxSize }, - FixedSizeLimits: { t: "b", v: volume.fixedSizeLimits }, + MinSize: { t: "t", v: volume.minSize }, + MaxSize: { t: "t", v: volume.maxSize }, + AutoSize: { t: "b", v: volume.autoSize }, Snapshots: { t: "b", v: volume.snapshots } }); }; const settings = removeUndefinedCockpitProperties({ - CandidateDevices: { t: "as", v: candidateDevices }, + BootDevice: { t: "s", v: bootDevice }, EncryptionPassword: { t: "s", v: encryptionPassword }, LVM: { t: "b", v: lvm }, Volumes: { t: "aa{sv}", v: volumes?.map(dbusVolume) } @@ -377,20 +368,21 @@ class ProposalManager { * @param {DBusVolume} dbusVolume * * @typedef {Object} DBusVolume - * @property {CockpitString} [MountPoint] - * @property {CockpitString} [DeviceType] - * @property {CockpitBoolean} [Optional] - * @property {CockpitBoolean} [Encrypted] - * @property {CockpitBoolean} [FixedSizeLimits] - * @property {CockpitBoolean} [AdaptiveSizes] - * @property {CockpitNumber} [MinSize] + * @property {CockpitString} MountPath + * @property {CockpitString} FsType + * @property {CockpitNumber} MinSize * @property {CockpitNumber} [MaxSize] - * @property {CockpitAString} [FsTypes] - * @property {CockpitString} [FsType] - * @property {CockpitBoolean} [Snapshots] - * @property {CockpitBoolean} [SnapshotsConfigurable] - * @property {CockpitBoolean} [SnapshotsAffectSizes] - * @property {CockpitAString} [SizeRelevantVolumes] + * @property {CockpitBoolean} AutoSize + * @property {CockpitBoolean} Snapshots + * @property {CockpitVolumeOutline} Outline + * + * @typedef {Object} DBusVolumeOutline + * @property {CockpitBoolean} Required + * @property {CockpitAString} FsTypes + * @property {CockpitBoolean} SupportAutoSize + * @property {CockpitBoolean} SnapshotsConfigurable + * @property {CockpitBoolean} SnapshotsAffectSizes + * @property {CockpitAString} SizeRelevantVolumes * * @typedef {Object} CockpitString * @property {string} t - variant type @@ -408,30 +400,34 @@ class ProposalManager { * @property {string} t - variant type * @property {string[]} v - value * + * @typedef {Object} CockpitVolumeOutline + * @property {string} t - variant type + * @property {DBusVolumeOutline} v - value + * * @returns {Volume} */ buildVolume(dbusVolume) { - const buildList = (value) => { - if (value === undefined) return []; + const buildOutline = (dbusOutline) => { + if (dbusOutline === undefined) return null; - return value.map(val => val.v); + return { + required: dbusOutline.Required.v, + fsTypes: dbusOutline.FsTypes.v.map(val => val.v), + supportAutoSize: dbusOutline.SupportAutoSize.v, + snapshotsConfigurable: dbusOutline.SnapshotsConfigurable.v, + snapshotsAffectSizes: dbusOutline.SnapshotsAffectSizes.v, + sizeRelevantVolumes: dbusOutline.SizeRelevantVolumes.v.map(val => val.v) + }; }; return { - mountPoint: dbusVolume.MountPoint?.v, - deviceType: dbusVolume.DeviceType?.v, - optional: dbusVolume.Optional?.v, - encrypted: dbusVolume.Encrypted?.v, - fixedSizeLimits: dbusVolume.FixedSizeLimits?.v, - adaptiveSizes: dbusVolume.AdaptiveSizes?.v, - minSize: dbusVolume.MinSize?.v, + mountPath: dbusVolume.MountPath.v, + fsType: dbusVolume.FsType.v, + minSize: dbusVolume.MinSize.v, maxSize: dbusVolume.MaxSize?.v, - fsTypes: buildList(dbusVolume.FsTypes?.v), - fsType: dbusVolume.FsType?.v, - snapshots: dbusVolume.Snapshots?.v, - snapshotsConfigurable: dbusVolume.SnapshotsConfigurable?.v, - snapshotsAffectSizes: dbusVolume.SnapshotsAffectSizes?.v, - sizeRelevantVolumes: buildList(dbusVolume.SizeRelevantVolumes?.v) + autoSize: dbusVolume.AutoSize.v, + snapshots: dbusVolume.Snapshots.v, + outline: buildOutline(dbusVolume.Outline.v) }; } diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index 9fbaf79ee2..5b3fbd254f 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -33,28 +33,6 @@ const cockpitCallbacks = {}; let managedObjects = {}; -const volumes = [ - { - MountPoint: { t: "s", v: "/test1" }, - DeviceType: { t: "s", v: "partition" }, - Optional: { t: "b", v: true }, - Encrypted: { t: "b", v: false }, - FixedSizeLimits: { t: "b", v: false }, - AdaptiveSizes: { t: "b", v: false }, - MinSize: { t: "x", v: 1024 }, - MaxSize: { t: "x", v: 2048 }, - FsTypes: { t: "as", v: [{ t: "s", v: "Btrfs" }, { t: "s", v: "Ext3" }] }, - FsType: { t: "s", v: "Btrfs" }, - Snapshots: { t: "b", v: true }, - SnapshotsConfigurable: { t: "b", v: true }, - SnapshotsAffectSizes: { t: "b", v: false }, - SizeRelevantVolumes: { t: "as", v: [] } - }, - { - MountPoint: { t: "s", v: "/test2" } - } -]; - const systemDevices = { sda: { sid: "59", @@ -173,9 +151,49 @@ const contexts = { }, withProposal: () => { cockpitProxies.proposal = { - CandidateDevices:["/dev/sda"], + BootDevice: "/dev/sda", LVM: true, - Volumes: volumes, + EncryptionPassword: "00000", + Volumes: [ + { + MountPath: { t: "s", v: "/" }, + FsType: { t: "s", v: "Btrfs" }, + MinSize: { t: "x", v: 1024 }, + MaxSize: { t: "x", v: 2048 }, + AutoSize: { t: "b", v: true }, + Snapshots: { t: "b", v: true }, + Outline: { + t: "a{sv}", + v: { + Required: { t: "b", v: true }, + FsTypes: { t: "as", v: [{ t: "s", v: "Btrfs" }, { t: "s", v: "Ext3" }] }, + SupportAutoSize: { t: "b", v: true }, + SnapshotsConfigurable: { t: "b", v: true }, + SnapshotsAffectSizes: { t: "b", v: true }, + SizeRelevantVolumes: { t: "as", v: [{ t: "s", v: "/home" }] } + } + } + }, + { + MountPath: { t: "s", v: "/home" }, + FsType: { t: "s", v: "XFS" }, + MinSize: { t: "x", v: 2048 }, + MaxSize: { t: "x", v: 4096 }, + AutoSize: { t: "b", v: false }, + Snapshots: { t: "b", v: false }, + Outline: { + t: "a{sv}", + v: { + Required: { t: "b", v: false }, + FsTypes: { t: "as", v: [{ t: "s", v: "Ext4" }, { t: "s", v: "XFS" }] }, + SupportAutoSize: { t: "b", v: false }, + SnapshotsConfigurable: { t: "b", v: false }, + SnapshotsAffectSizes: { t: "b", v: false }, + SizeRelevantVolumes: { t: "as", v: [] } + } + } + } + ], Actions: [ { Text: { t: "s", v: "Mount /dev/sdb1 as root" }, @@ -191,9 +209,6 @@ const contexts = { "/org/opensuse/Agama/Storage1/system/60" ]; }, - withVolumeTemplates: () => { - cockpitProxies.proposalCalculator.VolumeTemplates = volumes; - }, withoutIssues: () => { cockpitProxies.issues = { All: [] @@ -660,79 +675,117 @@ describe("#system", () => { }); describe("#proposal", () => { - const checkAvailableDevices = (availableDevices) => { - expect(availableDevices).toEqual([systemDevices.sda, systemDevices.sdb]); - }; - - const checkVolumes = (volumes) => { - expect(volumes.length).toEqual(2); - expect(volumes[0]).toEqual({ - mountPoint: "/test1", - deviceType: "partition", - optional: true, - encrypted: false, - fixedSizeLimits: false, - adaptiveSizes: false, - minSize: 1024, - maxSize:2048, - fsTypes: ["Btrfs", "Ext3"], - fsType: "Btrfs", - snapshots: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [] - }); - expect(volumes[1].mountPoint).toEqual("/test2"); - }; - - const checkProposalResult = (result) => { - expect(result.candidateDevices).toEqual(["/dev/sda"]); - expect(result.lvm).toBeTruthy(); - expect(result.actions).toEqual([ - { text: "Mount /dev/sdb1 as root", subvol: false, delete: false } - ]); - checkVolumes(result.volumes); - }; - - describe("#getData", () => { + describe("#getAvailableDevices", () => { beforeEach(() => { contexts.withSystemDevices(); contexts.withAvailableDevices(); - contexts.withVolumeTemplates(); - contexts.withProposal(); client = new StorageClient(); }); - it("returns the available devices, templates and the proposal result", async () => { - const { availableDevices, volumeTemplates, result } = await client.proposal.getData(); - checkAvailableDevices(availableDevices); - checkVolumes(volumeTemplates); - checkProposalResult(result); + it("returns the list of available devices", async () => { + const availableDevices = await client.proposal.getAvailableDevices(); + expect(availableDevices).toEqual([systemDevices.sda, systemDevices.sdb]); }); }); - describe("#getAvailableDevices", () => { + describe("#getProductMountPoints", () => { beforeEach(() => { - contexts.withSystemDevices(); - contexts.withAvailableDevices(); + cockpitProxies.proposalCalculator.ProductMountPoints = ["/", "swap", "/home"]; client = new StorageClient(); }); - it("returns the list of available devices", async () => { - const availableDevices = await client.proposal.getAvailableDevices(); - checkAvailableDevices(availableDevices); + it.only("returns the list of product mount points", async () => { + const mount_points = await client.proposal.getProductMountPoints(); + expect(mount_points).toEqual(["/", "swap", "/home"]); }); }); - describe("#getVolumeTemplates", () => { + describe("#defaultVolume", () => { beforeEach(() => { - contexts.withVolumeTemplates(); + cockpitProxies.proposalCalculator.DefaultVolume = jest.fn(mountPath => { + switch (mountPath) { + case "/home": return { + MountPath: { t: "s", v: "/home" }, + FsType: { t: "s", v: "XFS" }, + MinSize: { t: "x", v: 2048 }, + MaxSize: { t: "x", v: 4096 }, + AutoSize: { t: "b", v: false }, + Snapshots: { t: "b", v: false }, + Outline: { + t: "a{sv}", + v: { + Required: { t: "b", v: false }, + FsTypes: { t: "as", v: [{ t: "s", v: "Ext4" }, { t: "s", v: "XFS" }] }, + SupportAutoSize: { t: "b", v: false }, + SnapshotsConfigurable: { t: "b", v: false }, + SnapshotsAffectSizes: { t: "b", v: false }, + SizeRelevantVolumes: { t: "as", v: [] } + } + } + }; + case "": return { + MountPath: { t: "s", v: "" }, + FsType: { t: "s", v: "Ext4" }, + MinSize: { t: "x", v: 1024 }, + MaxSize: { t: "x", v: 2048 }, + AutoSize: { t: "b", v: false }, + Snapshots: { t: "b", v: false }, + Outline: { + t: "a{sv}", + v: { + Required: { t: "b", v: false }, + FsTypes: { t: "as", v: [{ t: "s", v: "Ext4" }, { t: "s", v: "XFS" }] }, + SupportAutoSize: { t: "b", v: false }, + SnapshotsConfigurable: { t: "b", v: false }, + SnapshotsAffectSizes: { t: "b", v: false }, + SizeRelevantVolumes: { t: "as", v: [] } + } + } + }; + } + }); + client = new StorageClient(); }); - it("returns the list of available volume templates", async () => { - const volumeTemplates = await client.proposal.getVolumeTemplates(); - checkVolumes(volumeTemplates); + it("returns the default volume for the given path", async () => { + const home = await client.proposal.defaultVolume("/home"); + + expect(home).toStrictEqual({ + mountPath: "/home", + fsType: "XFS", + minSize: 2048, + maxSize: 4096, + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Ext4", "XFS"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } + }); + + const generic = await client.proposal.defaultVolume(""); + + expect(generic).toStrictEqual({ + mountPath: "", + fsType: "Ext4", + minSize: 1024, + maxSize: 2048, + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Ext4", "XFS"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } + }); }); }); @@ -756,8 +809,51 @@ describe("#proposal", () => { }); it("returns the proposal settings and actions", async () => { - const result = await client.proposal.getResult(); - checkProposalResult(result); + const { settings, actions } = await client.proposal.getResult(); + + expect(settings).toStrictEqual({ + bootDevice: "/dev/sda", + lvm: true, + encryptionPassword: "00000", + volumes: [ + { + mountPath: "/", + fsType: "Btrfs", + minSize: 1024, + maxSize: 2048, + autoSize: true, + snapshots: true, + outline: { + required: true, + fsTypes: ["Btrfs", "Ext3"], + supportAutoSize: true, + snapshotsConfigurable: true, + snapshotsAffectSizes: true, + sizeRelevantVolumes: ["/home"] + } + }, + { + mountPath: "/home", + fsType: "XFS", + minSize: 2048, + maxSize: 4096, + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Ext4", "XFS"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } + } + ] + }); + + expect(actions).toStrictEqual([ + { text: "Mount /dev/sdb1 as root", subvol: false, delete: false } + ]); }); }); }); @@ -778,45 +874,43 @@ describe("#proposal", () => { it("calculates a proposal with the given settings", async () => { await client.proposal.calculate({ - candidateDevices: ["/dev/vda"], + bootDevice: "/dev/vdb", encryptionPassword: "12345", lvm: true, volumes: [ { - mountPoint: "/test1", - encrypted: false, + mountPath: "/test1", fsType: "Btrfs", minSize: 1024, - maxSize:2048, - fixedSizeLimits: false, + maxSize: 2048, + autoSize: false, snapshots: true }, { - mountPoint: "/test2", + mountPath: "/test2", minSize: 1024 } ] }); expect(cockpitProxies.proposalCalculator.Calculate).toHaveBeenCalledWith({ - CandidateDevices: { t: "as", v: ["/dev/vda"] }, + BootDevice: { t: "s", v: "/dev/vdb" }, EncryptionPassword: { t: "s", v: "12345" }, LVM: { t: "b", v: true }, Volumes: { t: "aa{sv}", v: [ { - MountPoint: { t: "s", v: "/test1" }, - Encrypted: { t: "b", v: false }, + MountPath: { t: "s", v: "/test1" }, FsType: { t: "s", v: "Btrfs" }, - MinSize: { t: "x", v: 1024 }, - MaxSize: { t: "x", v: 2048 }, - FixedSizeLimits: { t: "b", v: false }, + MinSize: { t: "t", v: 1024 }, + MaxSize: { t: "t", v: 2048 }, + AutoSize: { t: "b", v: false }, Snapshots: { t: "b", v: true } }, { - MountPoint: { t: "s", v: "/test2" }, - MinSize: { t: "x", v: 1024 } + MountPath: { t: "s", v: "/test2" }, + MinSize: { t: "t", v: 1024 } } ] } diff --git a/web/src/components/overview/StorageSection.jsx b/web/src/components/overview/StorageSection.jsx index 69d99077f9..e79d5275d0 100644 --- a/web/src/components/overview/StorageSection.jsx +++ b/web/src/components/overview/StorageSection.jsx @@ -30,14 +30,14 @@ import { Em, ProgressText, Section } from "~/components/core"; import { _ } from "~/i18n"; const ProposalSummary = ({ proposal }) => { - const { result } = proposal; + const { availableDevices = [], result = {} } = proposal; - if (result === undefined) return {_("Device not selected yet")}; + const bootDevice = result.settings?.bootDevice; + if (!bootDevice) return {_("No device selected yet")}; - const [candidateDevice] = result.candidateDevices; - const device = proposal.availableDevices.find(d => d.name === candidateDevice); + const device = availableDevices.find(d => d.name === bootDevice); - const label = device ? deviceLabel(device) : candidateDevice; + const label = device ? deviceLabel(device) : bootDevice; // TRANSLATORS: %s will be replaced by the device name and its size, // example: "/dev/sda, 20 GiB" @@ -51,6 +51,7 @@ const ProposalSummary = ({ proposal }) => { const initialState = { busy: true, + deprecated: false, proposal: undefined, errors: [], progress: { message: _("Probing storage devices"), current: 0, total: 0 } @@ -67,6 +68,10 @@ const reducer = (state, action) => { return { ...initialState, busy: action.payload.status === BUSY }; } + case "UPDATE_DEPRECATED": { + return { ...state, deprecated: action.payload.deprecated }; + } + case "UPDATE_PROPOSAL": { if (state.busy) return state; @@ -104,19 +109,36 @@ export default function StorageSection({ showErrors = false }) { }, [client, cancellablePromise]); useEffect(() => { - const updateProposal = async () => { - const isDeprecated = await cancellablePromise(client.isDeprecated()); - if (isDeprecated) await cancellablePromise(client.probe()); + const updateDeprecated = async (deprecated) => { + dispatch({ type: "UPDATE_DEPRECATED", payload: { deprecated } }); + + if (deprecated) { + const result = await cancellablePromise(client.proposal.getResult()); + await cancellablePromise(client.probe()); + if (result.settings) await cancellablePromise(client.proposal.calculate(result.settings)); + dispatch({ type: "UPDATE_DEPRECATED", payload: { deprecated: false } }); + } + }; + + cancellablePromise(client.isDeprecated()).then(updateDeprecated); - const proposal = await cancellablePromise(client.proposal.getData()); + return client.onDeprecate(() => updateDeprecated(true)); + }, [client, cancellablePromise]); + + useEffect(() => { + const updateProposal = async () => { + const proposal = { + availableDevices: await cancellablePromise(client.proposal.getAvailableDevices()), + result: await cancellablePromise(client.proposal.getResult()) + }; const issues = await cancellablePromise(client.getErrors()); const errors = issues.map(toValidationError); dispatch({ type: "UPDATE_PROPOSAL", payload: { proposal, errors } }); }; - if (!state.busy) updateProposal(); - }, [client, cancellablePromise, state.busy]); + if (!state.busy && !state.deprecated) updateProposal(); + }, [client, cancellablePromise, state.busy, state.deprecated]); useEffect(() => { cancellablePromise(client.getProgress()).then(({ message, current, total }) => { @@ -136,10 +158,6 @@ export default function StorageSection({ showErrors = false }) { }); }, [client, cancellablePromise]); - useEffect(() => { - return client.onDeprecate(() => client.probe()); - }, [client]); - const errors = showErrors ? state.errors : []; const busy = state.busy || !state.proposal; @@ -153,7 +171,7 @@ export default function StorageSection({ showErrors = false }) { } return ( - + ); }; diff --git a/web/src/components/overview/StorageSection.test.jsx b/web/src/components/overview/StorageSection.test.jsx index 209a1bf8c1..fed8bd70f8 100644 --- a/web/src/components/overview/StorageSection.test.jsx +++ b/web/src/components/overview/StorageSection.test.jsx @@ -30,37 +30,54 @@ import { StorageSection } from "~/components/overview"; jest.mock("~/client"); jest.mock("~/components/core/SectionSkeleton", () => mockComponent("Loading storage")); -let status = IDLE; -let proposal = { - availableDevices: [ - { name: "/dev/sda", size: 536870912000 }, - { name: "/dev/sdb", size: 697932185600 } - ], - result: { - candidateDevices: ["/dev/sda"], +const availableDevices = [ + { name: "/dev/sda", size: 536870912000 }, + { name: "/dev/sdb", size: 697932185600 } +]; + +const proposalResult = { + settings: { + bootDevice: "/dev/sda", lvm: false - } + }, + actions: [] }; -let errors = []; -let onStatusChangeFn = jest.fn(); + +const storageMock = { + probe: jest.fn().mockResolvedValue(0), + proposal: { + getAvailableDevices: jest.fn().mockResolvedValue(availableDevices), + getResult: jest.fn().mockResolvedValue(proposalResult), + calculate: jest.fn().mockResolvedValue(0) + }, + getStatus: jest.fn().mockResolvedValue(IDLE), + getProgress: jest.fn().mockResolvedValue({ + message: "Activating storage devices", current: 1, total: 4 + }), + onProgressChange: noop, + getErrors: jest.fn().mockResolvedValue([]), + onStatusChange: jest.fn(), + isDeprecated: jest.fn().mockResolvedValue(false), + onDeprecate: noop +}; + +let storage; beforeEach(() => { - createClient.mockImplementation(() => { - return { - storage: { - proposal: { getData: jest.fn().mockResolvedValue(proposal) }, - getStatus: jest.fn().mockResolvedValue(status), - getProgress: jest.fn().mockResolvedValue({ - message: "Activating storage devices", current: 1, total: 4 - }), - onProgressChange: noop, - getErrors: jest.fn().mockResolvedValue(errors), - onStatusChange: onStatusChangeFn, - isDeprecated: jest.fn().mockResolvedValue(false), - onDeprecate: noop - }, - }; - }); + storage = { ...storageMock, proposal: { ...storageMock.proposal } }; + + createClient.mockImplementation(() => ({ storage })); +}); + +it("probes storage if the storage devices are deprecated", async () => { + storage.isDeprecated = jest.fn().mockResolvedValue(true); + installerRender(); + await waitFor(() => expect(storage.probe).toHaveBeenCalled()); +}); + +it("does not probe storage if the storage devices are not deprecated", async () => { + installerRender(); + await waitFor(() => expect(storage.probe).not.toHaveBeenCalled()); }); describe("when there is a proposal", () => { @@ -72,9 +89,23 @@ describe("when there is a proposal", () => { await screen.findByText(/and deleting all its content/); }); + describe("and there is no boot device", () => { + beforeEach(() => { + const result = { settings: { bootDevice: "" } }; + storage.proposal.getResult = jest.fn().mockResolvedValue(result); + }); + + it("indicates that a device is not selected", async () => { + installerRender(); + + await screen.findByText(/No device selected/); + }); + }); + describe("with errors", () => { beforeEach(() => { - errors = [{ description: "Cannot make a proposal" }]; + const errors = [{ description: "Cannot make a proposal" }]; + storage.getErrors = jest.fn().mockResolvedValue(errors); }); describe("and component has received the showErrors prop", () => { @@ -89,15 +120,17 @@ describe("when there is a proposal", () => { it("does not render errors", async () => { installerRender(); - await waitFor(() => expect(screen.queryByText("Fake error")).not.toBeInTheDocument()); + await waitFor(() => { + expect(screen.queryByText("Cannot make a proposal")).not.toBeInTheDocument(); + }); }); }); }); - describe("but service status changes to busy", () => { + describe("and service status changes to busy", () => { it("renders the progress", async () => { const [mockFunction, callbacks] = createCallbackMock(); - onStatusChangeFn = mockFunction; + storage.onStatusChange = mockFunction; installerRender(); @@ -114,8 +147,9 @@ describe("when there is a proposal", () => { describe("when there is no proposal yet", () => { beforeEach(() => { - proposal = { result: undefined }; - errors = [{ description: "Fake error" }]; + storage.proposal.getResult = jest.fn().mockResolvedValue(undefined); + const errors = [{ description: "Fake error" }]; + storage.getErrors = jest.fn().mockResolvedValue(errors); }); it("renders the progress", async () => { @@ -131,10 +165,11 @@ describe("when there is no proposal yet", () => { }); }); -describe("but storage service is busy", () => { +describe("when storage service is busy", () => { beforeEach(() => { - status = BUSY; - errors = [{ description: "Fake error" }]; + storage.getStatus = jest.fn().mockResolvedValue(BUSY); + const errors = [{ description: "Fake error" }]; + storage.getErrors = jest.fn().mockResolvedValue(errors); }); it("renders the progress", async () => { @@ -149,3 +184,17 @@ describe("but storage service is busy", () => { await waitFor(() => expect(screen.queryByText("Fake error")).not.toBeInTheDocument()); }); }); + +describe("when the storage devices become deprecated", () => { + it("probes storage", async () => { + const [mockFunction, callbacks] = createCallbackMock(); + storage.onDeprecate = mockFunction; + + installerRender(); + + const [onDeprecateCb] = callbacks; + await act(() => onDeprecateCb()); + + await waitFor(() => expect(storage.probe).toHaveBeenCalled()); + }); +}); diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index 673e54ed60..b9d8488697 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -49,18 +49,19 @@ const reducer = (state, action) => { return { ...state, loading: false }; } - case "UPDATE_PROPOSAL": { - const { proposal, errors } = action.payload; - const { availableDevices, volumeTemplates, result = {} } = proposal; - const { candidateDevices, lvm, encryptionPassword, volumes, actions } = result; - return { - ...state, - availableDevices, - volumeTemplates, - settings: { candidateDevices, lvm, encryptionPassword, volumes }, - actions, - errors - }; + case "UPDATE_AVAILABLE_DEVICES": { + const { availableDevices } = action.payload; + return { ...state, availableDevices }; + } + + case "UPDATE_VOLUME_TEMPLATES": { + const { volumeTemplates } = action.payload; + return { ...state, volumeTemplates }; + } + + case "UPDATE_RESULT": { + const { settings, actions } = action.payload.result; + return { ...state, settings, actions }; } case "UPDATE_SETTINGS": { @@ -68,6 +69,11 @@ const reducer = (state, action) => { return { ...state, settings }; } + case "UPDATE_ERRORS": { + const { errors } = action.payload; + return { ...state, errors }; + } + default: { return state; } @@ -79,33 +85,73 @@ export default function ProposalPage() { const { cancellablePromise } = useCancellablePromise(); const [state, dispatch] = useReducer(reducer, initialState); - const loadProposal = useCallback(async () => { - const proposal = await cancellablePromise(client.proposal.getData()); + const loadAvailableDevices = useCallback(async () => { + return await cancellablePromise(client.proposal.getAvailableDevices()); + }, [client, cancellablePromise]); + + const loadVolumeTemplates = useCallback(async () => { + const mountPoints = await cancellablePromise(client.proposal.getProductMountPoints()); + const volumeTemplates = []; + + for (const mountPoint of mountPoints) { + volumeTemplates.push(await cancellablePromise(client.proposal.defaultVolume(mountPoint))); + } + + volumeTemplates.push(await cancellablePromise(client.proposal.defaultVolume(""))); + return volumeTemplates; + }, [client, cancellablePromise]); + + const loadProposalResult = useCallback(async () => { + return await cancellablePromise(client.proposal.getResult()); + }, [client, cancellablePromise]); + + const loadErrors = useCallback(async () => { const issues = await cancellablePromise(client.getErrors()); - const errors = issues.map(toValidationError); - return { proposal, errors }; + return issues.map(toValidationError); + }, [client, cancellablePromise]); + + const calculateProposal = useCallback(async (settings) => { + return await cancellablePromise(client.proposal.calculate(settings)); }, [client, cancellablePromise]); const load = useCallback(async () => { dispatch({ type: "START_LOADING" }); const isDeprecated = await cancellablePromise(client.isDeprecated()); - if (isDeprecated) await client.probe(); + if (isDeprecated) { + const result = await loadProposalResult(); + await cancellablePromise(client.probe()); + if (result?.settings) await calculateProposal(result.settings); + } - const { proposal, errors } = await loadProposal(); - dispatch({ type: "UPDATE_PROPOSAL", payload: { proposal, errors } }); - if (proposal.result !== undefined) dispatch({ type: "STOP_LOADING" }); - }, [cancellablePromise, client, loadProposal]); + const availableDevices = await loadAvailableDevices(); + dispatch({ type: "UPDATE_AVAILABLE_DEVICES", payload: { availableDevices } }); + + const volumeTemplates = await loadVolumeTemplates(); + dispatch({ type: "UPDATE_VOLUME_TEMPLATES", payload: { volumeTemplates } }); + + const result = await loadProposalResult(); + if (result !== undefined) dispatch({ type: "UPDATE_RESULT", payload: { result } }); + + const errors = await loadErrors(); + dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); + + if (result !== undefined) dispatch({ type: "STOP_LOADING" }); + }, [calculateProposal, cancellablePromise, client, loadAvailableDevices, loadErrors, loadProposalResult, loadVolumeTemplates]); const calculate = useCallback(async (settings) => { dispatch({ type: "START_LOADING" }); - await cancellablePromise(client.proposal.calculate(settings)); + await calculateProposal(settings); + + const result = await loadProposalResult(); + dispatch({ type: "UPDATE_RESULT", payload: { result } }); + + const errors = await loadErrors(); + dispatch({ type: "UPDATE_ERRORS", payload: { errors } }); - const { proposal, errors } = await loadProposal(); - dispatch({ type: "UPDATE_PROPOSAL", payload: { proposal, errors } }); dispatch({ type: "STOP_LOADING" }); - }, [cancellablePromise, client, loadProposal]); + }, [calculateProposal, loadErrors, loadProposalResult]); useEffect(() => { load().catch(console.error); @@ -114,7 +160,7 @@ export default function ProposalPage() { }, [client, load]); useEffect(() => { - const proposalLoaded = () => state.settings.candidateDevices !== undefined; + const proposalLoaded = () => state.settings.bootDevice !== undefined; const statusHandler = (serviceStatus) => { // Load the proposal if no proposal has been loaded yet. This can happen if the proposal @@ -138,8 +184,10 @@ export default function ProposalPage() { // Templates for already existing mount points are filtered out const usefulTemplates = () => { const volumes = state.settings.volumes || []; - const mountPoints = volumes.map(v => v.mountPoint); - return state.volumeTemplates.filter(t => !mountPoints.includes(t.mountPoint)); + const mountPaths = volumes.map(v => v.mountPath); + return state.volumeTemplates.filter(t => ( + t.mountPath.length > 0 && !mountPaths.includes(t.mountPath) + )); }; return ( diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index b106cd7e07..2d2593f68f 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -38,61 +38,44 @@ jest.mock("@patternfly/react-core", () => { }; }); -const defaultProposalData = { - availableDevices: [], - volumeTemplates: [], - result: { - candidateDevices: ["/dev/vda"], - lvm: false, - encryptionPassword: "", - volumes: [] - } +const storageMock = { + probe: jest.fn().mockResolvedValue(0), + proposal: { + getAvailableDevices: jest.fn().mockResolvedValue([]), + getProductMountPoints: jest.fn().mockResolvedValue([]), + getResult: jest.fn().mockResolvedValue(undefined), + defaultVolume: jest.fn(mountPath => Promise.resolve({ mountPath })), + calculate: jest.fn().mockResolvedValue(0) + }, + getErrors: jest.fn().mockResolvedValue([]), + isDeprecated: jest.fn().mockResolvedValue(false), + onDeprecate: jest.fn(), + onStatusChange: jest.fn() }; -let proposalData; - -const probeFn = jest.fn().mockResolvedValue(0); - -const isDeprecatedFn = jest.fn(); - -let onDeprecateFn = jest.fn(); - -let onStatusChangeFn = jest.fn(); +let storage; beforeEach(() => { - isDeprecatedFn.mockResolvedValue(false); - - proposalData = { ...defaultProposalData }; - - createClient.mockImplementation(() => { - return { - storage: { - probe: probeFn, - proposal: { - getData: jest.fn().mockResolvedValue(proposalData), - calculate: jest.fn().mockResolvedValue(0) - }, - getErrors: jest.fn().mockResolvedValue([]), - isDeprecated: isDeprecatedFn, - onDeprecate: onDeprecateFn, - onStatusChange: onStatusChangeFn - } - }; - }); + storage = { ...storageMock, proposal: { ...storageMock.proposal } }; + createClient.mockImplementation(() => ({ storage })); }); it("probes storage if the storage devices are deprecated", async () => { - isDeprecatedFn.mockResolvedValue(true); + storage.isDeprecated = jest.fn().mockResolvedValue(true); installerRender(); - await waitFor(() => expect(probeFn).toHaveBeenCalled()); + await waitFor(() => expect(storage.probe).toHaveBeenCalled()); }); it("does not probe storage if the storage devices are not deprecated", async () => { installerRender(); - await waitFor(() => expect(probeFn).not.toHaveBeenCalled()); + await waitFor(() => expect(storage.probe).not.toHaveBeenCalled()); }); it("loads the proposal data", async () => { + storage.proposal.getResult = jest.fn().mockResolvedValue( + { settings: { bootDevice: "/dev/vda" } } + ); + installerRender(); screen.getAllByText(/PFSkeleton/); @@ -117,24 +100,29 @@ it("renders the settings and actions sections", async () => { describe("when the storage devices become deprecated", () => { it("probes storage", async () => { const [mockFunction, callbacks] = createCallbackMock(); - onDeprecateFn = mockFunction; + storage.onDeprecate = mockFunction; + installerRender(); - isDeprecatedFn.mockResolvedValue(true); + storage.isDeprecated = jest.fn().mockResolvedValue(true); const [onDeprecateCb] = callbacks; await act(() => onDeprecateCb()); - await waitFor(() => expect(probeFn).toHaveBeenCalled()); + await waitFor(() => expect(storage.probe).toHaveBeenCalled()); }); it("loads the proposal data", async () => { + const result = { settings: { bootDevice: "/dev/vda" } }; + storage.proposal.getResult = jest.fn().mockResolvedValue(result); + const [mockFunction, callbacks] = createCallbackMock(); - onDeprecateFn = mockFunction; + storage.onDeprecate = mockFunction; + installerRender(); await screen.findByText("/dev/vda"); - proposalData.result = { ...defaultProposalData.result, candidateDevices: ["/dev/vdb"] }; + result.settings.bootDevice = "/dev/vdb"; const [onDeprecateCb] = callbacks; await act(() => onDeprecateCb()); @@ -145,7 +133,7 @@ describe("when the storage devices become deprecated", () => { describe("when there is no proposal yet", () => { beforeEach(() => { - proposalData.result = undefined; + storage.proposal.getResult = jest.fn().mockResolvedValue(undefined); }); it("shows the page as loading", async () => { @@ -157,12 +145,15 @@ describe("when there is no proposal yet", () => { it("loads the proposal when the service finishes to calculate", async () => { const [mockFunction, callbacks] = createCallbackMock(); - onStatusChangeFn = mockFunction; + storage.onStatusChange = mockFunction; + installerRender(); screen.getAllByText(/PFSkeleton/); - proposalData.result = { ...defaultProposalData.result }; + storage.proposal.getResult = jest.fn().mockResolvedValue( + { settings: { bootDevice: "/dev/vda" } } + ); const [onStatusChangeCb] = callbacks; await act(() => onStatusChangeCb(IDLE)); @@ -171,9 +162,16 @@ describe("when there is no proposal yet", () => { }); describe("when there is a proposal", () => { + beforeEach(() => { + storage.proposal.getResult = jest.fn().mockResolvedValue( + { settings: { bootDevice: "/dev/vda" } } + ); + }); + it("does not load the proposal when the service finishes to calculate", async () => { const [mockFunction, callbacks] = createCallbackMock(); - onStatusChangeFn = mockFunction; + storage.proposal.onStatusChange = mockFunction; + installerRender(); await screen.findByText("/dev/vda"); diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 072888928b..a810e20bc7 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -29,10 +29,12 @@ import { import { _ } from "~/i18n"; import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core"; import { DeviceSelector, ProposalVolumes } from "~/components/storage"; +import { deviceLabel } from '~/components/storage/utils'; import { Icon } from "~/components/layout"; import { noop } from "~/utils"; /** + * @typedef {import ("~/clients/storage").ProposalSettings} ProposalSettings * @typedef {import ("~/clients/storage").StorageDevice} StorageDevice * @typedef {import ("~/clients/storage").Volume} Volume */ @@ -109,9 +111,14 @@ const InstallationDeviceField = ({ current, devices, isLoading, onChange }) => { }; const DeviceContent = ({ device }) => { - const text = device || _("No device selected yet"); + const text = (deviceName) => { + if (!deviceName || deviceName.length === 0) return _("No device selected yet"); - return ; + const device = devices.find(d => d.name === deviceName); + return device ? deviceLabel(device) : deviceName; + }; + + return ; }; if (isLoading) { @@ -333,7 +340,7 @@ const EncryptionPasswordField = ({ selected: selectedProp, password: passwordPro * @param {object} props * @param {StorageDevice[]} [props.availableDevices=[]] * @param {Volume[]} [props.volumeTemplates=[]] - * @param {object} [props.settings={}] + * @param {ProposalSettings} [props.settings={}] * @param {boolean} [isLoading=false] * @param {onChangeFn} [props.onChange=noop] * @@ -349,7 +356,7 @@ export default function ProposalSettingsSection({ }) { const changeBootDevice = (device) => { if (onChange === noop) return; - onChange({ candidateDevices: [device] }); + onChange({ bootDevice: device }); }; const changeLVM = (lvm) => { @@ -367,7 +374,7 @@ export default function ProposalSettingsSection({ onChange({ volumes }); }; - const bootDevice = (settings.candidateDevices || [])[0]; + const { bootDevice } = settings; const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0; return ( @@ -392,6 +399,7 @@ export default function ProposalSettingsSection({ diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.jsx index 6325128db0..8e49c32136 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.jsx @@ -66,19 +66,20 @@ describe("Installation device field", () => { describe("and there is no selected device yet", () => { beforeEach(() => { - props.settings = { candidateDevices: [] }; + props.settings = { bootDevice: "" }; }); - it("does not render content", () => { + it("renders a message indicating that the device is not selected", () => { plainRender(); - expect(screen.queryByText(/Installation device/)).toBeNull(); + screen.getByText(/Installation device/); + screen.getByText(/No device selected/); }); }); describe("and there is a selected device", () => { beforeEach(() => { - props.settings = { candidateDevices: ["/dev/vda"] }; + props.settings = { bootDevice: "/dev/vda" }; }); it("renders the selected device", () => { @@ -92,7 +93,7 @@ describe("Installation device field", () => { describe("if there is no selected device yet", () => { beforeEach(() => { - props.settings = { candidateDevices: [] }; + props.settings = { bootDevice: "" }; }); it("renders a message indicating that the device is not selected", () => { @@ -105,7 +106,7 @@ describe("Installation device field", () => { describe("if there is a selected device", () => { beforeEach(() => { - props.settings = { candidateDevices: ["/dev/vda"] }; + props.settings = { bootDevice: "/dev/vda" }; }); it("renders the selected device", () => { @@ -119,13 +120,13 @@ describe("Installation device field", () => { it("allows selecting a device when clicking on the device name", async () => { props = { availableDevices: [vda], - settings: { candidateDevices: ["/dev/vda"] }, + settings: { bootDevice: "/dev/vda" }, onChange: jest.fn() }; const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/vda" }); + const button = screen.getByRole("button", { name: "/dev/vda, 1 KiB" }); await user.click(button); const popup = await screen.findByRole("dialog"); @@ -141,13 +142,13 @@ describe("Installation device field", () => { it("allows canceling the selection of the device", async () => { props = { availableDevices: [vda], - settings: { candidateDevices: ["/dev/vda"] }, + settings: { bootDevice: "/dev/vda" }, onChange: jest.fn() }; const { user } = plainRender(); - const button = screen.getByRole("button", { name: "/dev/vda" }); + const button = screen.getByRole("button", { name: "/dev/vda, 1 KiB" }); await user.click(button); const popup = await screen.findByRole("dialog"); diff --git a/web/src/components/storage/ProposalVolumes.jsx b/web/src/components/storage/ProposalVolumes.jsx index 619cd22ee0..74c45b57ce 100644 --- a/web/src/components/storage/ProposalVolumes.jsx +++ b/web/src/components/storage/ProposalVolumes.jsx @@ -46,7 +46,9 @@ import { noop } from "~/utils"; */ const AutoCalculatedHint = (volume) => { // no hint, the size is not affected by snapshots or other volumes - if (!volume.snapshotsAffectSizes && volume.sizeRelevantVolumes && volume.sizeRelevantVolumes.length === 0) { + const { snapshotsAffectSizes = false, sizeRelevantVolumes = [] } = volume.outline; + + if (!snapshotsAffectSizes && sizeRelevantVolumes.length === 0) { return null; } @@ -55,13 +57,13 @@ const AutoCalculatedHint = (volume) => { {/* TRANSLATORS: header for a list of items */} {_("These limits are affected by:")} - {volume.snapshotsAffectSizes && - // TRANSLATORS: list item, this affects the computed partition size limits - {_("The configuration of snapshots")}} - {volume.sizeRelevantVolumes && volume.sizeRelevantVolumes.length > 0 && - // TRANSLATORS: list item, this affects the computed partition size limits - // %s is replaced by a list of the volumes (like "/home, /boot") - {format(_("Presence of other volumes (%s)"), volume.sizeRelevantVolumes.join(", "))}} + {snapshotsAffectSizes && + // TRANSLATORS: list item, this affects the computed partition size limits + {_("The configuration of snapshots")}} + {sizeRelevantVolumes.length > 0 && + // TRANSLATORS: list item, this affects the computed partition size limits + // %s is replaced by a list of the volumes (like "/home, /boot") + {format(_("Presence of other volumes (%s)"), sizeRelevantVolumes.join(", "))}} ); @@ -156,6 +158,7 @@ const GeneralActions = ({ templates, onAdd, onReset }) => { * @param {object} props * @param {object[]} props.columns - Column specs * @param {object} props.volume - Volume to show + * @param {ProposalOptions} props.options - General proposal options * @param {boolean} props.isLoading - Whether to show the row as loading * @param {onDeleteFn} props.onDelete - Function to use for deleting the volume * @@ -163,7 +166,7 @@ const GeneralActions = ({ templates, onAdd, onReset }) => { * @param {object} volume * @return {void} */ -const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { +const VolumeRow = ({ columns, volume, options, isLoading, onEdit, onDelete }) => { const [isFormOpen, setIsFormOpen] = useState(false); const openForm = () => setIsFormOpen(true); @@ -177,8 +180,8 @@ const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { const SizeLimits = ({ volume }) => { const minSize = deviceSize(volume.minSize); - const maxSize = deviceSize(volume.maxSize); - const isAuto = volume.adaptiveSizes && !volume.fixedSizeLimits; + const maxSize = volume.maxSize ? deviceSize(volume.maxSize) : undefined; + const isAuto = volume.autoSize; let size = minSize; if (minSize && maxSize && minSize !== maxSize) size = `${minSize} - ${maxSize}`; @@ -194,12 +197,11 @@ const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { ); }; - const Details = ({ volume }) => { - const isLv = volume.deviceType === "lvm_lv"; + const Details = ({ volume, options }) => { const hasSnapshots = volume.fsType === "Btrfs" && volume.snapshots; // TRANSLATORS: the filesystem uses a logical volume (LVM) - const text = `${volume.fsType} ${isLv ? _("logical volume") : _("partition")}`; + const text = `${volume.fsType} ${options.lvm ? _("logical volume") : _("partition")}`; const lockIcon = ; const snapshotsIcon = ; @@ -207,7 +209,7 @@ const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => {
{text} {/* TRANSLATORS: filesystem flag, it uses an encryption */} - {_("encrypted")}} /> + {_("encrypted")}} /> {/* TRANSLATORS: filesystem flag, it allows creating snapshots */} {_("with snapshots")}} />
@@ -228,10 +230,10 @@ const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { } }; - if (volume.optional) - return [actions.edit, actions.delete]; - else + if (volume.outline.required) return [actions.edit]; + else + return [actions.edit, actions.delete]; }; const currentActions = actions(); @@ -252,8 +254,8 @@ const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { return ( <> - {volume.mountPoint} -
+ {volume.mountPath} +
{ * * @param {object} props * @param {object[]} props.volumes - Volumes to show + * @param {ProposalOptions} props.options - General proposal options * @param {boolean} props.isLoading - Whether to show the table as loading * @param {onVolumesChangeFn} props.onVolumesChange - Function to submit changes in volumes * @@ -293,24 +296,24 @@ const VolumeRow = ({ columns, volume, isLoading, onEdit, onDelete }) => { * @param {object[]} volumes * @return {void} */ -const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { +const VolumesTable = ({ volumes, options, isLoading, onVolumesChange }) => { const columns = { - mountPoint: _("Mount point"), + mountPath: _("Mount point"), details: _("Details"), size: _("Size"), actions: _("Actions") }; - const VolumesContent = ({ volumes, isLoading, onVolumesChange }) => { + const VolumesContent = ({ volumes, options, isLoading, onVolumesChange }) => { const editVolume = (volume) => { - const index = volumes.findIndex(v => v.mountPoint === volume.mountPoint); + const index = volumes.findIndex(v => v.mountPath === volume.mountPath); const newVolumes = [...volumes]; newVolumes[index] = volume; onVolumesChange(newVolumes); }; const deleteVolume = (volume) => { - const newVolumes = volumes.filter(v => v.mountPoint !== volume.mountPoint); + const newVolumes = volumes.filter(v => v.mountPath !== volume.mountPath); onVolumesChange(newVolumes); }; @@ -323,6 +326,7 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { id={index} columns={columns} volume={volume} + options={options} isLoading={isLoading} onEdit={editVolume} onDelete={deleteVolume} @@ -335,7 +339,7 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { - {columns.mountPoint} + {columns.mountPath} {columns.details} {columns.size} @@ -344,6 +348,7 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { @@ -359,9 +364,14 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { * @param {object} props * @param {object[]} [props.volumes=[]] - Volumes to show * @param {object[]} [props.templates=[]] - Templates to use for new volumes + * @param {ProposalOptions} [props.options={}] - General proposal options * @param {boolean} [props.isLoading=false] - Whether to show the content as loading * @param {onChangeFn} [props.onChange=noop] - Function to use for changing the volumes * + * @typedef {object} ProposalOptions + * @property {boolean} [lvm] + * @property {boolean} [encryption] + * * @callback onChangeFn * @param {object[]} volumes * @return {void} @@ -369,6 +379,7 @@ const VolumesTable = ({ volumes, isLoading, onVolumesChange }) => { export default function ProposalVolumes({ volumes = [], templates = [], + options = {}, isLoading = false, onChange = noop }) { @@ -401,6 +412,7 @@ export default function ProposalVolumes({ diff --git a/web/src/components/storage/ProposalVolumes.test.jsx b/web/src/components/storage/ProposalVolumes.test.jsx index 117e8c80d0..0f6832073f 100644 --- a/web/src/components/storage/ProposalVolumes.test.jsx +++ b/web/src/components/storage/ProposalVolumes.test.jsx @@ -35,40 +35,51 @@ jest.mock("@patternfly/react-core", () => { const volumes = { root: { - mountPoint: "/", - optional: false, - deviceType: "partition", - encrypted: false, + mountPath: "/", + fsType: "Btrfs", minSize: 1024, maxSize: 2048, - adaptiveSizes: false, - fixedSizeLimits: true, - fsType: "Btrfs", - snapshots: true + autoSize: false, + snapshots: true, + outline: { + required: true, + fsTypes: ["Btrfs", "Ext4"], + supportAutoSize: true, + snapshotsConfigurable: true, + snapshotsAffectSizes: true, + sizeRelevantVolumes: [] + } }, swap: { - mountPoint: "swap", - optional: true, - deviceType: "partition", - encrypted: false, + mountPath: "swap", + fsType: "Swap", minSize: 1024, maxSize: 1024, - adaptiveSizes: false, - fixedSizeLimits: true, - fsType: "Swap", - snapshots: false + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Swap"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } }, home: { - mountPoint: "/home", - optional: true, - deviceType: "partition", - encrypted: false, - minSize: 1024, - maxSize: -1, - adaptiveSizes: false, - fixedSizeLimits: true, + mountPath: "/home", fsType: "XFS", - snapshots: false + minSize: 1024, + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Ext4", "XFS"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } } }; diff --git a/web/src/components/storage/VolumeForm.jsx b/web/src/components/storage/VolumeForm.jsx index 4669814e9c..dc3ddc49b9 100644 --- a/web/src/components/storage/VolumeForm.jsx +++ b/web/src/components/storage/VolumeForm.jsx @@ -75,7 +75,7 @@ const SizeUnitFormSelect = ({ units, ...formSelectProps }) => { const MountPointFormSelect = ({ volumes, ...formSelectProps }) => { return ( - { volumes.map(v => ) } + { volumes.map(v => ) } ); }; @@ -91,16 +91,16 @@ const MountPointFormSelect = ({ volumes, ...formSelectProps }) => { const SizeAuto = ({ volume }) => { const conditions = []; - if (volume.snapshotsAffectSizes) + if (volume.outline.snapshotsAffectSizes) // TRANSLATORS: item which affects the final computed partition size conditions.push(_("the configuration of snapshots")); - if (volume.sizeRelevantVolumes && volume.sizeRelevantVolumes.length > 0) + if (volume.outline.sizeRelevantVolumes && volume.outline.sizeRelevantVolumes.length > 0) // TRANSLATORS: item which affects the final computed partition size // %s is replaced by a list of mount points like "/home, /boot" conditions.push(format(_("the presence of the file system for %s"), // TRANSLATORS: conjunction for merging two list items - volume.sizeRelevantVolumes.join(_(", ")))); + volume.outline.sizeRelevantVolumes.join(_(", ")))); // TRANSLATORS: the %s is replaced by the items which affect the computed size const conditionsText = format(_("The final size depends on %s."), @@ -275,7 +275,7 @@ const SizeOptions = ({ errors, formData, volume, onChange }) => { const sizeOptions = [SIZE_METHODS.MANUAL, SIZE_METHODS.RANGE]; - if (volume.adaptiveSizes) sizeOptions.push(SIZE_METHODS.AUTO); + if (volume.outline.supportAutoSize) sizeOptions.push(SIZE_METHODS.AUTO); return (
@@ -322,13 +322,13 @@ const createUpdatedVolume = (volume, formData) => { switch (formData.sizeMethod) { case SIZE_METHODS.AUTO: - updatedAttrs = { minSize: undefined, maxSize: undefined, fixedSizeLimits: false }; + updatedAttrs = { minSize: undefined, maxSize: undefined, autoSize: true }; break; case SIZE_METHODS.MANUAL: - updatedAttrs = { minSize: size, maxSize: size, fixedSizeLimits: true }; + updatedAttrs = { minSize: size, maxSize: size, autoSize: false }; break; case SIZE_METHODS.RANGE: - updatedAttrs = { minSize, maxSize: formData.maxSize ? maxSize : -1, fixedSizeLimits: true }; + updatedAttrs = { minSize, maxSize: formData.maxSize ? maxSize : undefined, autoSize: false }; break; } @@ -342,9 +342,9 @@ const createUpdatedVolume = (volume, formData) => { * @return {string} corresponding size method */ const sizeMethodFor = (volume) => { - const { adaptiveSizes, fixedSizeLimits, minSize, maxSize } = volume; + const { autoSize, minSize, maxSize } = volume; - if (adaptiveSizes && !fixedSizeLimits) { + if (autoSize) { return SIZE_METHODS.AUTO; } else if (minSize !== maxSize) { return SIZE_METHODS.RANGE; @@ -371,7 +371,7 @@ const prepareFormData = (volume) => { maxSize, maxSizeUnit, sizeMethod: sizeMethodFor(volume), - mountPoint: volume.mountPoint + mountPoint: volume.mountPath }; }; @@ -441,8 +441,8 @@ const reducer = (state, action) => { export default function VolumeForm({ id, volume: currentVolume, templates = [], onSubmit }) { const [state, dispatch] = useReducer(reducer, currentVolume || templates[0], createInitialState); - const changeVolume = (mountPoint) => { - const volume = templates.find(t => t.mountPoint === mountPoint); + const changeVolume = (mountPath) => { + const volume = templates.find(t => t.mountPath === mountPath); dispatch({ type: "CHANGE_VOLUME", payload: { volume } }); }; diff --git a/web/src/components/storage/VolumeForm.test.jsx b/web/src/components/storage/VolumeForm.test.jsx index 522a57239c..db8f2497c3 100644 --- a/web/src/components/storage/VolumeForm.test.jsx +++ b/web/src/components/storage/VolumeForm.test.jsx @@ -27,40 +27,51 @@ import { VolumeForm } from "~/components/storage"; const volumes = { root: { - mountPoint: "/", - optional: false, - deviceType: "partition", - encrypted: false, + mountPath: "/", + fsType: "Btrfs", minSize: 1024, maxSize: 2048, - adaptiveSizes: true, - fixedSizeLimits: true, - fsType: "Btrfs", - snapshots: true + autoSize: false, + snapshots: true, + outline: { + required: true, + fsTypes: ["Btrfs", "Ext4"], + supportAutoSize: true, + snapshotsConfigurable: true, + snapshotsAffectSizes: true, + sizeRelevantVolumes: [] + } }, swap: { - mountPoint: "swap", - optional: true, - deviceType: "partition", - encrypted: false, + mountPath: "swap", + fsType: "Swap", minSize: 1024, maxSize: 1024, - adaptiveSizes: false, - fixedSizeLimits: true, - fsType: "Swap", - snapshots: false + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Swap"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } }, home: { - mountPoint: "/home", - optional: true, - deviceType: "partition", - encrypted: false, - minSize: 1024, - maxSize: -1, - adaptiveSizes: false, - fixedSizeLimits: true, + mountPath: "/home", fsType: "XFS", - snapshots: false + minSize: 1024, + autoSize: false, + snapshots: false, + outline: { + required: false, + fsTypes: ["Ext4", "XFS"], + supportAutoSize: false, + snapshotsConfigurable: false, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + } } }; diff --git a/web/src/components/storage/utils.js b/web/src/components/storage/utils.js index e354067e27..3f4d7d77e5 100644 --- a/web/src/components/storage/utils.js +++ b/web/src/components/storage/utils.js @@ -62,9 +62,9 @@ const DEFAULT_SIZE_UNIT = "GiB"; * @returns {SizeObject} */ const splitSize = (size) => { - // From D-Bus, maxSize comes as -1 when set as "unlimited", but for Agama UI + // From D-Bus, maxSize comes as undefined when set as "unlimited", but for Agama UI // it means "leave it empty" - const sanitizedSize = size !== -1 ? size : ""; + const sanitizedSize = size === undefined ? "" : size; const parsedSize = typeof sanitizedSize === "string" ? sanitizedSize : xbytes(sanitizedSize, { iec: true }); const [qty, unit] = parsedSize.split(" "); // `Number` will remove trailing zeroes; @@ -85,15 +85,10 @@ const splitSize = (size) => { * deviceSize(1024) * // returns "1 KiB" * - * deviceSize(-1) - * // returns undefined - * - * @param {number} size - Number of bytes. The value -1 represents an unlimited size. - * @returns {string|undefined} + * @param {number} size - Number of bytes + * @returns {string} */ const deviceSize = (size) => { - if (size === -1) return undefined; - // Sadly, we cannot returns directly the xbytes(size, { iec: true }) because // it does not have an option for dropping/ignoring trailing zeroes and we do // not want to render them. diff --git a/web/src/components/storage/utils.test.js b/web/src/components/storage/utils.test.js index e3101713ec..5c65c3475b 100644 --- a/web/src/components/storage/utils.test.js +++ b/web/src/components/storage/utils.test.js @@ -22,11 +22,6 @@ import { deviceSize, deviceLabel, parseToBytes, splitSize } from "./utils"; describe("deviceSize", () => { - it("returns undefined is size is -1", () => { - const result = deviceSize(-1); - expect(result).toBeUndefined(); - }); - it("returns the size with units", () => { const result = deviceSize(1024); expect(result).toEqual("1 KiB"); @@ -73,16 +68,14 @@ describe("splitSize", () => { expect(splitSize(1000)).toEqual({ size: 1000, unit: "B" }); expect(splitSize(1024)).toEqual({ size: 1, unit: "KiB" }); expect(splitSize(1048576)).toEqual({ size: 1, unit: "MiB" }); - expect(splitSize(undefined)).toEqual({ size: 0, unit: "B" }); - expect(splitSize(null)).toEqual({ size: 0, unit: "B" }); }); it("returns a size object with unknown unit when a string without unit is given", () => { expect(splitSize("30")).toEqual({ size: 30, unit: undefined }); }); - it("returns an 'empty' size object when -1 is given", () => { - expect(splitSize(-1)).toEqual({ size: undefined, unit: undefined }); + it("returns an 'empty' size object when undefined is given", () => { + expect(splitSize(undefined)).toEqual({ size: undefined, unit: undefined }); }); it("returns an 'empty' size object when an unexpected string is given", () => {