diff --git a/doc/dbus_api.md b/doc/dbus_api.md index 90bdd51a0d..1c7ef0a108 100644 --- a/doc/dbus_api.md +++ b/doc/dbus_api.md @@ -280,7 +280,7 @@ Optional b Encrypted b MountPoint s FixedSizeLimits b -AdaptativeSizes b +AdaptiveSizes b MinSize x MaxSize x FsTypes as @@ -288,7 +288,7 @@ FsType s Snapshots b SnapshotsConfigurable b SnapshotsAffectSizes b -VolumesWithFallbackSizes as +SizeRelevantVolumes as ~~~ Example: @@ -393,11 +393,13 @@ Logout(out u result) ##### Properties ~~~ -Target readable s -Address readable s -Port readable u -Interface readable s -Startup readable s +Target readable s +Address readable s +Port readable u +Interface readable s +IBFT readable b +Connected readable b +Startup readable,writable s ~~~ ##### Details diff --git a/service/lib/dinstaller/dbus/storage/iscsi_node.rb b/service/lib/dinstaller/dbus/storage/iscsi_node.rb index a10b60f2a7..59ad8fa58e 100644 --- a/service/lib/dinstaller/dbus/storage/iscsi_node.rb +++ b/service/lib/dinstaller/dbus/storage/iscsi_node.rb @@ -49,20 +49,6 @@ def initialize(iscsi_manager, iscsi_node, path, logger: nil) @iscsi_node = iscsi_node end - ISCSI_NODE_INTERFACE = "org.opensuse.DInstaller.Storage1.ISCSI.Node" - private_constant :ISCSI_NODE_INTERFACE - - dbus_interface ISCSI_NODE_INTERFACE do - dbus_reader(:target, "s") - dbus_reader(:address, "s") - dbus_reader(:port, "u") - dbus_reader(:interface, "s") - dbus_reader(:connected, "b") - dbus_reader(:startup, "s") - dbus_method(:Login, "in options:a{sv}, out result:u") { |o| login(o) } - dbus_method(:Logout, "out result:u") { logout } - end - # Name of the iSCSI target # # @return [String] @@ -91,6 +77,13 @@ def interface iscsi_node.interface || "" end + # Whether the iSCSI node was initiated by iBTF + # + # @return [Boolean] + def ibft + iscsi_node.ibft? + end + # Whether the node is connected # # @return [Boolean] @@ -105,6 +98,17 @@ def startup iscsi_node.startup || "" end + # Sets a new value for the startup status + # + # @raise [::DBus::Error] If the given value is not valid. + # + # @param value [String] + def startup=(value) + raise ::DBus::Error, "Invalid startup value: #{value}" unless valid_startup?(value) + + iscsi_manager.update(iscsi_node, startup: value) + end + # Sets the associated iSCSI node # # @note A properties changed signal is always emitted. @@ -132,7 +136,7 @@ def login(options = {}) auth = iscsi_auth(options) startup = options["Startup"] - if startup && !DInstaller::Storage::ISCSI::Manager::STARTUP_OPTIONS.include?(startup) + if startup && !valid_startup?(startup) logger.info("iSCSI login error: startup value #{startup} is not valid") return 1 end @@ -151,6 +155,31 @@ def logout success = iscsi_manager.logout(iscsi_node) success ? 0 : 1 end + + ISCSI_NODE_INTERFACE = "org.opensuse.DInstaller.Storage1.ISCSI.Node" + private_constant :ISCSI_NODE_INTERFACE + + dbus_interface ISCSI_NODE_INTERFACE do + dbus_reader(:target, "s") + dbus_reader(:address, "s") + dbus_reader(:port, "u") + dbus_reader(:interface, "s") + dbus_reader(:ibft, "b", dbus_name: "IBFT") + dbus_reader(:connected, "b") + dbus_accessor(:startup, "s") + dbus_method(:Login, "in options:a{sv}, out result:u") { |o| login(o) } + dbus_method(:Logout, "out result:u") { logout } + end + + private + + # Whether the given value is a valid startup status + # + # @param value [String] + # @return [Boolean] + def valid_startup?(value) + DInstaller::Storage::ISCSI::Manager::STARTUP_OPTIONS.include?(value) + end end end end diff --git a/service/lib/dinstaller/storage/iscsi/manager.rb b/service/lib/dinstaller/storage/iscsi/manager.rb index 8ef74a8d2c..d281f9506d 100644 --- a/service/lib/dinstaller/storage/iscsi/manager.rb +++ b/service/lib/dinstaller/storage/iscsi/manager.rb @@ -108,7 +108,7 @@ def discover_send_targets(host, port, authentication) # Creates a new iSCSI session # - # @note iSCSI nodes are probed again if needed, see {#probe_after}. + # @note iSCSI nodes are probed again, see {#probe_after}. # # @param node [Node] # @param authentication [Y2IscsiClient::Authentication] @@ -118,13 +118,6 @@ def discover_send_targets(host, port, authentication) def login(node, authentication, startup: nil) startup ||= Yast::IscsiClientLib.default_startup_status - if !STARTUP_OPTIONS.include?(startup) - logger.info( - "Cannot create iSCSI session because startup status is not valid: #{startup}" - ) - return false - end - ensure_activated probe_after do @@ -163,6 +156,21 @@ def delete(node) end end + # Updates an iSCSI node + # + # @note iSCSI nodes are probed again, see {#probe_after}. + # + # @param node [Node] + # @param startup [String] New startup mode value + # + # @return [Boolean] Whether the action successes + def update(node, startup:) + probe_after do + Yast::IscsiClientLib.currentRecord = record_from(node) + Yast::IscsiClientLib.setStartupStatus(startup) + end + end + # Registers a callback to be called when the nodes are probed # # @param block [Proc] diff --git a/service/test/dinstaller/dbus/storage/iscsi_node_test.rb b/service/test/dinstaller/dbus/storage/iscsi_node_test.rb index d8bc323db5..41c8ea726f 100644 --- a/service/test/dinstaller/dbus/storage/iscsi_node_test.rb +++ b/service/test/dinstaller/dbus/storage/iscsi_node_test.rb @@ -39,6 +39,28 @@ allow(subject).to receive(:dbus_properties_changed) end + describe "#startup=" do + context "when the given startup status is not valid" do + let(:startup) { "invalid" } + + it "raises a D-Bus error" do + expect(iscsi_manager).to_not receive(:update) + + expect { subject.startup = startup }.to raise_error(::DBus::Error, /Invalid startup/) + end + end + + context "when the given startup status is valid" do + let(:startup) { "automatic" } + + it "updates the iSCSI node" do + expect(iscsi_manager).to receive(:update).with(iscsi_node, startup: startup) + + subject.startup = startup + end + end + end + describe "#iscsi_node=" do it "sets the iSCSI node value" do node = DInstaller::Storage::ISCSI::Node.new diff --git a/service/test/dinstaller/storage/iscsi/manager_test.rb b/service/test/dinstaller/storage/iscsi/manager_test.rb index d8d9690523..0ec18a2131 100644 --- a/service/test/dinstaller/storage/iscsi/manager_test.rb +++ b/service/test/dinstaller/storage/iscsi/manager_test.rb @@ -136,105 +136,75 @@ allow(Yast::IscsiClientLib).to receive(:setStartupStatus) end - context "if the given startup status is not valid" do - let(:startup) { "invalid" } + let(:startup) { "automatic" } - it "does not try to login" do - expect(Yast::IscsiClientLib).to_not receive(:login_into_current) + before do + allow(Yast::IscsiClientLib).to receive(:login_into_current).and_return(login_success) + allow(Yast::IscsiClientLib).to receive(:setStartupStatus).and_return(startup_success) + end - subject.login(node, auth, startup: startup) - end + let(:login_success) { nil } - it "does not activate iSCSI" do - expect(subject).to_not receive(:activate) + let(:startup_success) { nil } - subject.login(node, auth, startup: startup) - end + it "tries to login" do + expect(Yast::IscsiClientLib).to receive(:login_into_current) - it "does not probe iSCSI" do - expect(subject).to_not receive(:probe) - - subject.login(node, auth, startup: startup) - end + subject.login(node, auth, startup: startup) + end - it "returns false" do - result = subject.login(node, auth, startup: startup) + context "if iSCSI activation is not performed yet" do + it "activates iSCSI" do + expect(subject).to receive(:activate) - expect(result).to eq(false) + subject.login(node, auth, startup: startup) end end - context "if the given startup status is valid" do - let(:startup) { "automatic" } - + context "if iSCSI activation was already performed" do before do - allow(Yast::IscsiClientLib).to receive(:login_into_current).and_return(login_success) - allow(Yast::IscsiClientLib).to receive(:setStartupStatus).and_return(startup_success) + subject.activate end - let(:login_success) { nil } - - let(:startup_success) { nil } - - it "tries to login" do - expect(Yast::IscsiClientLib).to receive(:login_into_current) + it "does not activate iSCSI again" do + expect(subject).to_not receive(:activate) subject.login(node, auth, startup: startup) end + end - context "if iSCSI activation is not performed yet" do - it "activates iSCSI" do - expect(subject).to receive(:activate) + context "and the session is created" do + let(:login_success) { true } - subject.login(node, auth, startup: startup) - end - end + context "and the startup status is correctly set" do + let(:startup_success) { true } - context "if iSCSI activation was already performed" do - before do - subject.activate - end - - it "does not activate iSCSI again" do - expect(subject).to_not receive(:activate) + it "probes iSCSI" do + expect(subject).to receive(:probe) subject.login(node, auth, startup: startup) end - end - - context "and the session is created" do - let(:login_success) { true } - - context "and the startup status is correctly set" do - let(:startup_success) { true } - - it "probes iSCSI" do - expect(subject).to receive(:probe) - - subject.login(node, auth, startup: startup) - end - it "returns true" do - result = subject.login(node, auth, startup: startup) + it "returns true" do + result = subject.login(node, auth, startup: startup) - expect(result).to eq(true) - end + expect(result).to eq(true) end + end - context "and the startup status cannot be set" do - let(:startup_success) { false } + context "and the startup status cannot be set" do + let(:startup_success) { false } - it "probes iSCSI" do - expect(subject).to receive(:probe) + it "probes iSCSI" do + expect(subject).to receive(:probe) - subject.login(node, auth, startup: startup) - end + subject.login(node, auth, startup: startup) + end - it "returns false" do - result = subject.login(node, auth, startup: startup) + it "returns false" do + result = subject.login(node, auth, startup: startup) - expect(result).to eq(false) - end + expect(result).to eq(false) end end end @@ -299,4 +269,24 @@ subject.delete(node) end end + + describe "#update" do + before do + allow(Yast::IscsiClientLib).to receive(:setStartupStatus) + end + + let(:node) { DInstaller::Storage::ISCSI::Node.new } + + it "updates the iSCSI node" do + expect(Yast::IscsiClientLib).to receive(:setStartupStatus).with("manual") + + subject.update(node, startup: "manual") + end + + it "probes iSCSI" do + expect(subject).to receive(:probe) + + subject.update(node, startup: "manual") + end + end end diff --git a/web/cspell.json b/web/cspell.json index f1976674e2..7f0753d992 100644 --- a/web/cspell.json +++ b/web/cspell.json @@ -13,8 +13,10 @@ "ccmp", "dbus", "dinstaller", + "ibft", "ifaces", "ipaddr", + "iscsi", "jdoe", "lldp", "luks", diff --git a/web/src/client/storage.js b/web/src/client/storage.js index d37736de73..c0887c71a1 100644 --- a/web/src/client/storage.js +++ b/web/src/client/storage.js @@ -23,139 +23,439 @@ import DBusClient from "./dbus"; import { WithStatus, WithValidation } from "./mixins"; -import cockpit from "../lib/cockpit"; -const STORAGE_SERVICE = "org.opensuse.DInstaller.Storage"; -const STORAGE_PATH = "/org/opensuse/DInstaller/Storage1"; const PROPOSAL_CALCULATOR_IFACE = "org.opensuse.DInstaller.Storage1.Proposal.Calculator"; +const ISCSI_NODE_IFACE = "org.opensuse.DInstaller.Storage1.ISCSI.Node"; +const ISCSI_NODES_NAMESPACE = "/org/opensuse/DInstaller/Storage1/iscsi_nodes"; +const ISCSI_INITIATOR_IFACE = "org.opensuse.DInstaller.Storage1.ISCSI.Initiator"; const PROPOSAL_IFACE = "org.opensuse.DInstaller.Storage1.Proposal"; +const STORAGE_OBJECT = "/org/opensuse/DInstaller/Storage1"; /** - * Storage base client + * Removes properties with undefined value * - * @ignore + * @example + * removeUndefinedCockpitProperties({ + * property1: { t: "s", v: "foo" }, + * property2: { t: b, v: false }, + * property3: { t: "s", v: undefined } + * }); + * //returns { property1: { t: "s", v: "foo" }, property2: { t: "b", v: false } } + * + * @param {object} cockpitObject + * @returns {object} */ -class StorageBaseClient { +const removeUndefinedCockpitProperties = (cockpitObject) => { + const filtered = Object.entries(cockpitObject).filter(([, { v }]) => v !== undefined); + return Object.fromEntries(filtered); +}; + +/** + * Class providing an API for managing the storage proposal through D-Bus + */ +class ProposalManager { /** - * @param {string|undefined} address - D-Bus address; if it is undefined, it uses the system bus. + * @param {DBusClient} client */ - constructor(address = undefined) { - this.client = new DBusClient(STORAGE_SERVICE, address); + constructor(client) { + this.client = client; + this.proxies = {}; } /** - * Returns storage proposal values + * Gets data associated to the proposal * - * @return {Promise} + * @returns {Promise} + * + * @typedef {object} ProposalData + * @property {AvailableDevice[]} availableDevices + * @property {Result} result */ - async getProposal() { - const storageProxy = await this.client.proxy(PROPOSAL_CALCULATOR_IFACE, STORAGE_PATH); + async getData() { + const availableDevices = await this.getAvailableDevices(); + const result = await this.getResult(); - let proposalProxy; - try { - proposalProxy = await this.client.proxy(PROPOSAL_IFACE); - } catch { - proposalProxy = {}; - } + return { availableDevices, result }; + } + + /** + * Gets the list of available devices + * + * @returns {Promise} + * + * @typedef {object} AvailableDevice + * @property {string} id - Device kernel name + * @property {string} label - Device description + */ + async getAvailableDevices() { + const buildDevice = dbusDevice => { + return { + id: dbusDevice[0], + label: dbusDevice[1] + }; + }; + + const proxy = await this.proposalCalculatorProxy(); + return proxy.AvailableDevices.map(buildDevice); + } + + /** + * Gets the values of the current proposal + * + * @return {Promise} + * + * @typedef {object} Result + * @property {string[]} candidateDevices + * @property {boolean} lvm + * @property {string} encryptionPassword + * @property {Volume[]} volumes + * @property {Action[]} actions + * + * @typedef {object} Volume + * @property {string} [deviceType] + * @property {boolean} [optional] + * @property {string} [mountPoint] + * @property {boolean} [fixedSizeLimits] + * @property {number} [minSize] + * @property {number} [maxSize] + * @property {string[]} [fsTypes] + * @property {string} [fsType] + * @property {boolean} [snapshots] + * @property {boolean} [snapshotsConfigurable] + * @property {boolean} [snapshotsAffectSizes] + * @property {string[]} [sizeRelevantVolumes] + * + * @typedef {object} Action + * @property {string} text + * @property {boolean} subvol + * @property {boolean} delete + */ + async getResult() { + const proxy = await this.proposalProxy(); - // Check whether proposal object is already exported - if (!proposalProxy?.valid) return {}; + if (!proxy) return undefined; - const volume = dbusVolume => { - const valueFrom = dbusValue => dbusValue?.v; + const buildResult = (proxy) => { + const buildVolume = dbusVolume => { + const buildList = (value) => { + if (value === undefined) return []; - const valuesFrom = (dbusValues) => { - if (dbusValues === undefined) return []; - return dbusValues.v.map(valueFrom); + return value.map(val => val.v); + }; + + return { + deviceType: dbusVolume.DeviceType?.v, + optional: dbusVolume.Optional?.v, + encrypted: dbusVolume.Encrypted?.v, + mountPoint: dbusVolume.MountPoint?.v, + fixedSizeLimits: dbusVolume.FixedSizeLimits?.v, + adaptiveSizes: dbusVolume.AdaptiveSizes?.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) + }; + }; + + const buildAction = dbusAction => { + return { + text: dbusAction.Text.v, + subvol: dbusAction.Subvol.v, + delete: dbusAction.Delete.v + }; }; return { - mountPoint: valueFrom(dbusVolume.MountPoint), - optional: valueFrom(dbusVolume.Optional), - deviceType: valueFrom(dbusVolume.DeviceType), - encrypted: valueFrom(dbusVolume.Encrypted), - fsTypes: valuesFrom(dbusVolume.FsTypes), - fsType: valueFrom(dbusVolume.FsType), - minSize: valueFrom(dbusVolume.MinSize), - maxSize: valueFrom(dbusVolume.MaxSize), - fixedSizeLimits: valueFrom(dbusVolume.FixedSizeLimits), - adaptiveSizes: valueFrom(dbusVolume.AdaptiveSizes), - snapshots: valueFrom(dbusVolume.Snapshots), - snapshotsConfigurable: valueFrom(dbusVolume.SnapshotsConfigurable), - snapshotsAffectSizes: valueFrom(dbusVolume.SnapshotsAffectSizes), - sizeRelevantVolumes: valueFrom(dbusVolume.SizeRelevantVolumes) + candidateDevices: proxy.CandidateDevices, + lvm: proxy.LVM, + encryptionPassword: proxy.EncryptionPassword, + volumes: proxy.Volumes.map(buildVolume), + actions: proxy.Actions.map(buildAction) }; }; - const action = dbusAction => { - const { Text: { v: textVar }, Subvol: { v: subvolVar }, Delete: { v: deleteVar } } = dbusAction; - return { text: textVar, subvol: subvolVar, delete: deleteVar }; - }; + return buildResult(proxy); + } - return { - availableDevices: storageProxy.AvailableDevices.map(([id, label]) => ({ id, label })), - candidateDevices: proposalProxy.CandidateDevices, - lvm: proposalProxy.LVM, - encryptionPassword: proposalProxy.EncryptionPassword, - volumes: proposalProxy.Volumes.map(volume), - actions: proposalProxy.Actions.map(action) + /** + * 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 + * + * @returns {Promise} 0 on success, 1 on failure + */ + async calculate({ candidateDevices, encryptionPassword, lvm, volumes }) { + const dbusVolume = (volume) => { + return removeUndefinedCockpitProperties({ + MountPoint: { t: "s", v: volume.mountPoint }, + Encrypted: { t: "b", v: volume.encrypted }, + FsType: { t: "s", v: volume.fsType }, + MinSize: { t: "x", v: volume.minSize }, + MaxSize: { t: "x", v: volume.maxSize }, + FixedSizeLimits: { t: "b", v: volume.fixedSizeLimits }, + Snapshots: { t: "b", v: volume.snapshots } + }); }; + + const settings = removeUndefinedCockpitProperties({ + CandidateDevices: { t: "as", v: candidateDevices }, + EncryptionPassword: { t: "s", v: encryptionPassword }, + LVM: { t: "b", v: lvm }, + Volumes: { t: "aa{sv}", v: volumes?.map(dbusVolume) } + }); + + const proxy = await this.proposalCalculatorProxy(); + return proxy.Calculate(settings); } /** - * Calculates a new proposal + * @private + * Proxy for org.opensuse.DInstaller.Storage1.Proposal.Calculator iface * - * @param {object} settings - proposal settings - * @param {?string[]} [settings.candidateDevices] - Devices to use for the proposal - * @param {?string} [settings.encryptionPassword] - Password for encrypting devices - * @param {?boolean} [settings.lvm] - Whether to calculate the proposal with LVM volumes - * @param {?object[]} [settings.volumes] - Volumes to create - * @return {Promise} - 0 success, other for failure + * @returns {Promise} */ - async calculateProposal({ candidateDevices, encryptionPassword, lvm, volumes }) { - const proxy = await this.client.proxy(PROPOSAL_CALCULATOR_IFACE, STORAGE_PATH); + async proposalCalculatorProxy() { + if (!this.proxies.proposalCalculator) + this.proxies.proposalCalculator = await this.client.proxy(PROPOSAL_CALCULATOR_IFACE, STORAGE_OBJECT); - // Builds a new object without undefined attributes - const cleanObject = (object) => { - const newObject = { ...object }; + return this.proxies.proposalCalculator; + } - Object.keys(newObject).forEach(key => newObject[key] === undefined && delete newObject[key]); - return newObject; - }; + /** + * @private + * Proxy for org.opensuse.DInstaller.Storage1.Proposal iface + * + * @note The proposal object implementing this iface is dynamically exported. + * + * @returns {Promise} null if the proposal object is not exported yet + */ + async proposalProxy() { + try { + return await this.client.proxy(PROPOSAL_IFACE); + } catch { + return null; + } + } +} - // Builds the cockpit object or returns undefined if there is no value - const cockpitValue = (type, value) => { - if (value === undefined) return undefined; +/** + * Class providing an API for managing iSCSI through D-Bus + */ +class ISCSIManager { + /** + * @param {DBusClient} client + */ + constructor(client) { + this.client = client; + this.proxies = {}; + } - return cockpit.variant(type, value); - }; + /** + * Gets the iSCSI initiator name + * + * @returns {Promise} + */ + async getInitiatorName() { + const proxy = await this.iscsiInitiatorProxy(); + return proxy.InitiatorName; + } - const dbusVolume = (volume) => { - return cleanObject({ - MountPoint: cockpitValue("s", volume.mountPoint), - Encrypted: cockpitValue("b", volume.encrypted), - FsType: cockpitValue("s", volume.fsType), - MinSize: cockpitValue("x", volume.minSize), - MaxSize: cockpitValue("x", volume.maxSize), - FixedSizeLimits: cockpitValue("b", volume.fixedSizeLimits), - Snapshots: cockpitValue("b", volume.snapshots) - }); - }; + /** + * Sets the iSCSI initiator name + * + * @param {string} value + */ + async setInitiatorName(value) { + const proxy = await this.iscsiInitiatorProxy(); + proxy.InitiatorName = value; + } - const dbusVolumes = (volumes) => { - if (!volumes) return undefined; + /** + * Gets the list of exported iSCSI nodes + * + * @returns {Promise} + * + * @typedef {object} ISCSINode + * @property {string} id + * @property {string} target + * @property {string} address + * @property {number} port + * @property {string} interface + * @property {boolean} ibft + * @property {boolean} connected + * @property {string} startup + */ + async getNodes() { + const buildNode = (iscsiProxy) => { + const id = path => path.split("/").slice(-1)[0]; - return volumes.map(dbusVolume); + return { + id: id(iscsiProxy.path), + target: iscsiProxy.Target, + address: iscsiProxy.Address, + port: iscsiProxy.Port, + interface: iscsiProxy.Interface, + ibft: iscsiProxy.IBFT, + connected: iscsiProxy.Connected, + startup: iscsiProxy.Startup + }; }; - const settings = cleanObject({ - CandidateDevices: cockpitValue("as", candidateDevices), - EncryptionPassword: cockpitValue("s", encryptionPassword), - LVM: cockpitValue("b", lvm), - Volumes: cockpitValue("aa{sv}", dbusVolumes(volumes)) + const proxy = await this.iscsiNodesProxy(); + return Object.values(proxy).map(p => buildNode(p)); + } + + /** + * Performs an iSCSI discovery + * + * @param {string} address - IP address of the iSCSI server + * @param {number} port - Port of the iSCSI server + * @param {DiscoverOptions} [options] + * + * @typedef {object} DiscoverOptions + * @property {string} [username] - Username for authentication by target + * @property {string} [password] - Password for authentication by target + * @property {string} [reverseUsername] - Username for authentication by initiator + * @property {string} [reversePassword] - Password for authentication by initiator + * + * @returns {Promise} 0 on success, 1 on failure + */ + async discover(address, port, options = {}) { + const auth = removeUndefinedCockpitProperties({ + Username: { t: "s", v: options.username }, + Password: { t: "s", v: options.password }, + ReverseUsername: { t: "s", v: options.reverseUsername }, + ReversePassword: { t: "s", v: options.reversePassword } }); - return proxy.Calculate(settings); + const proxy = await this.iscsiInitiatorProxy(); + return proxy.Discover(address, port, auth); + } + + /** + * Deletes the given iSCSI node + * + * @param {ISCSINode} node + * @returns {Promise} 0 on success, 1 on failure if the given path is not exported, 2 on + * failure because any other reason. + */ + async delete(node) { + const path = this.nodePath(node); + + const proxy = await this.iscsiInitiatorProxy(); + return proxy.Delete(path); + } + + /** + * Creates an iSCSI session + * + * @param {ISCSINode} node + * @param {LoginOptions} options + * + * @typedef {object} LoginOptions + * @property {string} [username] - Username for authentication by target + * @property {string} [password] - Password for authentication by target + * @property {string} [reverseUsername] - Username for authentication by initiator + * @property {string} [reversePassword] - Password for authentication by initiator + * @property {string} [startup] - Startup status for the session + * + * @returns {Promise} 0 on success, 1 on failure if the given startup value is not + * valid, and 2 on failure because any other reason + */ + async login(node, options = {}) { + const path = this.nodePath(node); + + const dbusOptions = removeUndefinedCockpitProperties({ + Username: { t: "s", v: options.username }, + Password: { t: "s", v: options.password }, + ReverseUsername: { t: "s", v: options.reverseUsername }, + ReversePassword: { t: "s", v: options.reversePassword }, + Startup: { t: "s", v: options.startup } + }); + + const proxy = await this.client.proxy(ISCSI_NODE_IFACE, path); + return proxy.Login(dbusOptions); + } + + /** + * Closes an iSCSI session + * + * @param {ISCSINode} node + * @returns {Promise} 0 on success, 1 on failure + */ + async logout(node) { + const path = this.nodePath(node); + // const iscsiNode = new ISCSINodeObject(this.client, path); + // return await iscsiNode.iface.logout(); + const proxy = await this.client.proxy(ISCSI_NODE_IFACE, path); + return proxy.Logout(); + } + + /** + * @private + * Proxy for org.opensuse.DInstaller.Storage1.ISCSI.Initiator iface + * + * @returns {Promise} + */ + async iscsiInitiatorProxy() { + if (!this.proxies.iscsiInitiator) + this.proxies.iscsiInitiator = await this.client.proxy(ISCSI_INITIATOR_IFACE, STORAGE_OBJECT); + + return this.proxies.iscsiInitiator; + } + + /** + * @private + * Proxy for objects implementing org.opensuse.DInstaller.Storage1.ISCSI.Node iface + * + * @note The ISCSI nodes are dynamically exported. + * + * @returns {Promise} + */ + async iscsiNodesProxy() { + if (!this.proxies.iscsiNodes) + this.proxies.iscsiNodes = await this.client.proxies(ISCSI_NODE_IFACE, ISCSI_NODES_NAMESPACE); + + return this.proxies.iscsiNodes; + } + + /** + * @private + * Builds the D-Bus path for the given iSCSI node + * + * @param {ISCSINode} node + * @returns {string} + */ + nodePath(node) { + return ISCSI_NODES_NAMESPACE + "/" + node.id; + } +} + +/** + * Storage base client + * + * @ignore + */ +class StorageBaseClient { + static SERVICE = "org.opensuse.DInstaller.Storage"; + + /** + * @param {string|undefined} address - D-Bus address; if it is undefined, it uses the system bus. + */ + constructor(address = undefined) { + this.client = new DBusClient(StorageBaseClient.SERVICE, address); + this.proposal = new ProposalManager(this.client); + this.iscsi = new ISCSIManager(this.client); } } @@ -163,7 +463,7 @@ class StorageBaseClient { * Allows interacting with the storage settings */ class StorageClient extends WithValidation( - WithStatus(StorageBaseClient, STORAGE_PATH), STORAGE_PATH + WithStatus(StorageBaseClient, STORAGE_OBJECT), STORAGE_OBJECT ) {} export { StorageClient }; diff --git a/web/src/client/storage.test.js b/web/src/client/storage.test.js index d1164ea873..15b15dafd8 100644 --- a/web/src/client/storage.test.js +++ b/web/src/client/storage.test.js @@ -20,205 +20,417 @@ */ // @ts-check +// cspell:ignore onboot import DBusClient from "./dbus"; import { StorageClient } from "./storage"; jest.mock("./dbus"); -// NOTE: should we export them? -const PROPOSAL_CALCULATOR_IFACE = "org.opensuse.DInstaller.Storage1.Proposal.Calculator"; -const PROPOSAL_IFACE = "org.opensuse.DInstaller.Storage1.Proposal"; +const cockpitProxies = {}; -const calculateFn = jest.fn(); - -const storageProxy = { - wait: jest.fn(), - AvailableDevices: [ - ["/dev/sda", "/dev/sda, 950 GiB, Windows"], - ["/dev/sdb", "/dev/sdb, 500 GiB"] - ], - Calculate: calculateFn +const contexts = { + withoutProposal: () => { + cockpitProxies.proposal = null; + }, + withProposal: () => { + cockpitProxies.proposal = { + CandidateDevices:["/dev/sda"], + LVM: true, + Volumes: [ + { + MountPoint: { t: "s", v: "/test1" }, + Optional: { t: "b", v: true }, + DeviceType: { t: "s", v: "partition" }, + Encrypted: { t: "b", v: false }, + FsTypes: { t: "as", v: [{ t: "s", v: "Btrfs" }, { t: "s", v: "Ext3" }] }, + FsType: { t: "s", v: "Btrfs" }, + MinSize: { t: "x", v: 1024 }, + MaxSize: { t: "x", v: 2048 }, + FixedSizeLimits: { t: "b", v: false }, + AdaptiveSizes: { t: "b", v: false }, + Snapshots: { t: "b", v: true }, + SnapshotsConfigurable: { t: "b", v: true }, + SnapshotsAffectSizes: { t: "b", v: false }, + SizeRelevantVolumes: { t: "as", v: [] } + }, + { + MountPoint: { t: "s", v: "/test2" } + } + ], + Actions: [ + { + Text: { t: "s", v: "Mount /dev/sdb1 as root" }, + Subvol: { t: "b", v: false }, + Delete: { t: "b", v: false } + } + ] + }; + }, + withAvailableDevices: () => { + cockpitProxies.proposalCalculator = { + AvailableDevices: [ + ["/dev/sda", "/dev/sda, 950 GiB, Windows"], + ["/dev/sdb", "/dev/sdb, 500 GiB"] + ] + }; + }, + withoutISCSINodes: () => { + cockpitProxies.iscsiNodes = {}; + }, + withISCSINodes: () => { + cockpitProxies.iscsiNodes = { + "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1": { + path: "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1", + Target: "iqn.2023-01.com.example:37dac", + Address: "192.168.100.101", + Port: 3260, + Interface: "default", + IBFT: false, + Connected: false, + Startup: "" + }, + "/org/opensuse/DInstaller/Storage1/iscsi_nodes/2": { + path: "/org/opensuse/DInstaller/Storage1/iscsi_nodes/2", + Target: "iqn.2023-01.com.example:74afb", + Address: "192.168.100.102", + Port: 3260, + Interface: "default", + IBFT: true, + Connected: true, + Startup: "onboot" + } + }; + } }; -const validProposalProxy = { - valid: true, - wait: jest.fn(), - CandidateDevices: ["/dev/sda"], - LVM: true, - Volumes: [ - { - MountPoint: { t: "s", v: "/test1" }, - Optional: { t: "b", v: true }, - DeviceType: { t: "s", v: "partition" }, - Encrypted: { t: "b", v: false }, - FsTypes: { t: "as", v: [{ t: "s", v: "Btrfs" }, { t: "s", v: "Ext3" }] }, - FsType: { t: "s", v: "Btrfs" }, - MinSize: { t: "x", v: 1024 }, - MaxSize: { t: "x", v: 2048 }, - FixedSizeLimits: { t: "b", v: false }, - AdaptiveSizes: { t: "b", v: false }, - Snapshots: { t: "b", v: true }, - SnapshotsConfigurable: { t: "b", v: true }, - SnapshotsAffectSizes: { t: "b", v: false }, - SizeRelevantVolumes: { t: "as", v: [] } - }, - { - MountPoint: { t: "s", v: "/test2" } - } - ], - Actions: [ - { - Text: { t: "s", v: "Mount /dev/sdb1 as root" }, - Subvol: { t: "b", v: false }, - Delete: { t: "b", v: false } - } - ] +const mockProxy = (iface, path) => { + switch (iface) { + case "org.opensuse.DInstaller.Storage1.Proposal": return cockpitProxies.proposal; + case "org.opensuse.DInstaller.Storage1.Proposal.Calculator": return cockpitProxies.proposalCalculator; + case "org.opensuse.DInstaller.Storage1.ISCSI.Initiator": return cockpitProxies.iscsiInitiator; + case "org.opensuse.DInstaller.Storage1.ISCSI.Node": return cockpitProxies.iscsiNode[path]; + } }; -let proposalProxy; - -const proxies = (iface) => { +const mockProxies = (iface) => { switch (iface) { - case PROPOSAL_CALCULATOR_IFACE: return storageProxy; - case PROPOSAL_IFACE: return proposalProxy; + case "org.opensuse.DInstaller.Storage1.ISCSI.Node": return cockpitProxies.iscsiNodes; } }; -beforeEach(() => { - proposalProxy = { ...validProposalProxy }; +let client; +beforeEach(() => { // @ts-ignore DBusClient.mockImplementation(() => { - return { proxy: (iface) => proxies(iface) }; + return { + proxy: mockProxy, + proxies: mockProxies + }; }); + + client = new StorageClient(); }); -describe("#getProposal", () => { - describe("when something is wrong at cockpit side (e.g., the requested Dbus iface does not exist)", () => { +describe("#proposal", () => { + const checkAvailableDevices = (availableDevices) => { + expect(availableDevices).toEqual([ + { id: "/dev/sda", label: "/dev/sda, 950 GiB, Windows" }, + { id: "/dev/sdb", label: "/dev/sdb, 500 GiB" } + ]); + }; + + 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 } + ]); + + expect(result.volumes[0]).toEqual({ + mountPoint: "/test1", + optional: true, + deviceType: "partition", + encrypted: false, + fsTypes: ["Btrfs", "Ext3"], + fsType: "Btrfs", + minSize: 1024, + maxSize:2048, + fixedSizeLimits: false, + adaptiveSizes: false, + snapshots: true, + snapshotsConfigurable: true, + snapshotsAffectSizes: false, + sizeRelevantVolumes: [] + }); + expect(result.volumes[1].mountPoint).toEqual("/test2"); + }; + + describe("#getData", () => { beforeEach(() => { - // @ts-ignore - DBusClient.mockImplementation(() => { - return { - proxy: (iface) => { - if (iface === PROPOSAL_IFACE) throw new Error("Wrong!"); + contexts.withAvailableDevices(); + contexts.withProposal(); + }); - return proxies(iface); - } - }; - }); + it("returns the available devices and the proposal result", async () => { + const { availableDevices, result } = await client.proposal.getData(); + checkAvailableDevices(availableDevices); + checkProposalResult(result); + }); + }); + + describe("#getAvailableDevices", () => { + beforeEach(() => { + contexts.withAvailableDevices(); }); - it("returns an empty object", async () => { - const client = new StorageClient(); - const proposal = await client.getProposal(); - expect(proposal).toStrictEqual({}); + it("returns the list of available devices", async () => { + const availableDevices = await client.proposal.getAvailableDevices(); + checkAvailableDevices(availableDevices); }); }); - describe("when cockpit returns a proxy", () => { - describe("but holding a not valid proposal", () => { + describe("#getResult", () => { + describe("if there is no proposal yet", () => { beforeEach(() => { - proposalProxy = { ...validProposalProxy, valid: false }; + contexts.withoutProposal(); }); - it("returns an empty object", async() => { - const client = new StorageClient(); - const proposal = await client.getProposal(); - - expect(proposal).toStrictEqual({}); + it("returns undefined", async () => { + const result = await client.proposal.getResult(); + expect(result).toBe(undefined); }); }); - describe("with a valid proposal", () => { - it("returns the storage proposal settings and actions", async () => { - const client = new StorageClient(); - const proposal = await client.getProposal(); - expect(proposal.availableDevices).toEqual([ - { id: "/dev/sda", label: "/dev/sda, 950 GiB, Windows" }, - { id: "/dev/sdb", label: "/dev/sdb, 500 GiB" } - ]); - expect(proposal.candidateDevices).toEqual(["/dev/sda"]); - expect(proposal.lvm).toBeTruthy(); - expect(proposal.actions).toEqual([ - { text: "Mount /dev/sdb1 as root", subvol: false, delete: false } - ]); - - expect(proposal.volumes[0]).toEqual({ - mountPoint: "/test1", - optional: true, - deviceType: "partition", - encrypted: false, - fsTypes: ["Btrfs", "Ext3"], - fsType: "Btrfs", - minSize: 1024, - maxSize:2048, - fixedSizeLimits: false, - adaptiveSizes: false, - snapshots: true, - snapshotsConfigurable: true, - snapshotsAffectSizes: false, - sizeRelevantVolumes: [] - }); - expect(proposal.volumes[1].mountPoint).toEqual("/test2"); + describe("if there is a proposal", () => { + beforeEach(() => { + contexts.withProposal(); + }); + + it("returns the proposal settings and actions", async () => { + const result = await client.proposal.getResult(); + checkProposalResult(result); }); }); }); -}); -describe("#calculate", () => { - it("calculates a default proposal when no settings are given", async () => { - const client = new StorageClient(); - await client.calculateProposal({}); - - expect(calculateFn).toHaveBeenCalledWith({}); - }); + describe("#calculate", () => { + beforeEach(() => { + cockpitProxies.proposalCalculator = { + Calculate: jest.fn() + }; + }); - it("calculates a proposal with the given settings", async () => { - const client = new StorageClient(); - await client.calculateProposal({ - candidateDevices: ["/dev/vda"], - encryptionPassword: "12345", - lvm: true, - volumes: [ - { - mountPoint: "/test1", - encrypted: false, - fsType: "Btrfs", - minSize: 1024, - maxSize:2048, - fixedSizeLimits: false, - snapshots: true - }, - { - mountPoint: "/test2", - minSize: 1024 - } - ] + it("calculates a default proposal when no settings are given", async () => { + await client.proposal.calculate({}); + expect(cockpitProxies.proposalCalculator.Calculate).toHaveBeenCalledWith({}); }); - expect(calculateFn).toHaveBeenCalledWith({ - CandidateDevices: { t: "as", v: ["/dev/vda"] }, - EncryptionPassword: { t: "s", v: "12345" }, - LVM: { t: "b", v: true }, - Volumes: { - t: "aa{sv}", - v: [ + it("calculates a proposal with the given settings", async () => { + await client.proposal.calculate({ + candidateDevices: ["/dev/vda"], + encryptionPassword: "12345", + lvm: true, + volumes: [ { - MountPoint: { t: "s", v: "/test1" }, - Encrypted: { t: "b", v: false }, - FsType: { t: "s", v: "Btrfs" }, - MinSize: { t: "x", v: 1024 }, - MaxSize: { t: "x", v: 2048 }, - FixedSizeLimits: { t: "b", v: false }, - Snapshots: { t: "b", v: true } + mountPoint: "/test1", + encrypted: false, + fsType: "Btrfs", + minSize: 1024, + maxSize:2048, + fixedSizeLimits: false, + snapshots: true }, { - MountPoint: { t: "s", v: "/test2" }, - MinSize: { t: "x", v: 1024 } + mountPoint: "/test2", + minSize: 1024 } ] - } + }); + + expect(cockpitProxies.proposalCalculator.Calculate).toHaveBeenCalledWith({ + CandidateDevices: { t: "as", v: ["/dev/vda"] }, + 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 }, + FsType: { t: "s", v: "Btrfs" }, + MinSize: { t: "x", v: 1024 }, + MaxSize: { t: "x", v: 2048 }, + FixedSizeLimits: { t: "b", v: false }, + Snapshots: { t: "b", v: true } + }, + { + MountPoint: { t: "s", v: "/test2" }, + MinSize: { t: "x", v: 1024 } + } + ] + } + }); + }); + }); +}); + +describe("#iscsi", () => { + describe("#getInitiatorName", () => { + beforeEach(() => { + cockpitProxies.iscsiInitiator = { + InitiatorName: "iqn.1996-04.com.suse:01:351e6d6249" + }; + }); + + it("returns the current initiator name", async () => { + const initiatorName = await client.iscsi.getInitiatorName(); + expect(initiatorName).toEqual("iqn.1996-04.com.suse:01:351e6d6249"); + }); + }); + + describe("#setInitiatorName", () => { + beforeEach(() => { + cockpitProxies.iscsiInitiator = { + InitiatorName: "iqn.1996-04.com.suse:01:351e6d6249" + }; + }); + + it("sets the given initiator name", async () => { + await client.iscsi.setInitiatorName("test"); + const initiatorName = await client.iscsi.getInitiatorName(); + expect(initiatorName).toEqual("test"); + }); + }); + + describe("#getNodes", () => { + describe("if there is no exported iSCSI nodes yet", () => { + beforeEach(() => { + contexts.withoutISCSINodes(); + }); + + it("returns an empty list", async () => { + const result = await client.iscsi.getNodes(); + expect(result).toStrictEqual([]); + }); + }); + + describe("if there are exported iSCSI nodes", () => { + beforeEach(() => { + contexts.withISCSINodes(); + }); + + it("returns a list with the exported iSCSI nodes", async () => { + const result = await client.iscsi.getNodes(); + expect(result.length).toEqual(2); + expect(result).toContainEqual({ + id: "1", + target: "iqn.2023-01.com.example:37dac", + address: "192.168.100.101", + port: 3260, + interface: "default", + ibft: false, + connected: false, + startup: "" + }); + expect(result).toContainEqual({ + id: "2", + target: "iqn.2023-01.com.example:74afb", + address: "192.168.100.102", + port: 3260, + interface: "default", + ibft: true, + connected: true, + startup: "onboot" + }); + }); + }); + }); + + describe("#discover", () => { + beforeEach(() => { + cockpitProxies.iscsiInitiator = { + Discover: jest.fn() + }; + }); + + it("performs an iSCSI discovery with the given options", async () => { + await client.iscsi.discover("192.168.100.101", 3260, { + username: "test", + password: "12345", + reverseUsername: "target", + reversePassword: "notsecret" + }); + + expect(cockpitProxies.iscsiInitiator.Discover).toHaveBeenCalledWith("192.168.100.101", 3260, { + Username: { t: "s", v: "test" }, + Password: { t: "s", v: "12345" }, + ReverseUsername: { t: "s", v: "target" }, + ReversePassword: { t: "s", v: "notsecret" } + }); + }); + }); + + describe("#Delete", () => { + beforeEach(() => { + cockpitProxies.iscsiInitiator = { + Delete: jest.fn() + }; + }); + + it("deletes the given iSCSI node", async () => { + await client.iscsi.delete({ id: "1" }); + expect(cockpitProxies.iscsiInitiator.Delete).toHaveBeenCalledWith( + "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1" + ); + }); + }); + + describe("#login", () => { + const nodeProxy = { + Login: jest.fn() + }; + + beforeEach(() => { + cockpitProxies.iscsiNode = { + "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1": nodeProxy + }; + }); + + it("performs an iSCSI login with the given options", async () => { + await client.iscsi.login({ id: "1" }, { + username: "test", + password: "12345", + reverseUsername: "target", + reversePassword: "notsecret", + startup: "automatic" + }); + + expect(nodeProxy.Login).toHaveBeenCalledWith({ + Username: { t: "s", v: "test" }, + Password: { t: "s", v: "12345" }, + ReverseUsername: { t: "s", v: "target" }, + ReversePassword: { t: "s", v: "notsecret" }, + Startup: { t: "s", v: "automatic" } + }); + }); + }); + + describe("#logout", () => { + const nodeProxy = { + Logout: jest.fn() + }; + + beforeEach(() => { + cockpitProxies.iscsiNode = { + "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1": nodeProxy + }; + }); + + it("performs an iSCSI logout of the given node", async () => { + await client.iscsi.logout({ id: "1" }); + expect(nodeProxy.Logout).toHaveBeenCalled(); }); }); }); diff --git a/web/src/components/overview/StorageSection.jsx b/web/src/components/overview/StorageSection.jsx index 66c8252017..0379e71372 100644 --- a/web/src/components/overview/StorageSection.jsx +++ b/web/src/components/overview/StorageSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -69,7 +69,7 @@ export default function StorageSection({ showErrors }) { useEffect(() => { const updateProposal = async () => { - const proposal = await cancellablePromise(client.storage.getProposal()); + const proposal = await cancellablePromise(client.storage.proposal.getData()); const errors = await cancellablePromise(client.storage.getValidationErrors()); dispatch({ type: "UPDATE_PROPOSAL", payload: { proposal, errors } }); diff --git a/web/src/components/overview/StorageSection.test.jsx b/web/src/components/overview/StorageSection.test.jsx index ff8941d050..de8d9ec7e6 100644 --- a/web/src/components/overview/StorageSection.test.jsx +++ b/web/src/components/overview/StorageSection.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -41,8 +41,10 @@ let proposal = { { id: "/dev/sda", label: "/dev/sda, 500 GiB" }, { id: "/dev/sdb", label: "/dev/sdb, 650 GiB" } ], - candidateDevices: ["/dev/sda"], - lvm: false + result: { + candidateDevices: ["/dev/sda"], + lvm: false + } }; let errors = []; let onStatusChangeFn = jest.fn(); @@ -51,7 +53,7 @@ beforeEach(() => { createClient.mockImplementation(() => { return { storage: { - getProposal: jest.fn().mockResolvedValue(proposal), + proposal: { getData: jest.fn().mockResolvedValue(proposal) }, getStatus: jest.fn().mockResolvedValue(status), getValidationErrors: jest.fn().mockResolvedValue(errors), onStatusChange: onStatusChangeFn @@ -111,7 +113,7 @@ describe("when there is a proposal", () => { describe("when there is no proposal yet", () => { beforeEach(() => { - proposal = undefined; + proposal = { result: undefined }; errors = [{ message: "Fake error" }]; }); diff --git a/web/src/components/storage/ProposalActionsSection.jsx b/web/src/components/storage/ProposalActionsSection.jsx index 484f8f71df..026f7d68d3 100644 --- a/web/src/components/storage/ProposalActionsSection.jsx +++ b/web/src/components/storage/ProposalActionsSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -31,7 +31,7 @@ export default function ProposalActionsSection({ proposal, errors }) { hasSeparator errors={errors} > - + ); } diff --git a/web/src/components/storage/ProposalActionsSection.test.jsx b/web/src/components/storage/ProposalActionsSection.test.jsx index 3428d79a9b..602496d705 100644 --- a/web/src/components/storage/ProposalActionsSection.test.jsx +++ b/web/src/components/storage/ProposalActionsSection.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -26,7 +26,7 @@ import { ProposalActionsSection } from "~/components/storage"; jest.mock("~/components/storage/ProposalActions", () => mockComponent("ProposalActions content")); -const proposal = {}; +const proposal = { result: {} }; describe("ProposalActionsSection", () => { it("renders the proposal actions", async () => { diff --git a/web/src/components/storage/ProposalPage.jsx b/web/src/components/storage/ProposalPage.jsx index 582de84742..206f5de105 100644 --- a/web/src/components/storage/ProposalPage.jsx +++ b/web/src/components/storage/ProposalPage.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -68,7 +68,7 @@ export default function ProposalPage() { const loadProposal = async () => { dispatch({ type: "SET_BUSY" }); - const proposal = await cancellablePromise(client.storage.getProposal()); + const proposal = await cancellablePromise(client.storage.proposal.getData()); const errors = await cancellablePromise(client.storage.getValidationErrors()); dispatch({ @@ -82,12 +82,12 @@ export default function ProposalPage() { const calculateProposal = async (settings) => { dispatch({ type: "SET_BUSY" }); - await client.storage.calculateProposal({ ...state.proposal, ...settings }); + await client.storage.proposal.calculate({ ...state.proposal.result, ...settings }); dispatch({ type: "CALCULATE" }); }; const PageContent = () => { - if (state.busy || !state.proposal) return ; + if (state.busy || state.proposal?.result === undefined) return ; return ( <> diff --git a/web/src/components/storage/ProposalPage.test.jsx b/web/src/components/storage/ProposalPage.test.jsx index e83dc11e35..5e9162bbd5 100644 --- a/web/src/components/storage/ProposalPage.test.jsx +++ b/web/src/components/storage/ProposalPage.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -52,9 +52,11 @@ beforeEach(() => { createClient.mockImplementation(() => { return { storage: { - getProposal: jest.fn().mockResolvedValue(proposal), - getValidationErrors: jest.fn().mockResolvedValue([]), - calculateProposal: jest.fn().mockResolvedValue(0) + proposal: { + getData: jest.fn().mockResolvedValue(proposal), + calculate: jest.fn().mockResolvedValue(0) + }, + getValidationErrors: jest.fn().mockResolvedValue([]) } }; }); @@ -62,7 +64,7 @@ beforeEach(() => { describe("when there is no proposal yet", () => { beforeEach(() => { - proposal = undefined; + proposal = { result: undefined }; }); it("renders the skeleton", async () => { @@ -74,7 +76,7 @@ describe("when there is no proposal yet", () => { describe("when there is a proposal", () => { beforeEach(() => { - proposal = {}; + proposal = { result: {} }; }); it("renders the sections", async () => { diff --git a/web/src/components/storage/ProposalSettingsForm.jsx b/web/src/components/storage/ProposalSettingsForm.jsx index 13f3c83e58..1df68271a1 100644 --- a/web/src/components/storage/ProposalSettingsForm.jsx +++ b/web/src/components/storage/ProposalSettingsForm.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -50,9 +50,9 @@ const reducer = (state, action) => { export default function ProposalSettingsForm({ id, proposal, onSubmit, onValidate }) { const [state, dispatch] = useReducer(reducer, { - lvm: proposal.lvm, - encryption: proposal.encryptionPassword?.length !== 0, - encryptionPassword: proposal.encryptionPassword + lvm: proposal.result.lvm, + encryption: proposal.result.encryptionPassword?.length !== 0, + encryptionPassword: proposal.result.encryptionPassword }); const onLvmChange = (value) => { diff --git a/web/src/components/storage/ProposalSettingsForm.test.jsx b/web/src/components/storage/ProposalSettingsForm.test.jsx index a87d80cd83..5a21d668d8 100644 --- a/web/src/components/storage/ProposalSettingsForm.test.jsx +++ b/web/src/components/storage/ProposalSettingsForm.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -26,8 +26,10 @@ import { installerRender } from "~/test-utils"; import { ProposalSettingsForm } from "~/components/storage"; const proposal = { - lvm: false, - encryptionPassword: "" + result: { + lvm: false, + encryptionPassword: "" + } }; const onSubmitFn = jest.fn(); const onValidateFn = jest.fn(); @@ -61,7 +63,7 @@ describe("ProposalSettingsForm", () => { describe("Input for setting LVM", () => { it("gets its initial value from given proposal", () => { installerRender( - + ); const lvmCheckbox = screen.getByRole("checkbox", { name: "Use LVM" }); @@ -93,7 +95,7 @@ describe("ProposalSettingsForm", () => { it("gets rendered as checked when given proposal contains a password", () => { installerRender( - + ); const encryptionOptionsCheckbox = screen.getByRole("checkbox", { name: "Encrypt devices" }); @@ -120,7 +122,7 @@ describe("ProposalSettingsForm", () => { it("changes its state when user types on it", async () => { const { user } = installerRender( ); diff --git a/web/src/components/storage/ProposalSettingsSection.jsx b/web/src/components/storage/ProposalSettingsSection.jsx index 79e776f386..09fa9d3357 100644 --- a/web/src/components/storage/ProposalSettingsSection.jsx +++ b/web/src/components/storage/ProposalSettingsSection.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -36,8 +36,8 @@ export default function ProposalSettingsSection({ proposal, calculateProposal }) const ProposalDescription = () => { const settingsText = (proposal) => { let text = "Create file systems over"; - if (proposal.encryptionPassword.length > 0) text += " encrypted"; - text += proposal.lvm ? " LVM volumes" : " partitions"; + if (proposal.result.encryptionPassword.length > 0) text += " encrypted"; + text += proposal.result.lvm ? " LVM volumes" : " partitions"; return text; }; @@ -46,7 +46,7 @@ export default function ProposalSettingsSection({ proposal, calculateProposal }) {settingsText(proposal)} - Create the following file systems: {proposal.volumes.map(v => ( + Create the following file systems: {proposal.result.volumes.map(v => ( {v.mountPoint} ))} diff --git a/web/src/components/storage/ProposalSettingsSection.test.jsx b/web/src/components/storage/ProposalSettingsSection.test.jsx index 5ca3b7e920..dc8d2b9141 100644 --- a/web/src/components/storage/ProposalSettingsSection.test.jsx +++ b/web/src/components/storage/ProposalSettingsSection.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -36,10 +36,12 @@ const FakeProposalSettingsForm = ({ id, onSubmit }) => { jest.mock("~/components/storage/ProposalSettingsForm", () => FakeProposalSettingsForm); const proposal = { - candidateDevices: ["/dev/sda"], - encryptionPassword: "", - lvm: false, - volumes: [{ mountPoint: "/test1" }, { mountPoint: "/test2" }] + result: { + candidateDevices: ["/dev/sda"], + encryptionPassword: "", + lvm: false, + volumes: [{ mountPoint: "/test1" }, { mountPoint: "/test2" }] + } }; it("renders the list of the volumes to create", () => { @@ -108,8 +110,8 @@ it("closes the popup and submits the form when accept is clicked", async () => { describe("when neither lvm nor encryption are selected", () => { beforeEach(() => { - proposal.lvm = false; - proposal.encryptionPassword = ""; + proposal.result.lvm = false; + proposal.result.encryptionPassword = ""; }); it("renders the proper description for the current settings", () => { @@ -121,8 +123,8 @@ describe("when neither lvm nor encryption are selected", () => { describe("when lvm is selected", () => { beforeEach(() => { - proposal.lvm = true; - proposal.encryptionPassword = ""; + proposal.result.lvm = true; + proposal.result.encryptionPassword = ""; }); it("renders the proper description for the current settings", () => { @@ -134,8 +136,8 @@ describe("when lvm is selected", () => { describe("when encryption is selected", () => { beforeEach(() => { - proposal.lvm = false; - proposal.encryptionPassword = "12345"; + proposal.result.lvm = false; + proposal.result.encryptionPassword = "12345"; }); it("renders the proper description for the current settings", () => { @@ -147,8 +149,8 @@ describe("when encryption is selected", () => { describe("when LVM and encryption are selected", () => { beforeEach(() => { - proposal.lvm = true; - proposal.encryptionPassword = "12345"; + proposal.result.lvm = true; + proposal.result.encryptionPassword = "12345"; }); it("renders the proper description for the current settings", () => { diff --git a/web/src/components/storage/ProposalSummary.jsx b/web/src/components/storage/ProposalSummary.jsx index 1585fdf199..25e7fd7562 100644 --- a/web/src/components/storage/ProposalSummary.jsx +++ b/web/src/components/storage/ProposalSummary.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -30,9 +30,9 @@ export default function ProposalSummary({ proposal }) { ); }; - const device = proposal.availableDevices.find(d => d.id === proposal.candidateDevices[0]); + if (proposal.result === undefined) return Device not selected yet; - if (!device) return Device not selected yet; + const device = proposal.availableDevices.find(d => d.id === proposal.result.candidateDevices[0]); return ( diff --git a/web/src/components/storage/ProposalTargetForm.jsx b/web/src/components/storage/ProposalTargetForm.jsx index 13526e6814..c1be608c17 100644 --- a/web/src/components/storage/ProposalTargetForm.jsx +++ b/web/src/components/storage/ProposalTargetForm.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -28,7 +28,7 @@ import { import { DeviceSelector } from "~/components/storage"; export default function ProposalTargetForm({ id, proposal, onSubmit }) { - const [candidateDevices, setCandidateDevices] = useState(proposal.candidateDevices); + const [candidateDevices, setCandidateDevices] = useState(proposal.result.candidateDevices); const accept = (e) => { e.preventDefault(); diff --git a/web/src/components/storage/ProposalTargetForm.test.jsx b/web/src/components/storage/ProposalTargetForm.test.jsx index 41170923d7..00627488f1 100644 --- a/web/src/components/storage/ProposalTargetForm.test.jsx +++ b/web/src/components/storage/ProposalTargetForm.test.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) [2022] SUSE LLC + * Copyright (c) [2022-2023] SUSE LLC * * All Rights Reserved. * @@ -30,7 +30,9 @@ const proposal = { { id: "/dev/sda", label: "/dev/sda, 500 GiB" }, { id: "/dev/sdb", label: "/dev/sdb, 650 GiB" } ], - candidateDevices: ["/dev/sda"], + result: { + candidateDevices: ["/dev/sda"] + } }; const onSubmitFn = jest.fn();