diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 337f3dd006..2471e17087 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: - name: Install Ruby development files run: zypper --non-interactive install gcc gcc-c++ make openssl-devel ruby-devel npm augeas-devel + - name: Install required packages + run: zypper --non-interactive install yast2-iscsi-client + - name: Install RubyGems dependencies run: bundle config set --local with 'development' && bundle install diff --git a/doc/dbus_api.md b/doc/dbus_api.md index b0fc921aa3..90bdd51a0d 100644 --- a/doc/dbus_api.md +++ b/doc/dbus_api.md @@ -1,4 +1,4 @@ -# D-Bus API Document +# D-Bus API Reference ## General Principles @@ -111,66 +111,327 @@ Iface: o.o.YaST.Installer1.Software - PropertiesChanged ( only standard one from org.freedesktop.DBus.Properties interface ) -## Storage +## `org.opensuse.DInstaller.Storage` Service -### org.opensuse.DInstaller.Storage1 +Service for managing storage devices. -#### Methods +### Overview -- Probe -> void +~~~ +/DInstaller/Storage1 + .ObjectManager + .DInstaller.ServiceStatus1 + .DInstaller.Progress1 + .DInstaller.Validation1 + .DInstaller.Storage1 + .DInstaller.Storage1.Proposal.Calculator + .DInstaller.Storage1.ISCSI.Initiator +/DInstaller/Storage1/Proposal + .DInstaller.Storage1.Proposal +/DInstaller/Storage1/iscsi_nodes/[0-9]+ + .DInstaller.Storage1.ISCSI.Node +~~~ -- Install -> void +### D-Bus Objects -- Finish -> void +#### `/org/opensuse/DInstaller/Storage1` Object -### org.opensuse.DInstaller.Proposal1 +~~~ +/DInstaller/Storage1 + .ObjectManager + .DInstaller.ServiceStatus1 + .DInstaller.Progress1 + .DInstaller.Validation1 + .DInstaller.Storage1 + .DInstaller.Storage1.Proposal.Calculator + .DInstaller.Storage1.ISCSI.Initiator +~~~ -** Making space is not covered yet** +Main object exported by the service `org.opensuse.DInstaller.Service`. This object implements the `org.freedesktop.DBus.ObjectManager` interface and should be used by clients to discover other objects. -#### Properties +This object also implements generic interfaces to manage the service status, progress and validation. + +Moreover, it implements interfaces to manipulate the global state (perform installation, create proposals, login sessions for iSCSI nodes, etc). + +#### `/org/opensuse/DInstaller/Storage1/Proposal` Object + +~~~ +/DInstaller/Storage1/Proposal + .DInstaller.Storage1.Proposal +~~~ + +This object is exported only if a proposal was already calculated (successful or not). It can be used to inspect the result of the calculated proposal. + +#### `/org/opensuse/DInstaller/Storage1/iscsi_nodes/[0-9]+` Objects + +~~~ +/DInstaller/Storage1/iscsi_nodes/[0-9]+ + .DInstaller.Storage1.ISCSI.Node +~~~ + +Objects representing iSCSI nodes are dynamically exported when a successful iSCSI discovery is performed, see `.org.opensuse.DInstaller.Storage1.ISCSI.Initiator` interface. + +### D-Bus Interfaces + +#### `org.opensuse.DInstaller.Storage1` Interface + +Offers methods for performing general installation actions. + +##### Methods + +~~~ +Probe() +Install() +Finish() +~~~ + +#### `org.opensuse.DInstaller.Storage1.Proposal.Calculator` Interface + +Allows creating a storage proposal. + +##### Methods + +~~~ +Calculate(in a{sv} settings, + out u result) +~~~ + +##### Properties + +~~~ +AvailableDevices readable a(ssa{sv}) +VolumeTemplates readable aa{sv} +Result readable o +~~~ + +##### Details + +###### `Calculate` method + +~~~ +Calculate(in a{sv} settings, + out u result) +~~~ + +Calculates a new proposal with the given settings. A proposal object is exported when the proposal is calculated. + +Arguments: + +* `in a{sv} settings`: Allowed settings correspond to the properties defined by `org.opensuse.DInstaller.Storage1.Proposal` interface. +* `out u result`: `0` on success and `1` on failure. + +###### `AvailableDevices` Property + +~~~ +AvailableDevices readable a(ssa{sv}) +~~~ + +Array in which each element has a device name, description, and extra data. + +Example: `1 "/dev/sda" "/dev/sda, 8.00 GiB, USB" 0` + +Extra data is not used yet. + +###### `VolumeTemplates` Property + +~~~ +VolumeTemplates readable aa{sv} +~~~ + +Templates that can be used as starting point for the volumes of a new proposal. See `Volumes` property from `org.opensuse.DInstaller.Storage1.Proposal` interface. + +###### `Result` Property + +~~~ +Result readable o +~~~ + +Path of the object with the proposal result, typically `/org/opensuse/DInstaller/Storage1/Proposal`. If there is no proposal exported yet, then the path points to root `/`. + +#### `org.opensuse.DInstaller.Storage1.Proposal` Interface + +Information about the calculated storage proposal. + +##### Properties + +~~~ +CandidateDevices readable as +LVM readable b +EncryptionPassword readable s +Volumes readable aa{sv} +Actions readable aa{sv} +~~~ + +##### Details + +###### `Volumes` Property + +~~~ +Volumes readable aa{sv} +~~~ + +List of volumes used for calculating the proposal. + +Each volume is defined by the following properties: + +~~~ +DeviceType s +Optional b +Encrypted b +MountPoint s +FixedSizeLimits b +AdaptativeSizes b +MinSize x +MaxSize x +FsTypes as +FsType s +Snapshots b +SnapshotsConfigurable b +SnapshotsAffectSizes b +VolumesWithFallbackSizes as +~~~ + +Example: + +~~~ +1 14 DeviceType s "partition" Optional b false Encrypted b false MountPoint s / FixedSizeLimit b false AdaptiveSizes b false MinSize x 1024 MaxSize x 2048 FsTypes as 3 Btrfs XFS EXT4 FsType Btrfs Snapshots b true SnapshotsConfigurable b true SnapshotsAffectSizes b false VolumeWithFallbackSizes as 1 /home +~~~ + +###### `Actions` Property + +~~~ +Actions readable aa{sv} +~~~ + +Actions to perform in the system to create the proposal. If the proposal failed, then the list of actions is empty. + +Each action is defined by the following properties: + +~~~ +Text readable s +Subvol readable b +Delete readable b +~~~ + +Example: + +~~~ +2 3 Text s "Create partition /dev/vdb1" Subvol b false Delete b false 3 Text s "Delete Btrfs subvolume @/var" Subvol b true Delete b true +~~~ + +#### `org.opensuse.DInstaller.Storage1.ISCSI.Initiator` Interface + +Provides methods for configuring iSCSI initiator and for discovering nodes. + +##### Methods + +~~~ +Discover(in s address, + in u port, + in a{sv} options, + out u result) +Delete(in o iscsi_node_path, + out u result) +~~~ + +##### Properties + +~~~ +IniciatorName readable,writable s +~~~ + +##### Details + +###### `Discover` Method + +~~~ +Discover(in s address, + in u port, + in a{sv} options, + out u result) +~~~ + +Performs nodes discovery. Discovered nodes are exported with the path `/org/opensuse/DInstaller/iscsi/node[0-9]+`. + +Arguments: + +* `in s address`: IP address of the iSCSI server. +* `in u port`: Port of the iSCSI server. +* `in a{sv} options`: + * `Username s`: Username for authentication by target. + * `Password s`: Password for authentication by target. + * `ReverseUsername s`: Username for authentication by initiator. + * `ReversePassword s`: Password for authentication by initiator. +* `out u result`: `0` on success and `1` on failure. + +##### `Delete` Method + +~~~ +Delete(in o iscsi_node_path, + out u result) +~~~ + +Deletes a discovered iSCSI node. The iSCSI node object is unexported. Note that connected nodes cannot be deleted. + +Arguments: + +* `in o iscsi_node_path`: Path of the iSCSI node to delete. +* `out u result`: `0` on success and `1` on failure if the given node is not exported, `2` on failure because any other reason. + +#### `org.opensuse.DInstaller.Storage1.ISCSI.Node` Interface + +This interface is implemented by objects exported at `/org/opensuse/DInstaller/Storage1/iscsi_nodes/[0-9]+` path. It provides information about an iSCSI node and allows to perform login and logout. + +##### Methods + +~~~ +Login(in a{sv} options, + out u result) +Logout(out u result) +~~~ + +##### Properties + +~~~ +Target readable s +Address readable s +Port readable u +Interface readable s +Startup readable s +~~~ + +##### Details + +###### `Login` Method + +~~~ +Login(in a{sv} options, + out u result) +~~~ + +Creates an iSCSI session. If the session is created, then a new iSCSI session object is exported with the path `/org/opensuse/DInstaller/Storage1/iscsi/session[0-9]+`. + +Arguments: + +* `in a{sv} options`: + * `Username s`: Username for authentication by target. + * `Password s`: Password for authentication by target. + * `ReverseUsername s`: Username for authentication by initiator. + * `ReversePassword s`: Password for authentication by initiator. + * `Startup s`: startup mode (`manual`, `onboot`, `automatic`). +* `out u result`: `0` on success, `1` on failure if the given startup value is not valid, and `2` on failure because any other reason. + +###### `Logout` Method + +~~~ +Logout(out u result) +~~~ + +Closes an iSCSI session. + +Arguments: -- AvailableDevices -> a(ssa{sv}) (r) - e.g., ["/dev/sda", "/dev/sda, 8.00 GiB, USB", {}] - -- CandidateDevices -> as (r) - -- LVM -> b (r) - -- EncryptionPassword -> s (r) - -- VolumeTemplates -> aa{sv} (r) - Struct keys and values: see Volumes - -- Volumes -> aa{sv} (r) - Struct keys and values: - - DeviceType -> s - e.g., "partition", "lvm_lv" - - Optional -> b - - Encrypted -> b - - MountPoint -> s - - FixedSizeLimits -> b - - AdaptativeSizes -> b - - MinSize -> s - - MaxSize -> s - - FsTypes -> as - e.g., ["Btrfs", "XFS"] - - FsType -> s - - Snapshots -> b - - SnapshotsConfigurable -> b - - SnapshotsAffectSizes -> b - - VolumesWithFallbackSizes -> as - e.g., ["/home", "/var"] - -- Actions -> aa{sv} (r) - Struct keys and values: - - Text -> s (r) - - Subvol -> b (r) - - Delete -> b (r) - -#### Methods - -- Calculate(aa{sv}) -> u (0 success, 1 fail) - Calculates a new proposal with the given properties (see proposal properties). +* `out u result`: `0` on success and `1` on failure. ## Users diff --git a/service/lib/dinstaller/dbus/storage/iscsi_node.rb b/service/lib/dinstaller/dbus/storage/iscsi_node.rb new file mode 100644 index 0000000000..a10b60f2a7 --- /dev/null +++ b/service/lib/dinstaller/dbus/storage/iscsi_node.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus" +require "dinstaller/dbus/base_object" +require "dinstaller/dbus/storage/with_iscsi_auth" + +module DInstaller + module DBus + module Storage + # Class representing an iSCSI node + class ISCSINode < BaseObject + include WithISCSIAuth + + # @return [DInstaller::Storage::ISCSI::Manager] + attr_reader :iscsi_manager + + # @return [DInstaller::Storage::ISCSI::Node] + attr_reader :iscsi_node + + # Constructor + # + # @param iscsi_manager [DInstaller::Storage::Iscsi::Manager] + # @param iscsi_node [DInstaller::Storage::Iscsi::Node] + # @param path [DBus::ObjectPath] Path in which the object is exported + # @param logger [Logger, nil] + def initialize(iscsi_manager, iscsi_node, path, logger: nil) + super(path, logger: logger) + + @iscsi_manager = iscsi_manager + @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] + def target + iscsi_node.target || "" + end + + # IP address of the iSCSI target + # + # @return [String] + def address + iscsi_node.address || "" + end + + # Port of the iSCSI target + # + # @return [Integer] + def port + iscsi_node.port || 0 + end + + # Interface of the iSCSI node + # + # @return [String] + def interface + iscsi_node.interface || "" + end + + # Whether the node is connected + # + # @return [Boolean] + def connected + iscsi_node.connected? + end + + # Startup status of the connection + # + # @return [String] Empty if the node is not connected + def startup + iscsi_node.startup || "" + end + + # Sets the associated iSCSI node + # + # @note A properties changed signal is always emitted. + # + # @param value [DInstaller::Storage::ISCSI::Node] + def iscsi_node=(value) + @iscsi_node = value + + properties = interfaces_and_properties[ISCSI_NODE_INTERFACE] + dbus_properties_changed(ISCSI_NODE_INTERFACE, properties, []) + end + + # Creates an iSCSI session + # + # @param options [Hash] Options from a D-Bus call: + # @option Username [String] Username for authentication by target + # @option Password [String] Password for authentication by target + # @option ReverseUsername [String] Username for authentication by initiator + # @option ReversePassword [String] Password for authentication by inititator + # @option Startup [String] Valid values are "onboot", "manual", "automatic" + # + # @return [Integer] 0 on success, 1 on failure if the given startup value is not valid, and + # 2 on failure because any other reason. + def login(options = {}) + auth = iscsi_auth(options) + startup = options["Startup"] + + if startup && !DInstaller::Storage::ISCSI::Manager::STARTUP_OPTIONS.include?(startup) + logger.info("iSCSI login error: startup value #{startup} is not valid") + return 1 + end + + success = iscsi_manager.login(iscsi_node, auth, startup: startup) + return 0 if success + + logger.info("iSCSI login error: fail to login iSCSI node #{path}") + 2 # Error code + end + + # Logouts the iSCSI session + # + # @return [Integer] 0 on success, 1 on failure + def logout + success = iscsi_manager.logout(iscsi_node) + success ? 0 : 1 + end + end + end + end +end diff --git a/service/lib/dinstaller/dbus/storage/iscsi_nodes_tree.rb b/service/lib/dinstaller/dbus/storage/iscsi_nodes_tree.rb new file mode 100644 index 0000000000..8e4e0873aa --- /dev/null +++ b/service/lib/dinstaller/dbus/storage/iscsi_nodes_tree.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dinstaller/dbus/with_path_generator" +require "dinstaller/dbus/storage/iscsi_node" + +module DInstaller + module DBus + module Storage + # Class representing the iSCSI nodes tree exported on D-Bus + class ISCSINodesTree + include WithPathGenerator + + ROOT_PATH = "/org/opensuse/DInstaller/Storage1/iscsi_nodes" + path_generator ROOT_PATH + + # Constructor + # + # @param service [::DBus::Service] + # @param iscsi_manager DInstaller::Storage::ISCSI::Manager] + # @param logger [Logger, nil] + def initialize(service, iscsi_manager, logger: nil) + @service = service + @iscsi_manager = iscsi_manager + @logger = logger + end + + # Finds an iSCSI D-Bus node exported with the given path + # + # @param path [::DBus::ObjectPath] + def find(path) + dbus_nodes.find { |n| n.path == path } + end + + # Updates the D-Bus tree according to the given iSCSI nodes + # + # New nodes are exported, existing nodes are updated and missing nodes are unexported. + # + # @param iscsi_nodes [Array] + def update(iscsi_nodes) + add_new_nodes(iscsi_nodes) + update_existing_nodes(iscsi_nodes) + delete_old_nodes(iscsi_nodes) + end + + private + + # @return [::DBus::Service] + attr_reader :service + + # @return [DInstaller::Storage::ISCSI::Manager] + attr_reader :iscsi_manager + + # @return [Logger] + attr_reader :logger + + # Exports a new iSCSI D-Bus node for the given iSCSI nodes which do not have a D-Bus object + # + # @param iscsi_nodes [Array] + def add_new_nodes(iscsi_nodes) + new_iscsi_nodes = iscsi_nodes.select { |n| find_node(n).nil? } + new_iscsi_nodes.each { |n| add_node(n) } + end + + # Updates the D-Bus iSCSI node for the given iSCSI nodes that already have a D-Bus object + # + # @param iscsi_nodes [Array] + def update_existing_nodes(iscsi_nodes) + existing_iscsi_nodes = iscsi_nodes.reject { |n| find_node(n).nil? } + existing_iscsi_nodes.each { |n| update_node(n) } + end + + # Unexports the D-Bus iSCSI nodes that do not represent any of the given iSCSI nodes + # + # @param iscsi_nodes [Array] + def delete_old_nodes(iscsi_nodes) + current_iscsi_nodes = dbus_nodes.map(&:iscsi_node) + deleted_iscsi_nodes = current_iscsi_nodes.select do |current_node| + iscsi_nodes.none? { |n| same_iscsi_node?(n, current_node) } + end + + deleted_iscsi_nodes.each { |n| delete_node(n) } + end + + # Exports a D-Bus node for the given iSCSI node + # + # @param iscsi_node [DInstaller::Storage::ISCSI::Node] + def add_node(iscsi_node) + dbus_node = DBus::Storage::ISCSINode.new( + iscsi_manager, iscsi_node, next_path, logger: logger + ) + service.export(dbus_node) + dbus_node.path + end + + # Updates the D-Bus node associated to the given iSCSI node + # + # @param iscsi_node [DInstaller::Storage::ISCSI::Node] + def update_node(iscsi_node) + dbus_node = find_node(iscsi_node) + dbus_node.iscsi_node = iscsi_node + end + + # Unexports the D-Bus node associated to the given iSCSI node + # + # @param iscsi_node [DInstaller::Storage::ISCSI::Node] + def delete_node(iscsi_node) + dbus_node = find_node(iscsi_node) + service.unexport(dbus_node) + end + + # Returns the D-Bus node associated to the given iSCSI node + # + # @param iscsi_node [DInstaller::Storage::ISCSI::Node] + # @return [DInstaller::DBus::Storage::ISCSINode] + def find_node(iscsi_node) + dbus_nodes.find { |n| same_iscsi_node?(n.iscsi_node, iscsi_node) } + end + + # All exported iSCSI D-Bus nodes + # + # @return [Array] + def dbus_nodes + root = service.get_node(ROOT_PATH, create: false) + return [] unless root + + root.descendant_objects + end + + # Whether the given iSCSI nodes can be considered the same iSCSI node + # + # @param iscsi_node1 [DInstaller::Storage::ISCSI::Node] + # @param iscsi_node2 [DInstaller::Storage::ISCSI::Node] + # + # @return [Boolean] + def same_iscsi_node?(iscsi_node1, iscsi_node2) + [:address, :port, :target, :interface].all? do |method| + iscsi_node1.public_send(method) == iscsi_node2.public_send(method) + end + end + end + end + end +end diff --git a/service/lib/dinstaller/dbus/storage/manager.rb b/service/lib/dinstaller/dbus/storage/manager.rb index 0f751d7eb8..b5deb27a4c 100644 --- a/service/lib/dinstaller/dbus/storage/manager.rb +++ b/service/lib/dinstaller/dbus/storage/manager.rb @@ -25,14 +25,18 @@ require "dinstaller/dbus/interfaces/progress" require "dinstaller/dbus/interfaces/service_status" require "dinstaller/dbus/interfaces/validation" +require "dinstaller/dbus/storage/proposal" require "dinstaller/dbus/storage/proposal_settings_converter" require "dinstaller/dbus/storage/volume_converter" +require "dinstaller/dbus/storage/with_iscsi_auth" +require "dinstaller/dbus/storage/iscsi_nodes_tree" module DInstaller module DBus module Storage # D-Bus object to manage storage installation class Manager < BaseObject + include WithISCSIAuth include WithServiceStatus include ::DBus::ObjectManager include DBus::Interfaces::Progress @@ -52,17 +56,12 @@ def initialize(backend, logger) register_proposal_callbacks register_progress_callbacks register_service_status_callbacks + register_iscsi_callbacks end STORAGE_INTERFACE = "org.opensuse.DInstaller.Storage1" private_constant :STORAGE_INTERFACE - dbus_interface STORAGE_INTERFACE do - dbus_method(:Probe) { probe } - dbus_method(:Install) { install } - dbus_method(:Finish) { finish } - end - def probe busy_while { backend.probe } end @@ -75,22 +74,15 @@ def finish busy_while { backend.finish } end + dbus_interface STORAGE_INTERFACE do + dbus_method(:Probe) { probe } + dbus_method(:Install) { install } + dbus_method(:Finish) { finish } + end + PROPOSAL_CALCULATOR_INTERFACE = "org.opensuse.DInstaller.Storage1.Proposal.Calculator" private_constant :PROPOSAL_CALCULATOR_INTERFACE - dbus_interface PROPOSAL_CALCULATOR_INTERFACE do - dbus_reader :available_devices, "a(ssa{sv})" - - dbus_reader :volume_templates, "aa{sv}" - - dbus_reader :result, "o" - - # result: 0 success; 1 error - dbus_method :Calculate, "in settings:a{sv}, out result:u" do |settings| - busy_while { calculate_proposal(settings) } - end - end - # List of disks available for installation # # Each device is represented by an array containing the name of the device and the label to @@ -131,6 +123,82 @@ def calculate_proposal(dbus_settings) success ? 0 : 1 end + dbus_interface PROPOSAL_CALCULATOR_INTERFACE do + dbus_reader :available_devices, "a(ssa{sv})" + + dbus_reader :volume_templates, "aa{sv}" + + dbus_reader :result, "o" + + # result: 0 success; 1 error + dbus_method :Calculate, "in settings:a{sv}, out result:u" do |settings| + busy_while { calculate_proposal(settings) } + end + end + + ISCSI_INITIATOR_INTERFACE = "org.opensuse.DInstaller.Storage1.ISCSI.Initiator" + private_constant :ISCSI_INITIATOR_INTERFACE + + # Gets the iSCSI initiator name + # + # @return [String] + def initiator_name + backend.iscsi.initiator.name || "" + end + + # Sets the iSCSI initiator name + # + # @param value [String] + def initiator_name=(value) + backend.iscsi.initiator.name = value + end + + # Performs an iSCSI discovery + # + # @param address [String] IP address of the iSCSI server + # @param port [Integer] Port of the iSCSI server + # @param options [Hash] Options from a D-Bus call: + # @option Username [String] Username for authentication by target + # @option Password [String] Password for authentication by target + # @option ReverseUsername [String] Username for authentication by initiator + # @option ReversePassword [String] Username for authentication by inititator + # + # @return [Integer] 0 on success, 1 on failure + def iscsi_discover(address, port, options = {}) + success = backend.iscsi.discover_send_targets(address, port, iscsi_auth(options)) + success ? 0 : 1 + end + + # Deletes an iSCSI node from the database + # + # @param path [::DBus::ObjectPath] + # @return [Integer] 0 on success, 1 on failure if the given node is not exported, 2 on + # failure because any other reason. + def iscsi_delete(path) + dbus_node = iscsi_nodes_tree.find(path) + if !dbus_node + logger.info("iSCSI delete error: iSCSI node #{path} is not exported") + return 1 + end + + success = backend.iscsi.delete(dbus_node.iscsi_node) + return 0 if success + + logger.info("iSCSI delete error: fail to delete iSCSI node #{path}") + 2 # Error code + end + + dbus_interface ISCSI_INITIATOR_INTERFACE do + dbus_accessor :initiator_name, "s" + + dbus_method :Discover, + "in address:s, in port:u, in options:a{sv}, out result:u" do |address, port, options| + busy_while { iscsi_discover(address, port, options) } + end + + dbus_method(:Delete, "in node:o, out result:u") { |n| iscsi_delete(n) } + end + private # @return [DInstaller::Storage::Manager] @@ -152,6 +220,12 @@ def register_proposal_callbacks end end + def register_iscsi_callbacks + backend.iscsi.on_probe do + refresh_iscsi_nodes + end + end + def properties_changed properties = interfaces_and_properties[PROPOSAL_CALCULATOR_INTERFACE] dbus_properties_changed(PROPOSAL_CALCULATOR_INTERFACE, properties, []) @@ -162,6 +236,15 @@ def export_proposal @dbus_proposal = DBus::Storage::Proposal.new(proposal, logger) @service.export(@dbus_proposal) end + + def refresh_iscsi_nodes + nodes = backend.iscsi.nodes + iscsi_nodes_tree.update(nodes) + end + + def iscsi_nodes_tree + @iscsi_nodes_tree ||= ISCSINodesTree.new(@service, backend.iscsi, logger: logger) + end end end end diff --git a/service/lib/dinstaller/dbus/storage/with_iscsi_auth.rb b/service/lib/dinstaller/dbus/storage/with_iscsi_auth.rb new file mode 100644 index 0000000000..bb89cbe8c4 --- /dev/null +++ b/service/lib/dinstaller/dbus/storage/with_iscsi_auth.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "y2iscsi_client/authentication" + +module DInstaller + module DBus + module Storage + # Mixin for creating an iSCSI authentication object from D-Bus options + module WithISCSIAuth + # Creates an iSCSI authentication object + # + # @param dbus_options [Hash] Options from a D-Bus call: + # @option Username [String] Username for authentication by target + # @option Password [String] Password for authentication by target + # @option ReverseUsername [String] Username for authentication by initiator + # @option ReversePassword [String] Username for authentication by inititator + # + # @return [Y2IscsiClient::Authentication] + def iscsi_auth(dbus_options) + Y2IscsiClient::Authentication.new.tap do |auth| + auth.username = dbus_options["Username"] + auth.password = dbus_options["Password"] + auth.username_in = dbus_options["ReverseUsername"] + auth.password_in = dbus_options["ReversePassword"] + end + end + end + end + end +end diff --git a/service/lib/dinstaller/dbus/with_path_generator.rb b/service/lib/dinstaller/dbus/with_path_generator.rb new file mode 100644 index 0000000000..604df07cea --- /dev/null +++ b/service/lib/dinstaller/dbus/with_path_generator.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "dbus/object_path" +require "pathname" + +module DInstaller + module DBus + # Mixin for creating D-Bus path of dynamically exported objects + # + # @example + # class Test1 + # include WithPathGenerator + # path_generator "/test1/objects" + # end + # + # class Test2 + # include WithPathGenerator + # path_generator "/test2", "object" + # end + # + # test1.next_path #=> "/test1/objects/1" + # test1.next_path #=> "/test1/objects/2" + # + # test2.next_path #=> "/test2/object1" + # test2.next_path #=> "/test2/object2" + module WithPathGenerator + # Generates the next based on the configuration of the path generator + # + # @return [::DBus::ObjectPath] + def next_path + self.class.next_path + end + + def self.included(base) + base.extend ClassMethods + end + + # Define class methods + module ClassMethods + def next_path + raise "path_generator not configured yet" unless @path_generator + + @path_generator.next + end + + # Configures the path generator + # + # @param base_path [String] + # @param base_name [String] + def path_generator(base_path, base_name = "") + @path_generator = PathGenerator.new(base_path, base_name) + end + + # Class for generating an object path + class PathGenerator + def initialize(base_path, base_name) + @base_path = base_path + @base_name = base_name + end + + def next + path = Pathname.new(@base_path).join(@base_name + next_id.to_s) + ::DBus::ObjectPath.new(path.to_s) + end + + private + + # Generates the next id + # + # @return [Integer] + def next_id + @last_id ||= 0 + @last_id += 1 + end + end + end + end + end +end diff --git a/service/lib/dinstaller/storage/iscsi.rb b/service/lib/dinstaller/storage/iscsi.rb new file mode 100644 index 0000000000..fc39a9b0cb --- /dev/null +++ b/service/lib/dinstaller/storage/iscsi.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module DInstaller + module Storage + # Module for iSCSI + module ISCSI + end + end +end + +require "dinstaller/storage/iscsi/initiator" +require "dinstaller/storage/iscsi/node" +require "dinstaller/storage/iscsi/manager" diff --git a/service/lib/dinstaller/storage/iscsi/initiator.rb b/service/lib/dinstaller/storage/iscsi/initiator.rb new file mode 100644 index 0000000000..fea5ee5d18 --- /dev/null +++ b/service/lib/dinstaller/storage/iscsi/initiator.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +Yast.import "IscsiClientLib" + +module DInstaller + module Storage + module ISCSI + # Class representing an open-iscsi initiator + # + # This class is a wrapper for YaST code dealing with the iSCSI initiator. Note that the YaST + # code uses a singleton module, so different instances of this class always represent the very + # same iSCSI initiator configured by YaST. + class Initiator + # Initiator name + # + # @return [String] + def name + Yast::IscsiClientLib.initiatorname + end + + # Sets the inititator name + # + # @param value [String] + def name=(value) + return if Yast::IscsiClientLib.initiatorname == value + + Yast::IscsiClientLib.writeInitiatorName(value) + end + + # Configured iSCSI offload card + # + # @return [String] + def offload_card + Yast::IscsiClientLib.GetOffloadCard() + end + + # Sets the iSCSI offload card + # + # @param value [String] + def offload_card=(value) + Yast::IscsiClientLib.SetOffloadCard(value) + end + + # Whether the initiator name was set via iBFT + # + # @return [Boolean] + def ibft_name? + !Yast::IscsiClientLib.getiBFT.fetch("iSCSI_INITIATOR_NAME", "").empty? + end + end + end + end +end diff --git a/service/lib/dinstaller/storage/iscsi/manager.rb b/service/lib/dinstaller/storage/iscsi/manager.rb new file mode 100644 index 0000000000..8ef74a8d2c --- /dev/null +++ b/service/lib/dinstaller/storage/iscsi/manager.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require "yast" +require "dinstaller/storage/iscsi/node" +require "dinstaller/storage/iscsi/initiator" + +Yast.import "IscsiClientLib" + +module DInstaller + module Storage + module ISCSI + # Manager for iSCSI + class Manager + STARTUP_OPTIONS = ["onboot", "manual", "automatic"].freeze + + # iSCSI initiator + # + # @return [Initiator] + attr_reader :initiator + + # Discovered iSCSI nodes + # + # @return [Array] + attr_reader :nodes + + # Constructor + # + # @param logger [Logger, nil] + def initialize(logger: nil) + @logger = logger || ::Logger.new($stdout) + @initiator = ISCSI::Initiator.new + + @on_probe_callbacks = [] + end + + # Performs actions for activating iSCSI + def activate + logger.info "Activating iSCSI" + + @activated = true + + # Why we need to sleep every now and then? This was copied from yast2-iscsi-client. + sl = 0.5 + + Yast::IscsiClientLib.getiBFT + sleep(sl) + + # Check initiator name, creating one if missing + return false unless Yast::IscsiClientLib.checkInitiatorName(silent: true) + + sleep(sl) + + Yast::IscsiClientLib.getConfig + Yast::IscsiClientLib.autoLogOn + sleep(sl) + end + + # Probes iSCSI + # + # Callbacks are called at the end, see {#on_probe}. + def probe + logger.info "Probing iSCSI" + + Yast::IscsiClientLib.readSessions + @nodes = Yast::IscsiClientLib.getDiscovered.map { |t| node_from(t.split) } + + @on_probe_callbacks.each(&:call) + end + + # Performs an iSCSI discovery + # + # Based on provided address and port, ie. assuming ISNS is not used. Since YaST do not offer + # UI to configure ISNS during installation, we are assuming it's not supported. + # + # @note iSCSI nodes are probed again, see {#probe_after}. + # + # @param host [String] IP address + # @param port [Integer] + # @param authentication [Y2IscsiClient::Authentication] + # + # @return [Boolean] Whether the action successes + def discover_send_targets(host, port, authentication) + ensure_activated + + probe_after do + Yast::IscsiClientLib.discover(host, port, authentication, silent: true) + end + end + + # Creates a new iSCSI session + # + # @note iSCSI nodes are probed again if needed, see {#probe_after}. + # + # @param node [Node] + # @param authentication [Y2IscsiClient::Authentication] + # @param startup [String, nil] Startup status + # + # @return [Boolean] Whether the action successes + 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 + Yast::IscsiClientLib.currentRecord = record_from(node) + Yast::IscsiClientLib.login_into_current(authentication, silent: true) && + Yast::IscsiClientLib.setStartupStatus(startup) + end + end + + # Closes an iSCSI session + # + # @note iSCSI nodes are probed again, see {#probe_after}. + # + # @param node [Node] + # @return [Boolean] Whether the action successes + def logout(node) + ensure_activated + + probe_after do + Yast::IscsiClientLib.currentRecord = record_from(node) + # Yes, this is the correct method name for logging out + Yast::IscsiClientLib.deleteRecord + end + end + + # Deletes an iSCSI node from the database + # + # @note iSCSI nodes are probed again, see {#probe_after}. + # + # @param node [Node] + # @return [Boolean] Whether the action successes + def delete(node) + probe_after do + Yast::IscsiClientLib.currentRecord = record_from(node) + Yast::IscsiClientLib.removeRecord + end + end + + # Registers a callback to be called when the nodes are probed + # + # @param block [Proc] + def on_probe(&block) + @on_probe_callbacks << block + end + + private + + # @return [Logger] + attr_reader :logger + + # Calls activation if needed + def ensure_activated + activate unless activated? + end + + # Whether activation has been already performed + # + # @return [Boolean] + def activated? + !!@activated + end + + # Creates a node from the record provided by YaST + # + # @param record [Array] Contains portal, target and interface of the iSCSI node. + # @return [Node] + def node_from(record) + ISCSI::Node.new.tap do |node| + node.portal = record[0] + node.target = record[1] + node.interface = record[2] || "default" + node.connected = false + + Yast::IscsiClientLib.currentRecord = record + node.ibtf = Yast::IscsiClientLib.iBFT?(Yast::IscsiClientLib.getCurrentNodeValues) + + session_record = find_session_for(record) + + if session_record + node.connected = true + # FIXME: the calculation of both startup and ibft imply executing getCurrentNodeValues + # (ie. calling iscsiadm) + Yast::IscsiClientLib.currentRecord = session_record + node.startup = Yast::IscsiClientLib.getStartupStatus + end + end + end + + # Generates a YaST record from a node + # + # @param node [Node] + # @return [Array] + def record_from(node) + [node.portal, node.target, node.interface] + end + + # Finds a session for the given iSCSI record + # + # @param record [Array] Contains portal, target and interface of the iSCSI node. + # @return [Array, nil] Record for the iSCSI session + def find_session_for(record) + Yast::IscsiClientLib.currentRecord = record + Yast::IscsiClientLib.find_session(true)&.split + end + + # Calls the given block and performs iSCSI probing afterwards + # + # @note Returns the result of the block. + # @param block [Proc] + def probe_after(&block) + block.call.tap { probe } + end + end + end + end +end diff --git a/service/lib/dinstaller/storage/iscsi/node.rb b/service/lib/dinstaller/storage/iscsi/node.rb new file mode 100644 index 0000000000..ec78a8bdd1 --- /dev/null +++ b/service/lib/dinstaller/storage/iscsi/node.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +module DInstaller + module Storage + module ISCSI + # Class to represent an Open-iscsi node + # + # Bear in mind Open-iscsi does not use the term node as defined by the iSCSI RFC, where a node + # is a single iSCSI initiator or target. Open-iscsi uses the term node to refer to a portal on + # a target + class Node + # Target IP address + # + # @return [String] + attr_accessor :address + + # Target port + # + # @return [Integer] + attr_accessor :port + + # Target name + # + # @return [String] + attr_accessor :target + + # Target interface + # + # @return [String] + attr_accessor :interface + + # Whether the node was initiated by iBTF + # + # @return [Boolean] + attr_accessor :ibtf + + # Whether the node is connected (there is a session) + # + # @return [Boolean] + attr_accessor :connected + + # Startup status + # + # @return [String] + attr_accessor :startup + + def ibft? + !!ibtf + end + + def connected? + !!connected + end + + def portal + "#{address}:#{port}" + end + + def portal=(value) + address, port = value.split(":") + + @address = address + @port = port.to_i + end + end + end + end +end diff --git a/service/lib/dinstaller/storage/manager.rb b/service/lib/dinstaller/storage/manager.rb index c9d6fc4e26..b9e6cd2f1b 100644 --- a/service/lib/dinstaller/storage/manager.rb +++ b/service/lib/dinstaller/storage/manager.rb @@ -28,6 +28,7 @@ require "dinstaller/storage/proposal" require "dinstaller/storage/proposal_settings" require "dinstaller/storage/callbacks" +require "dinstaller/storage/iscsi/manager" require "dinstaller/with_progress" require "dinstaller/security" require "dinstaller/dbus/clients/questions" @@ -111,6 +112,13 @@ def proposal @proposal ||= Proposal.new(logger, config) end + # iSCSI manager + # + # @return [Storage::ISCSI::Manager] + def iscsi + @iscsi ||= ISCSI::Manager.new(logger: logger) + end + # Validates the storage configuration # # @return [Array] List of validation errors @@ -133,12 +141,14 @@ def validate def activate_devices callbacks = Callbacks::Activate.new(questions_client, logger) + iscsi.activate Y2Storage::StorageManager.instance.activate(callbacks) end # Probes the devices def probe_devices # TODO: probe callbacks + iscsi.probe Y2Storage::StorageManager.instance.probe end diff --git a/service/lib/dinstaller/storage/proposal.rb b/service/lib/dinstaller/storage/proposal.rb index 83731dba45..880e6609e0 100644 --- a/service/lib/dinstaller/storage/proposal.rb +++ b/service/lib/dinstaller/storage/proposal.rb @@ -21,7 +21,6 @@ require "y2storage" require "y2storage/dialogs/guided_setup/helpers/disk" -require "dinstaller/with_progress" require "dinstaller/validation_error" require "dinstaller/storage/actions" require "dinstaller/storage/proposal_settings" @@ -42,8 +41,6 @@ module Storage # proposal.calculate(settings) #=> true # proposal.calculated_volumes #=> [Volume, Volume] class Proposal - include WithProgress - # Constructor # # @param logger [Logger] diff --git a/service/package/gem2rpm.yml b/service/package/gem2rpm.yml index 291ffeb9fc..351eb706ac 100644 --- a/service/package/gem2rpm.yml +++ b/service/package/gem2rpm.yml @@ -11,6 +11,7 @@ Requires: yast2-network Requires: yast2-proxy Requires: yast2-storage-ng + Requires: yast2-iscsi-client >= 4.5.7 Requires: yast2-users # yast2 with ArchFilter Requires: yast2 >= 4.5.20 diff --git a/service/package/rubygem-d-installer.changes b/service/package/rubygem-d-installer.changes index 2b3ca1307c..2ca8513996 100644 --- a/service/package/rubygem-d-installer.changes +++ b/service/package/rubygem-d-installer.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Feb 15 16:09:16 UTC 2023 - José Iván López González + +- Add D-Bus API for iSCSI. + ------------------------------------------------------------------- Wed Feb 15 15:18:43 UTC 2023 - Imobach Gonzalez Sosa diff --git a/service/test/dinstaller/dbus/storage/iscsi_node_test.rb b/service/test/dinstaller/dbus/storage/iscsi_node_test.rb new file mode 100644 index 0000000000..d8bc323db5 --- /dev/null +++ b/service/test/dinstaller/dbus/storage/iscsi_node_test.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "dinstaller/dbus/storage/iscsi_node" +require "dinstaller/storage/iscsi/manager" +require "dinstaller/storage/iscsi/node" + +describe DInstaller::DBus::Storage::ISCSINode do + subject { described_class.new(iscsi_manager, iscsi_node, path, logger: logger) } + + let(:iscsi_manager) { DInstaller::Storage::ISCSI::Manager.new } + + let(:iscsi_node) { DInstaller::Storage::ISCSI::Node.new } + + let(:path) { "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1" } + + let(:logger) { Logger.new($stdout, level: :warn) } + + before do + allow(subject).to receive(:dbus_properties_changed) + end + + describe "#iscsi_node=" do + it "sets the iSCSI node value" do + node = DInstaller::Storage::ISCSI::Node.new + expect(subject.iscsi_node).to_not eq(node) + + subject.iscsi_node = node + expect(subject.iscsi_node).to eq(node) + end + + it "emits properties changed signal" do + expect(subject).to receive(:dbus_properties_changed) + + subject.iscsi_node = DInstaller::Storage::ISCSI::Node.new + end + end + + describe "#login" do + it "creates an iSCSI session" do + expect(iscsi_manager).to receive(:login) do |node, auth, startup:| + expect(node).to eq(iscsi_node) + expect(auth).to be_a(Y2IscsiClient::Authentication) + expect(startup).to be_nil + end + + subject.login + end + + it "uses the given startup status" do + expect(iscsi_manager).to receive(:login).with(anything, anything, startup: "automatic") + + subject.login({ "Startup" => "automatic" }) + end + + context "when no authentication options are given" do + it "uses an empty authentication" do + expect(iscsi_manager).to receive(:login) do |_, auth| + expect(auth.by_target?).to eq(false) + expect(auth.by_initiator?).to eq(false) + end + + subject.login + end + end + + context "when authentication options are given" do + let(:auth_options) do + { + "Username" => "testi", + "Password" => "testi", + "ReverseUsername" => "testt", + "ReversePassword" => "testt" + } + end + + it "uses the expected authentication" do + expect(iscsi_manager).to receive(:login) do |_, auth| + expect(auth.username).to eq("testi") + expect(auth.password).to eq("testi") + expect(auth.username_in).to eq("testt") + expect(auth.password_in).to eq("testt") + end + + subject.login(auth_options) + end + end + + context "when the action successes" do + before do + allow(iscsi_manager).to receive(:login).and_return(true) + end + + it "returns 0" do + expect(subject.login).to eq(0) + end + end + + context "when the given startup value is not valid" do + let(:startup) { "invalid" } + + it "returns 1" do + expect(subject.login({ "Startup" => startup })).to eq(1) + end + end + + context "when the action fails" do + before do + allow(iscsi_manager).to receive(:login).and_return(false) + end + + it "returns 2" do + expect(subject.login).to eq(2) + end + end + end + + describe "#logout" do + it "closes an iSCSI session" do + expect(iscsi_manager).to receive(:logout).with(iscsi_node) + + subject.logout + end + + context "when the action successes" do + before do + allow(iscsi_manager).to receive(:logout).and_return(true) + end + + it "returns 0" do + expect(subject.logout).to eq(0) + end + end + + context "when the action fails" do + before do + allow(iscsi_manager).to receive(:logout).and_return(false) + end + + it "returns 1" do + expect(subject.logout).to eq(1) + end + end + end +end diff --git a/service/test/dinstaller/dbus/storage/iscsi_nodes_tree_test.rb b/service/test/dinstaller/dbus/storage/iscsi_nodes_tree_test.rb new file mode 100644 index 0000000000..eb88c55e19 --- /dev/null +++ b/service/test/dinstaller/dbus/storage/iscsi_nodes_tree_test.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "dinstaller/dbus/storage/iscsi_nodes_tree" +require "dinstaller/dbus/storage/iscsi_node" +require "dinstaller/storage/iscsi/manager" +require "dinstaller/storage/iscsi/node" +require "dbus" + +describe DInstaller::DBus::Storage::ISCSINodesTree do + subject { described_class.new(service, iscsi_manager, logger: logger) } + + let(:service) { instance_double(::DBus::Service) } + + let(:iscsi_manager) { DInstaller::Storage::ISCSI::Manager.new } + + let(:logger) { Logger.new($stdout, level: :warn) } + + before do + allow(service).to receive(:get_node).with(described_class::ROOT_PATH, anything) + .and_return(root_node) + + allow(root_node).to receive(:descendant_objects).and_return(dbus_nodes) + end + + let(:root_node) { DInstaller::Storage::ISCSI::Node.new } + + let(:dbus_nodes) { [] } + + describe "#find" do + let(:dbus_nodes) { [dbus_node1, dbus_node2] } + + let(:dbus_node1) do + instance_double(::DBus::Object, path: "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1") + end + + let(:dbus_node2) do + instance_double(::DBus::Object, path: "/org/opensuse/DInstaller/Storage1/iscsi_nodes/2") + end + + context "when the given path is already exported on D-Bus" do + let(:path) { "/org/opensuse/DInstaller/Storage1/iscsi_nodes/2" } + + it "returns the iSCSI D-Bus node exported with the given path" do + expect(subject.find(path)).to eq(dbus_node2) + end + end + + context "when the given path is not exported on D-Bus yet" do + let(:path) { "/org/opensuse/DInstaller/Storage1/iscsi_nodes/3" } + + it "returns nil" do + expect(subject.find(path)).to be_nil + end + end + end + + describe "#update" do + let(:dbus_nodes) { [dbus_node1, dbus_node2] } + + let(:dbus_node1) do + instance_double(DInstaller::DBus::Storage::ISCSINode, iscsi_node: node1) + end + + let(:dbus_node2) do + instance_double(DInstaller::DBus::Storage::ISCSINode, iscsi_node: node2) + end + + let(:node1) do + DInstaller::Storage::ISCSI::Node.new.tap do |node| + node.address = "192.168.100.101" + node.port = 3260 + node.target = "iqn.2023-01.com.example:12ac588" + node.interface = "default" + end + end + + let(:node2) do + DInstaller::Storage::ISCSI::Node.new.tap do |node| + node.address = "192.168.100.102" + node.port = 3260 + node.target = "iqn.2023-01.com.example:12ac588" + node.interface = "default" + end + end + + before do + allow(service).to receive(:export) + allow(service).to receive(:unexport) + end + + context "if a given iSCSI node is not exported yet" do + let(:nodes) { [node3] } + + let(:node3) do + DInstaller::Storage::ISCSI::Node.new.tap do |node| + node.address = "192.168.100.103" + node.port = 3260 + node.target = "iqn.2023-01.com.example:12ac588" + node.interface = "default" + end + end + + it "exports a new D-Bus node" do + expect(service).to receive(:export) do |dbus_node| + expect(dbus_node.path).to match(/#{described_class::ROOT_PATH}\/[0-9]+/) + end + + subject.update(nodes) + end + end + + context "if a given iSCSI node is already exported" do + # This node is equal to node2 + let(:nodes) { [node3] } + + let(:node3) do + DInstaller::Storage::ISCSI::Node.new.tap do |node| + node.address = "192.168.100.102" + node.port = 3260 + node.target = "iqn.2023-01.com.example:12ac588" + node.interface = "default" + end + end + + it "updates the D-Bus node" do + expect(dbus_node2).to receive(:iscsi_node=).with(node3) + + subject.update(nodes) + end + end + + context "if an exported D-Bus node does not represent any of the given iSCSI nodes" do + # There is a D-Bus node for node2 but node2 is missing in the list of given nodes + let(:nodes) { [node1] } + + before do + allow(dbus_node1).to receive(:iscsi_node=) + end + + it "unexports the D-Bus node" do + expect(service).to receive(:unexport).with(dbus_node2) + + subject.update(nodes) + end + end + end +end diff --git a/service/test/dinstaller/dbus/storage/manager_test.rb b/service/test/dinstaller/dbus/storage/manager_test.rb index a5bca34603..bd623d4249 100644 --- a/service/test/dinstaller/dbus/storage/manager_test.rb +++ b/service/test/dinstaller/dbus/storage/manager_test.rb @@ -26,6 +26,7 @@ require "dinstaller/storage/proposal" require "dinstaller/storage/proposal_settings" require "dinstaller/storage/volume" +require "dinstaller/storage/iscsi/manager" require "y2storage" require "dbus" @@ -37,6 +38,7 @@ let(:backend) do instance_double(DInstaller::Storage::Manager, proposal: proposal, + iscsi: iscsi, on_progress_change: nil, on_progress_finish: nil) end @@ -47,6 +49,8 @@ let(:settings) { nil } + let(:iscsi) { instance_double(DInstaller::Storage::ISCSI::Manager, on_probe: nil) } + describe "#available_devices" do before do allow(proposal).to receive(:available_devices).and_return(devices) @@ -277,4 +281,144 @@ end end end + + describe "#iscsi_discover" do + it "performs an iSCSI discovery" do + expect(iscsi).to receive(:discover_send_targets) do |address, port, auth| + expect(address).to eq("192.168.100.90") + expect(port).to eq(3260) + expect(auth).to be_a(Y2IscsiClient::Authentication) + end + + subject.iscsi_discover("192.168.100.90", 3260, {}) + end + + context "when no authentication options are given" do + let(:auth_options) { {} } + + it "uses an empty authentication" do + expect(iscsi).to receive(:discover_send_targets) do |_, _, auth| + expect(auth.by_target?).to eq(false) + expect(auth.by_initiator?).to eq(false) + end + + subject.iscsi_discover("192.168.100.90", 3260, auth_options) + end + end + + context "when authentication options are given" do + let(:auth_options) do + { + "Username" => "testi", + "Password" => "testi", + "ReverseUsername" => "testt", + "ReversePassword" => "testt" + } + end + + it "uses the expected authentication" do + expect(iscsi).to receive(:discover_send_targets) do |_, _, auth| + expect(auth.username).to eq("testi") + expect(auth.password).to eq("testi") + expect(auth.username_in).to eq("testt") + expect(auth.password_in).to eq("testt") + end + + subject.iscsi_discover("192.168.100.90", 3260, auth_options) + end + end + + context "when the action successes" do + before do + allow(iscsi).to receive(:discover_send_targets).and_return(true) + end + + it "returns 0" do + result = subject.iscsi_discover("192.168.100.90", 3260, {}) + + expect(result).to eq(0) + end + end + + context "when the action fails" do + before do + allow(iscsi).to receive(:discover_send_targets).and_return(false) + end + + it "returns 1" do + result = subject.iscsi_discover("192.168.100.90", 3260, {}) + + expect(result).to eq(1) + end + end + end + + describe "#iscsi_delete" do + before do + allow(DInstaller::DBus::Storage::ISCSINodesTree) + .to receive(:new).and_return(iscsi_nodes_tree) + end + + let(:iscsi_nodes_tree) { instance_double(DInstaller::DBus::Storage::ISCSINodesTree) } + + let(:path) { "/org/opensuse/DInstaller/Storage1/iscsi_nodes/1" } + + context "when the requested path for deleting is not exported yet" do + before do + allow(iscsi_nodes_tree).to receive(:find).with(path).and_return(nil) + end + + it "does not delete the iSCSI node" do + expect(iscsi).to_not receive(:delete) + + subject.iscsi_delete(path) + end + + it "returns 1" do + result = subject.iscsi_delete(path) + + expect(result).to eq(1) + end + end + + context "when the requested path for deleting is exported" do + before do + allow(iscsi_nodes_tree).to receive(:find).with(path).and_return(dbus_node) + end + + let(:dbus_node) { DInstaller::DBus::Storage::ISCSINode.new(iscsi, node, path) } + + let(:node) { DInstaller::Storage::ISCSI::Node.new } + + it "deletes the iSCSI node" do + expect(iscsi).to receive(:delete).with(node) + + subject.iscsi_delete(path) + end + + context "and the action successes" do + before do + allow(iscsi).to receive(:delete).with(node).and_return(true) + end + + it "returns 0" do + result = subject.iscsi_delete(path) + + expect(result).to eq(0) + end + end + + context "and the action fails" do + before do + allow(iscsi).to receive(:delete).with(node).and_return(false) + end + + it "returns 2" do + result = subject.iscsi_delete(path) + + expect(result).to eq(2) + end + end + end + end end diff --git a/service/test/dinstaller/storage/iscsi/manager_test.rb b/service/test/dinstaller/storage/iscsi/manager_test.rb new file mode 100644 index 0000000000..d8d9690523 --- /dev/null +++ b/service/test/dinstaller/storage/iscsi/manager_test.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# Copyright (c) [2023] SUSE LLC +# +# All Rights Reserved. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of version 2 of the GNU General Public License as published +# by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, contact SUSE LLC. +# +# To contact SUSE LLC about this file by physical or electronic mail, you may +# find current contact information at www.suse.com. + +require_relative "../../../test_helper" +require "dinstaller/storage/iscsi/manager" +require "dinstaller/storage/iscsi/node" + +describe DInstaller::Storage::ISCSI::Manager do + subject { described_class.new(logger: logger) } + + let(:logger) { Logger.new($stdout, level: :warn) } + + before do + allow(Yast::IscsiClientLib).to receive(:getiBFT) + allow(Yast::IscsiClientLib).to receive(:checkInitiatorName) + allow(Yast::IscsiClientLib).to receive(:getConfig) + allow(Yast::IscsiClientLib).to receive(:autoLogOn) + allow(Yast::IscsiClientLib).to receive(:readSessions) + allow(Yast::IscsiClientLib).to receive(:getDiscovered).and_return(yast_nodes) + allow(Yast::IscsiClientLib).to receive(:currentRecord=) + allow(Yast::IscsiClientLib).to receive(:getCurrentNodeValues) + allow(Yast::IscsiClientLib).to receive(:iBFT?) + allow(Yast::IscsiClientLib).to receive(:find_session) + allow(Yast::IscsiClientLib).to receive(:getStartupStatus) + allow(subject).to receive(:sleep) + end + + let(:yast_nodes) { [] } + + describe "#probe" do + let(:yast_nodes) do + [ + "192.168.100.101:3264 iqn.2023-01.com.example:12ac588 default", + "192.168.100.102:3264 iqn.2023-01.com.example:12ac588" + ] + end + + it "reads the discoverd nodes" do + subject.probe + + nodes = subject.nodes + expect(nodes).to all(be_a(DInstaller::Storage::ISCSI::Node)) + + expect(nodes).to include(an_object_having_attributes( + address: "192.168.100.101", + port: 3264, + target: "iqn.2023-01.com.example:12ac588", + interface: "default" + )) + + expect(nodes).to include(an_object_having_attributes( + address: "192.168.100.102", + port: 3264, + target: "iqn.2023-01.com.example:12ac588", + interface: "default" + )) + end + + let(:callback) { proc {} } + + it "runs the callbacks" do + subject.on_probe(&callback) + + expect(callback).to receive(:call) + + subject.probe + end + end + + describe "#discover_send_targets" do + before do + allow(Yast::IscsiClientLib).to receive(:discover) + end + + let(:auth) { Y2IscsiClient::Authentication.new } + + it "performs iSCSI discovery" do + expect(Yast::IscsiClientLib).to receive(:discover) + + subject.discover_send_targets("192.168.100.101", 3264, auth) + end + + it "probes iSCSI" do + expect(subject).to receive(:probe) + + subject.discover_send_targets("192.168.100.101", 3264, auth) + end + + context "if iSCSI activation is not performed yet" do + it "activates iSCSI" do + expect(subject).to receive(:activate) + + subject.discover_send_targets("192.168.100.101", 3264, auth) + end + end + + 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) + + subject.discover_send_targets("192.168.100.101", 3264, auth) + end + end + end + + describe "#login" do + let(:node) { DInstaller::Storage::ISCSI::Node.new } + + let(:auth) { Y2IscsiClient::Authentication.new } + + before do + allow(Yast::IscsiClientLib).to receive(:default_startup_status).and_return("onboot") + allow(Yast::IscsiClientLib).to receive(:login_into_current) + allow(Yast::IscsiClientLib).to receive(:setStartupStatus) + end + + context "if the given startup status is not valid" do + let(:startup) { "invalid" } + + it "does not try to login" do + expect(Yast::IscsiClientLib).to_not receive(:login_into_current) + + subject.login(node, auth, startup: startup) + end + + it "does not activate iSCSI" do + expect(subject).to_not receive(:activate) + + subject.login(node, auth, startup: startup) + end + + it "does not probe iSCSI" do + expect(subject).to_not receive(:probe) + + subject.login(node, auth, startup: startup) + end + + it "returns false" do + result = subject.login(node, auth, startup: startup) + + expect(result).to eq(false) + end + end + + context "if the given startup status is valid" do + let(:startup) { "automatic" } + + 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 + + let(:login_success) { nil } + + let(:startup_success) { nil } + + it "tries to login" do + expect(Yast::IscsiClientLib).to receive(:login_into_current) + + subject.login(node, auth, startup: startup) + end + + context "if iSCSI activation is not performed yet" do + it "activates iSCSI" do + expect(subject).to receive(:activate) + + subject.login(node, auth, startup: startup) + end + end + + 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) + + 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) + + expect(result).to eq(true) + end + end + + context "and the startup status cannot be set" do + let(:startup_success) { false } + + it "probes iSCSI" do + expect(subject).to receive(:probe) + + subject.login(node, auth, startup: startup) + end + + it "returns false" do + result = subject.login(node, auth, startup: startup) + + expect(result).to eq(false) + end + end + end + end + end + + describe "#logout" do + before do + allow(Yast::IscsiClientLib).to receive(:deleteRecord) + end + + let(:node) { DInstaller::Storage::ISCSI::Node.new } + + it "closes the iSCSI session" do + expect(Yast::IscsiClientLib).to receive(:deleteRecord) + + subject.logout(node) + end + + it "probes iSCSI" do + expect(subject).to receive(:probe) + + subject.logout(node) + end + + context "if iSCSI activation is not performed yet" do + it "activates iSCSI" do + expect(subject).to receive(:activate) + + subject.logout(node) + end + end + + 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) + + subject.logout(node) + end + end + end + + describe "#delete" do + before do + allow(Yast::IscsiClientLib).to receive(:removeRecord) + end + + let(:node) { DInstaller::Storage::ISCSI::Node.new } + + it "deletes the iSCSI node" do + expect(Yast::IscsiClientLib).to receive(:removeRecord) + + subject.delete(node) + end + + it "probes iSCSI" do + expect(subject).to receive(:probe) + + subject.delete(node) + end + end +end diff --git a/service/test/dinstaller/storage/manager_test.rb b/service/test/dinstaller/storage/manager_test.rb index d98bc2315c..0aedbcfce2 100644 --- a/service/test/dinstaller/storage/manager_test.rb +++ b/service/test/dinstaller/storage/manager_test.rb @@ -21,6 +21,7 @@ require_relative "../../test_helper" require "dinstaller/storage/manager" +require "dinstaller/storage/iscsi/manager" require "dinstaller/config" require "dinstaller/dbus/clients/questions" @@ -54,6 +55,7 @@ describe "#probe" do before do allow(DInstaller::Storage::Proposal).to receive(:new).and_return(proposal) + allow(DInstaller::Storage::ISCSI::Manager).to receive(:new).and_return(iscsi) end let(:proposal) do @@ -65,11 +67,15 @@ let(:disk1) { instance_double(Y2Storage::Disk, name: "/dev/vda") } let(:disk2) { instance_double(Y2Storage::Disk, name: "/dev/vdb") } + let(:iscsi) { DInstaller::Storage::ISCSI::Manager.new } + it "probes the storage devices and calculates a proposal" do expect(config).to receive(:pick_product).with("ALP") + expect(iscsi).to receive(:activate) expect(y2storage_manager).to receive(:activate) do |callbacks| expect(callbacks).to be_a(DInstaller::Storage::Callbacks::Activate) end + expect(iscsi).to receive(:probe) expect(y2storage_manager).to receive(:probe) expect(proposal).to receive(:calculate) storage.probe