From f5495f34a3522df46d46d42b7e093a9a753c444b Mon Sep 17 00:00:00 2001 From: Bernhard Suttner Date: Fri, 20 Feb 2026 00:51:16 +0100 Subject: [PATCH] Add bootdisk support --- lib/fog/libvirt/compute.rb | 4 + lib/fog/libvirt/models/compute/server.rb | 36 ++++- lib/fog/libvirt/models/compute/util/util.rb | 7 + .../libvirt/requests/compute/attach_iso.rb | 61 +++++++++ .../libvirt/requests/compute/destroy_iso.rb | 39 ++++++ .../libvirt/requests/compute/detach_iso.rb | 81 +++++++++++ .../libvirt/requests/compute/upload_iso.rb | 54 ++++++++ tests/libvirt/compute_tests.rb | 2 +- tests/libvirt/models/compute/server_tests.rb | 3 + .../requests/compute/attach_iso_tests.rb | 128 ++++++++++++++++++ .../requests/compute/destroy_iso_tests.rb | 69 ++++++++++ .../requests/compute/detach_iso_tests.rb | 114 ++++++++++++++++ .../requests/compute/upload_iso_tests.rb | 122 +++++++++++++++++ 13 files changed, 715 insertions(+), 5 deletions(-) create mode 100644 lib/fog/libvirt/requests/compute/attach_iso.rb create mode 100644 lib/fog/libvirt/requests/compute/destroy_iso.rb create mode 100644 lib/fog/libvirt/requests/compute/detach_iso.rb create mode 100644 lib/fog/libvirt/requests/compute/upload_iso.rb create mode 100644 tests/libvirt/requests/compute/attach_iso_tests.rb create mode 100644 tests/libvirt/requests/compute/destroy_iso_tests.rb create mode 100644 tests/libvirt/requests/compute/detach_iso_tests.rb create mode 100644 tests/libvirt/requests/compute/upload_iso_tests.rb diff --git a/lib/fog/libvirt/compute.rb b/lib/fog/libvirt/compute.rb index 14f6c20..e3949cd 100644 --- a/lib/fog/libvirt/compute.rb +++ b/lib/fog/libvirt/compute.rb @@ -45,6 +45,10 @@ class Compute < Fog::Service request :get_node_info request :update_autostart request :update_display + request :upload_iso + request :attach_iso + request :detach_iso + request :destroy_iso request :libversion module Shared diff --git a/lib/fog/libvirt/models/compute/server.rb b/lib/fog/libvirt/models/compute/server.rb index 805898f..5c9bd53 100644 --- a/lib/fog/libvirt/models/compute/server.rb +++ b/lib/fog/libvirt/models/compute/server.rb @@ -61,6 +61,33 @@ def initialize(attributes={} ) @user_data = attributes.delete(:user_data) end + def upload_iso(file_path, volume_name = nil, pool_name = nil) + raise ArgumentError, "file_path is a required parameter" if file_path.nil? + + volume_name ||= File.basename(file_path) + pool_name ||= default_iso_pool_name + service.upload_iso(pool_name, volume_name, file_path) + end + + def attach_iso(path, options = {}) + raise ArgumentError, "path is a required parameter" if path.nil? + + iso_path = File.absolute_path?(path) ? path : File.join(iso_dir || default_iso_dir, path) + service.attach_iso(uuid, iso_path, options) + end + + def detach_iso(options = {}) + service.detach_iso(uuid, options) + end + + def destroy_iso(volume_name, pool_name = nil) + raise ArgumentError, "volume_name is a required parameter" if volume_name.nil? + + iso_name = File.basename(volume_name) + pool_name ||= service.volumes.all(:name => iso_name).first&.pool_name || default_iso_pool_name + service.destroy_iso(pool_name, iso_name) + end + def new? uuid.nil? end @@ -531,14 +558,15 @@ def create_or_clone_volume @volumes.nil? ? @volumes = [volume] : @volumes << volume end - def default_iso_dir - "/var/lib/libvirt/images" - end - def default_volume_name "#{name}.#{volume_format_type || 'img'}" end + def default_iso_pool_name + volume = volumes&.first + volume&.pool_name || "default" + end + def defaults { :persistent => true, diff --git a/lib/fog/libvirt/models/compute/util/util.rb b/lib/fog/libvirt/models/compute/util/util.rb index e0eee24..231d7da 100644 --- a/lib/fog/libvirt/models/compute/util/util.rb +++ b/lib/fog/libvirt/models/compute/util/util.rb @@ -4,6 +4,9 @@ module Fog module Libvirt module Util + DEFAULT_CDROM_TARGET_DEV = "sdc".freeze + DEFAULT_CDROM_BUS = "sata".freeze + def xml_element(xml, path, attribute=nil) xml = Nokogiri::XML(xml) attribute.nil? ? (xml/path).first.text : (xml/path).first[attribute.to_sym] @@ -17,6 +20,10 @@ def xml_elements(xml, path, attribute=nil) def randomized_name "fog-#{(SecureRandom.random_number*10E14).to_i.round}" end + + def default_iso_dir + "/var/lib/libvirt/images" + end end end end diff --git a/lib/fog/libvirt/requests/compute/attach_iso.rb b/lib/fog/libvirt/requests/compute/attach_iso.rb new file mode 100644 index 0000000..ae8266e --- /dev/null +++ b/lib/fog/libvirt/requests/compute/attach_iso.rb @@ -0,0 +1,61 @@ +require "nokogiri" + +module Fog + module Libvirt + class Compute + module Shared + include Fog::Libvirt::Util + + def attach_iso(uuid, iso_path, options = {}) + raise ArgumentError, "uuid is a required parameter" if uuid.nil? + raise ArgumentError, "iso_path is a required parameter" if iso_path.nil? + + options ||= {} + raise ArgumentError, "options must be a hash" unless options.is_a?(Hash) + + target_dev = options.fetch(:target_dev, DEFAULT_CDROM_TARGET_DEV) + bus = options.fetch(:bus, DEFAULT_CDROM_BUS) + flags = options.fetch(:flags, ::Libvirt::Domain::AFFECT_CONFIG) + + resolved_iso_path = File.absolute_path?(iso_path) ? iso_path : File.join(default_iso_dir, iso_path) + xml = attach_cdrom_xml(resolved_iso_path, target_dev, bus) + + domain = client.lookup_domain_by_uuid(uuid) + begin + domain.attach_device(xml, flags) + rescue ::Libvirt::Error => e + begin + domain.update_device(xml, flags) + rescue ::Libvirt::Error + raise e + end + end + + # if we get no exception, we assume the operation was successful + true + end + + private + + def attach_cdrom_xml(iso_path, target_dev, bus) + Nokogiri::XML::Builder.new do |x| + x.disk(:type => "file", :device => "cdrom") do + x.driver(:name => "qemu", :type => "raw") + x.source(:file => iso_path) + x.target(:dev => target_dev, :bus => bus) + x.readonly + end + end.to_xml + end + end + + class Real + include Shared + end + + class Mock + include Shared + end + end + end +end diff --git a/lib/fog/libvirt/requests/compute/destroy_iso.rb b/lib/fog/libvirt/requests/compute/destroy_iso.rb new file mode 100644 index 0000000..10ff182 --- /dev/null +++ b/lib/fog/libvirt/requests/compute/destroy_iso.rb @@ -0,0 +1,39 @@ +module Fog + module Libvirt + class Compute + module Shared + def destroy_iso(pool_name, volume_name) + raise ArgumentError, "pool_name is a required parameter" if pool_name.nil? + raise ArgumentError, "volume_name is a required parameter" if volume_name.nil? + + pool = client.lookup_storage_pool_by_name(pool_name) + begin + pool.lookup_volume_by_name(volume_name).delete + rescue ::Libvirt::RetrieveError + # already absent, treat as success if not present afterwards + end + + # if the ISO is absent, then we are good + volume_absent?(pool, volume_name) + end + + private + + def volume_absent?(pool, volume_name) + pool.lookup_volume_by_name(volume_name) + false + rescue ::Libvirt::RetrieveError + true + end + end + + class Real + include Shared + end + + class Mock + include Shared + end + end + end +end diff --git a/lib/fog/libvirt/requests/compute/detach_iso.rb b/lib/fog/libvirt/requests/compute/detach_iso.rb new file mode 100644 index 0000000..79d49b3 --- /dev/null +++ b/lib/fog/libvirt/requests/compute/detach_iso.rb @@ -0,0 +1,81 @@ +require "nokogiri" + +module Fog + module Libvirt + class Compute + module Shared + include Fog::Libvirt::Util + + def detach_iso(uuid, options = {}) + raise ArgumentError, "uuid is a required parameter" if uuid.nil? + + options ||= {} + raise ArgumentError, "options must be a hash" unless options.is_a?(Hash) + + target_dev = options.fetch(:target_dev, DEFAULT_CDROM_TARGET_DEV) + bus = options.fetch(:bus, DEFAULT_CDROM_BUS) + flags = options.fetch(:flags, ::Libvirt::Domain::AFFECT_CONFIG) + domain = client.lookup_domain_by_uuid(uuid) + domain_active = domain.active? + flags = effective_detach_iso_flags(flags, domain_active) + + if domain_active + domain.update_device(eject_cdrom_xml(target_dev, bus), flags) + begin + domain.detach_device(detach_cdrom_xml(target_dev, bus), flags) + rescue ::Libvirt::Error + # Some backends don't allow to detach the cdrom if the host is running. + # In this case, we just eject the cdrom and leave it attached to the VM. + # Return true that maybe the ISO file can be removed in further steps. + true + end + else + begin + domain.detach_device(detach_cdrom_xml(target_dev, bus), flags) + true + rescue ::Libvirt::Error + false + end + end + end + + private + + def detach_cdrom_xml(target_dev, bus) + Nokogiri::XML::Builder.new do |x| + x.disk(:type => "file", :device => "cdrom") do + x.target(:dev => target_dev, :bus => bus) + end + end.to_xml + end + + def eject_cdrom_xml(target_dev, bus) + Nokogiri::XML::Builder.new do |x| + x.disk(:type => "file", :device => "cdrom", :tray => "open") do + x.driver(:name => "qemu", :type => "raw") + x.target(:dev => target_dev, :bus => bus) + x.readonly + end + end.to_xml + end + + def effective_detach_iso_flags(flags, domain_active) + return flags unless (flags & ::Libvirt::Domain::AFFECT_CONFIG) == ::Libvirt::Domain::AFFECT_CONFIG + return flags unless domain_active + + flags | ::Libvirt::Domain::AFFECT_LIVE + rescue ::Libvirt::Error + flags + end + end + + class Real + include Shared + end + + class Mock + include Shared + end + end + end +end diff --git a/lib/fog/libvirt/requests/compute/upload_iso.rb b/lib/fog/libvirt/requests/compute/upload_iso.rb new file mode 100644 index 0000000..306811b --- /dev/null +++ b/lib/fog/libvirt/requests/compute/upload_iso.rb @@ -0,0 +1,54 @@ +require "nokogiri" + +module Fog + module Libvirt + class Compute + module Shared + def upload_iso(pool_name, volume_name, file_path) + raise ArgumentError, "pool_name is a required parameter" if pool_name.nil? + raise ArgumentError, "volume_name is a required parameter" if volume_name.nil? + raise ArgumentError, "file_path is a required parameter" if file_path.nil? + + pool = client.lookup_storage_pool_by_name(pool_name) + pool.lookup_volume_by_name(volume_name).delete if pool.list_volumes.include?(volume_name) + + create_volume(pool_name, iso_volume_xml(volume_name, file_path)) + upload_volume(pool_name, volume_name, file_path) + + volume = pool.lookup_volume_by_name(volume_name) + { + :pool_name => pool_name, + :name => volume_name, + :key => volume.key, + :path => volume.path + } + end + + private + + def iso_volume_xml(volume_name, file_path) + iso_size = File.size(file_path) + + Nokogiri::XML::Builder.new do |x| + x.volume do + x.name(volume_name) + x.allocation(0, :unit => "B") + x.capacity(iso_size, :unit => "B") + x.target do + x.format(:type => "raw") + end + end + end.to_xml + end + end + + class Real + include Shared + end + + class Mock + include Shared + end + end + end +end diff --git a/tests/libvirt/compute_tests.rb b/tests/libvirt/compute_tests.rb index b28f79c..c5904e1 100644 --- a/tests/libvirt/compute_tests.rb +++ b/tests/libvirt/compute_tests.rb @@ -12,7 +12,7 @@ %w{ create_domain create_volume define_domain define_pool destroy_interface destroy_network get_node_info update_autostart list_domains list_interfaces list_networks list_pools list_pool_volumes list_volumes pool_action vm_action volume_action - dhcp_leases }.each do |request| + dhcp_leases upload_iso attach_iso detach_iso destroy_iso }.each do |request| test("it should respond to #{request}") { compute.respond_to? request } end end diff --git a/tests/libvirt/models/compute/server_tests.rb b/tests/libvirt/models/compute/server_tests.rb index 448baef..16d03cc 100644 --- a/tests/libvirt/models/compute/server_tests.rb +++ b/tests/libvirt/models/compute/server_tests.rb @@ -13,6 +13,9 @@ %w{ start stop destroy reboot suspend }.each do |action| test(action) { server.respond_to? action } end + %w{ upload_iso attach_iso detach_iso destroy_iso }.each do |action| + test(action) { server.respond_to? action } + end %w{ start reboot suspend stop }.each do |action| test("#{action} returns successfully") { begin diff --git a/tests/libvirt/requests/compute/attach_iso_tests.rb b/tests/libvirt/requests/compute/attach_iso_tests.rb new file mode 100644 index 0000000..3b77f18 --- /dev/null +++ b/tests/libvirt/requests/compute/attach_iso_tests.rb @@ -0,0 +1,128 @@ +require File.expand_path("../../../helper", __dir__) +require "fog/libvirt" + +class AttachIsoFakeDomain + attr_reader :xml, :flags, :calls, :xml_desc + + def initialize(xml_desc, fail_attach: false, fail_update: false) + @xml_desc = xml_desc + @calls = [] + @fail_attach = fail_attach + @fail_update = fail_update + end + + def update_device(xml, flags = 0) + raise ::Libvirt::Error, "update failed" if @fail_update + + @calls << :update + @xml = xml + @flags = flags + true + end + + def attach_device(xml, flags = 0) + raise ::Libvirt::Error, "attach failed" if @fail_attach + + @calls << :attach + @xml = xml + @flags = flags + true + end +end + +class AttachIsoFakeClient + def initialize(domain) + @domain = domain + end + + def lookup_domain_by_uuid(_uuid) + @domain + end +end + +Shindo.tests("Fog::Compute[:libvirt] | attach_iso") do + tests("attach_iso") do + returns(true, "attaches cdrom device directly when attach succeeds") do + domain = AttachIsoFakeDomain.new("") + client = AttachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + iso = "/var/lib/libvirt/images/os.iso" + + ok = service.attach_iso(uuid, iso, :target_dev => "sdc", :bus => "sata", :flags => 0) + + ok && + domain.calls == [:attach] && + domain.xml.include?('device="cdrom"') && + domain.xml.include?('type="file"') && + domain.xml.include?("', + :fail_attach => true + ) + client = AttachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + iso = "/var/lib/libvirt/images/os.iso" + + ok = service.attach_iso(uuid, iso, :target_dev => "sdc", :bus => "sata", :flags => 0) + + ok && + domain.calls == [:update] && + domain.xml.include?('device="cdrom"') && + domain.xml.include?('type="file"') && + domain.xml.include?("") + client = AttachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + iso = "os.iso" + + ok = service.attach_iso(uuid, iso) + + ok && + domain.calls == [:attach] && + domain.xml.include?('file="/var/lib/libvirt/images/os.iso"') + end + + raises(::Libvirt::Error, "raises when both attach and update fail") do + domain = AttachIsoFakeDomain.new( + "", + :fail_attach => true, + :fail_update => true + ) + client = AttachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + iso = "/var/lib/libvirt/images/os.iso" + + service.attach_iso(uuid, iso, :target_dev => "sdc", :bus => "sata", :flags => 0) + end + end +end diff --git a/tests/libvirt/requests/compute/destroy_iso_tests.rb b/tests/libvirt/requests/compute/destroy_iso_tests.rb new file mode 100644 index 0000000..7fd29a1 --- /dev/null +++ b/tests/libvirt/requests/compute/destroy_iso_tests.rb @@ -0,0 +1,69 @@ +require File.expand_path("../../../helper", __dir__) +require "fog/libvirt" + +class DeleteIsoFakeVolume + attr_reader :name, :deleted + + def initialize(name, &on_delete) + @name = name + @on_delete = on_delete + end + + def delete + @deleted = true + @on_delete&.call(name) + true + end +end + +class DeleteIsoFakePool + def initialize(volumes = {}) + @volumes = volumes + end + + def lookup_volume_by_name(volume_name) + volume = @volumes[volume_name] + raise ::Libvirt::RetrieveError, "volume not found" unless volume + + volume + end +end + +class DeleteIsoFakeClient + def initialize(pool) + @pool = pool + end + + def lookup_storage_pool_by_name(_pool_name) + @pool + end +end + +Shindo.tests("Fog::Compute[:libvirt] | destroy_iso") do + tests("destroy_iso") do + returns(true, "deletes an iso storage volume from a pool and verifies absence") do + volumes = {} + volume = DeleteIsoFakeVolume.new("os.iso") { |name| volumes.delete(name) } + volumes["os.iso"] = volume + pool = DeleteIsoFakePool.new(volumes) + client = DeleteIsoFakeClient.new(pool) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + ok = service.destroy_iso("default", "os.iso") + + ok && volume.deleted + end + + returns(true, "returns true when iso volume is already absent") do + pool = DeleteIsoFakePool.new({}) + client = DeleteIsoFakeClient.new(pool) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + service.destroy_iso("default", "os.iso") + end + end +end diff --git a/tests/libvirt/requests/compute/detach_iso_tests.rb b/tests/libvirt/requests/compute/detach_iso_tests.rb new file mode 100644 index 0000000..6c504c2 --- /dev/null +++ b/tests/libvirt/requests/compute/detach_iso_tests.rb @@ -0,0 +1,114 @@ +require File.expand_path("../../../helper", __dir__) +require "fog/libvirt" + +class DetachIsoFakeDomain + attr_reader :xml, :flags, :update_xml, :update_flags, :calls + + def initialize(active: false, fail_detach: false) + @active = active + @fail_detach = fail_detach + @calls = [] + end + + def active? + @active + end + + def update_device(xml, flags = 0) + @calls << :update + @update_xml = xml + @update_flags = flags + true + end + + def detach_device(xml, flags = 0) + raise ::Libvirt::Error, "detach not supported" if @fail_detach + + @calls << :detach + @xml = xml + @flags = flags + true + end +end + +class DetachIsoFakeClient + def initialize(domain) + @domain = domain + end + + def lookup_domain_by_uuid(_uuid) + @domain + end +end + +Shindo.tests("Fog::Compute[:libvirt] | detach_iso") do + tests("detach_iso") do + returns(true, "detaches a cdrom device via libvirt detach_device") do + domain = DetachIsoFakeDomain.new(:active => false) + client = DetachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + ok = service.detach_iso(uuid, :target_dev => "sdc", :bus => "sata", :flags => 0) + + ok && + domain.calls == [:detach] && + domain.xml.include?('device="cdrom"') && + domain.xml.include?('type="file"') && + domain.xml.include?('dev="sdc"') && + domain.xml.include?('bus="sata"') && + domain.flags.zero? + end + + returns(true, "uses AFFECT_LIVE and AFFECT_CONFIG for active domains by default") do + domain = DetachIsoFakeDomain.new(:active => true) + client = DetachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + + ok = service.detach_iso(uuid) + + ok && + domain.calls == [:update, :detach] && + domain.update_xml.include?('tray="open"') && + domain.flags == (::Libvirt::Domain::AFFECT_CONFIG | ::Libvirt::Domain::AFFECT_LIVE) + end + + returns(true, "returns ejection success when live cdrom detach is unsupported") do + domain = DetachIsoFakeDomain.new(:active => true, :fail_detach => true) + client = DetachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + + ok = service.detach_iso(uuid) + + ok && + domain.calls == [:update] && + domain.update_xml.include?('tray="open"') + end + + returns(true, "respects combined flags that already include AFFECT_CONFIG") do + domain = DetachIsoFakeDomain.new(:active => true) + client = DetachIsoFakeClient.new(domain) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + uuid = "11111111-2222-3333-4444-555555555555" + combined_flags = ::Libvirt::Domain::AFFECT_CONFIG | ::Libvirt::Domain::AFFECT_LIVE + + ok = service.detach_iso(uuid, :flags => combined_flags) + + ok && + domain.flags == combined_flags + end + end +end diff --git a/tests/libvirt/requests/compute/upload_iso_tests.rb b/tests/libvirt/requests/compute/upload_iso_tests.rb new file mode 100644 index 0000000..4331cc3 --- /dev/null +++ b/tests/libvirt/requests/compute/upload_iso_tests.rb @@ -0,0 +1,122 @@ +require File.expand_path("../../../helper", __dir__) +require "fog/libvirt" +require "tempfile" + +class UploadIsoFakeVolume + attr_reader :name, :path, :key, :deleted + + def initialize(name, path, key, &on_delete) + @name = name + @path = path + @key = key + @on_delete = on_delete + end + + def upload(_stream, _offset, _length) + true + end + + def delete + @deleted = true + @on_delete&.call(name) + true + end +end + +class UploadIsoFakePool + attr_reader :xml, :volume, :deleted_volume_names + + def initialize(existing_volume_names = []) + @existing_volume_names = existing_volume_names + @deleted_volume_names = [] + end + + def create_vol_xml(xml) + @xml = xml + true + end + + def list_volumes + @existing_volume_names + end + + def lookup_volume_by_name(name) + on_delete = proc { |deleted_name| @deleted_volume_names << deleted_name } if @existing_volume_names.include?(name) + UploadIsoFakeVolume.new(name, "/var/lib/libvirt/images/#{name}", "pool/#{name}", &on_delete) + end +end + +class UploadIsoFakeStream + def sendall + loop do + count, _chunk = yield(nil, 4096) + break if count.zero? + end + end + + def finish; end +end + +class UploadIsoFakeClient + def initialize(pool) + @pool = pool + end + + def lookup_storage_pool_by_name(_pool_name) + @pool + end + + def stream + UploadIsoFakeStream.new + end +end + +Shindo.tests("Fog::Compute[:libvirt] | upload_iso") do + tests("upload_iso") do + returns(true, "creates and uploads an iso volume in the pool") do + pool = UploadIsoFakePool.new + client = UploadIsoFakeClient.new(pool) + + service = Fog::Libvirt::Compute::Real.allocate + service.instance_variable_set(:@client, client) + + file = Tempfile.new(["os", ".iso"]) + file.write("test-iso") + file.flush + + result = service.upload_iso("default", "os.iso", file.path) + + !pool.xml.nil? && + pool.xml.include?("os.iso") && + pool.xml.include?("os.iso") && + result[:name] == "os.iso" + ensure + file.close + file.unlink + end + end +end