vagrant-xapi 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE +21 -0
  4. data/README.md +460 -4
  5. data/docs/architecture.md +1420 -0
  6. data/docs/operations.md +531 -0
  7. data/lib/vagrant-xapi/action/base.rb +103 -0
  8. data/lib/vagrant-xapi/action/cloud_init.rb +130 -0
  9. data/lib/vagrant-xapi/action/create.rb +127 -0
  10. data/lib/vagrant-xapi/action/destroy.rb +68 -0
  11. data/lib/vagrant-xapi/action/network.rb +229 -0
  12. data/lib/vagrant-xapi/action/package.rb +103 -0
  13. data/lib/vagrant-xapi/action/power.rb +185 -0
  14. data/lib/vagrant-xapi/action/snapshots.rb +43 -0
  15. data/lib/vagrant-xapi/action.rb +317 -0
  16. data/lib/vagrant-xapi/api/client.rb +393 -0
  17. data/lib/vagrant-xapi/api/errors.rb +98 -0
  18. data/lib/vagrant-xapi/api/http_transfer.rb +332 -0
  19. data/lib/vagrant-xapi/api/redactor.rb +77 -0
  20. data/lib/vagrant-xapi/api/session_registry.rb +59 -0
  21. data/lib/vagrant-xapi/api/task.rb +156 -0
  22. data/lib/vagrant-xapi/api/transport.rb +249 -0
  23. data/lib/vagrant-xapi/cap.rb +90 -0
  24. data/lib/vagrant-xapi/command/adopt.rb +92 -0
  25. data/lib/vagrant-xapi/command/base.rb +79 -0
  26. data/lib/vagrant-xapi/command/console.rb +87 -0
  27. data/lib/vagrant-xapi/command/diff.rb +267 -0
  28. data/lib/vagrant-xapi/command/hosts.rb +63 -0
  29. data/lib/vagrant-xapi/command/info.rb +94 -0
  30. data/lib/vagrant-xapi/command/marked.rb +85 -0
  31. data/lib/vagrant-xapi/command/orphans.rb +96 -0
  32. data/lib/vagrant-xapi/command/root.rb +55 -0
  33. data/lib/vagrant-xapi/command/templates.rb +91 -0
  34. data/lib/vagrant-xapi/config.rb +333 -0
  35. data/lib/vagrant-xapi/errors.rb +113 -0
  36. data/lib/vagrant-xapi/extensions.rb +80 -0
  37. data/lib/vagrant-xapi/glue.rb +330 -0
  38. data/lib/vagrant-xapi/legacy/plugin.rb +61 -0
  39. data/lib/vagrant-xapi/legacy/xenapi.rb +129 -0
  40. data/lib/vagrant-xapi/plugin.rb +58 -0
  41. data/lib/vagrant-xapi/provider.rb +135 -0
  42. data/lib/vagrant-xapi/services/adoption.rb +73 -0
  43. data/lib/vagrant-xapi/services/base.rb +242 -0
  44. data/lib/vagrant-xapi/services/box_source.rb +218 -0
  45. data/lib/vagrant-xapi/services/console.rb +115 -0
  46. data/lib/vagrant-xapi/services/content_diff.rb +96 -0
  47. data/lib/vagrant-xapi/services/destroyer.rb +104 -0
  48. data/lib/vagrant-xapi/services/diff_disks.rb +346 -0
  49. data/lib/vagrant-xapi/services/differ.rb +382 -0
  50. data/lib/vagrant-xapi/services/disk_image.rb +122 -0
  51. data/lib/vagrant-xapi/services/disks.rb +281 -0
  52. data/lib/vagrant-xapi/services/exporter.rb +128 -0
  53. data/lib/vagrant-xapi/services/file_diff.rb +249 -0
  54. data/lib/vagrant-xapi/services/guest_files.rb +730 -0
  55. data/lib/vagrant-xapi/services/hosts.rb +55 -0
  56. data/lib/vagrant-xapi/services/image_upload.rb +172 -0
  57. data/lib/vagrant-xapi/services/inventory.rb +225 -0
  58. data/lib/vagrant-xapi/services/ip_detector.rb +107 -0
  59. data/lib/vagrant-xapi/services/lock.rb +293 -0
  60. data/lib/vagrant-xapi/services/lookup.rb +181 -0
  61. data/lib/vagrant-xapi/services/networks.rb +248 -0
  62. data/lib/vagrant-xapi/services/power.rb +108 -0
  63. data/lib/vagrant-xapi/services/rfb.rb +129 -0
  64. data/lib/vagrant-xapi/services/snapshots.rb +81 -0
  65. data/lib/vagrant-xapi/services/template_cache.rb +263 -0
  66. data/lib/vagrant-xapi/services/tunnels.rb +217 -0
  67. data/lib/vagrant-xapi/services/vm_builder.rb +215 -0
  68. data/lib/vagrant-xapi/version.rb +7 -0
  69. data/lib/vagrant-xapi/xenserver/config.rb +69 -0
  70. data/lib/vagrant-xapi/xenserver/migrate.rb +103 -0
  71. data/lib/vagrant-xapi/xenserver/plugin.rb +96 -0
  72. data/lib/vagrant-xapi/xenserver/provider.rb +22 -0
  73. data/lib/vagrant-xapi/xenserver/takeover.rb +79 -0
  74. data/lib/vagrant-xapi.rb +21 -2
  75. data/lib/xenapi.rb +22 -0
  76. data/locales/en.yml +517 -0
  77. data/locales/legacy.en.yml +6 -0
  78. metadata +86 -5
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ require_relative "base"
6
+
7
+ module VagrantPlugins
8
+ module Xapi
9
+ module Services
10
+ # The hosts of the pool as the master reports them: address, role and
11
+ # TLS certificate. The certificates come from host.get_server_certificate,
12
+ # so through the verified connection to the master.
13
+ class Hosts
14
+ HostInfo = Struct.new(:uuid, :name, :address, :role, :enabled, :fingerprint, :certificate,
15
+ keyword_init: true)
16
+
17
+ def initialize(ctx)
18
+ @client = ctx.client
19
+ end
20
+
21
+ # @return [Array<HostInfo>] the master first, then by name
22
+ def list
23
+ master = @client.master_ref
24
+ @client.call("host.get_all_records").map do |ref, rec|
25
+ pem = certificate(ref)
26
+ HostInfo.new(uuid: rec["uuid"], name: rec["name_label"], address: rec["address"],
27
+ role: ref == master ? "master" : "member", enabled: rec["enabled"] != false,
28
+ fingerprint: pem && fingerprint(pem), certificate: pem)
29
+ end.sort_by { |h| [h.role == "master" ? 0 : 1, h.name.to_s] }
30
+ end
31
+
32
+ # SHA-256 of the DER certificate, as tls_fingerprint takes it.
33
+ def self.fingerprint(pem)
34
+ OpenSSL::Digest::SHA256.hexdigest(OpenSSL::X509::Certificate.new(pem).to_der).scan(/../).join(":").upcase
35
+ end
36
+
37
+ private
38
+
39
+ def fingerprint(pem)
40
+ self.class.fingerprint(pem)
41
+ rescue OpenSSL::X509::CertificateError
42
+ nil
43
+ end
44
+
45
+ # nil when the host has none or the role may not read it.
46
+ def certificate(ref)
47
+ pem = @client.call("host.get_server_certificate", ref).to_s
48
+ pem.empty? ? nil : pem
49
+ rescue Api::XapiError
50
+ nil
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ require_relative "base"
7
+ require_relative "disk_image"
8
+ require_relative "lookup"
9
+
10
+ module VagrantPlugins
11
+ module Xapi
12
+ module Services
13
+ # Uploads a disk image into an existing VDI through /import_raw_vdi, on
14
+ # the host that has the VDI's SR attached.
15
+ #
16
+ # qcow2: whether XAPI can import it is asked once per session with an
17
+ # empty probe PUT on a small scratch VDI. XAPI rejects an unknown format
18
+ # before it reads the body; a supported format gets a 200. If qcow2 is
19
+ # not supported (or the probe gives no answer), or the image has
20
+ # compressed clusters or features XAPI's import does not take, the image
21
+ # is converted to VHD on the local machine with the converter (qemu-img).
22
+ class ImageUpload
23
+ include Helpers
24
+
25
+ PROBE_SIZE = 1024 * 1024
26
+
27
+ # @param converter [#call, nil] converter.call(src, dst): qcow2 -> VHD
28
+ def initialize(ctx, converter: nil)
29
+ @ctx = ctx
30
+ @client = ctx.client
31
+ @lookup = Lookup.new(ctx)
32
+ @converter = converter
33
+ end
34
+
35
+ # @param format [String] vhd, qcow2 or raw
36
+ # @return [String] the VDI ref; a new one when the VDI had to be
37
+ # recreated for the VHD fallback
38
+ def upload(vdi, path, format, label:)
39
+ sr = @client.call("VDI.get_SR", vdi)
40
+ host = @lookup.transfer_host_for_sr(sr)
41
+ tmpdir = nil
42
+ if format == "qcow2"
43
+ obstacle = DiskImage.qcow2_import_obstacle(path)
44
+ if obstacle || !qcow2_supported?(sr, host)
45
+ tmpdir = Dir.mktmpdir("vagrant-xapi-convert-")
46
+ path = convert(path, tmpdir, reason: obstacle)
47
+ format = "vhd"
48
+ end
49
+ end
50
+ begin
51
+ send_image(vdi, path, format, host, label)
52
+ rescue Api::HttpError, Api::ConnectionError, Api::XapiError => e
53
+ # The probe said yes, but this host refused qcow2 after all (e.g.
54
+ # another version during a rolling upgrade), or its import could
55
+ # not read this image: convert and retry.
56
+ raise unless format == "qcow2" && qcow2_refused?(e)
57
+
58
+ @ctx.logger&.info("qcow2 upload refused (#{e.message}); converting to VHD")
59
+ # Only an HTTP refusal of the format says something about the host.
60
+ @client.set_feature(feature_key(host), false) if e.is_a?(Api::HttpError)
61
+ tmpdir ||= Dir.mktmpdir("vagrant-xapi-convert-")
62
+ vhd = convert(path, tmpdir, reason: e.is_a?(Api::XapiError) ? :refused : nil, detail: e.message)
63
+ vdi = recreate(vdi)
64
+ begin
65
+ send_image(vdi, vhd, "vhd", host, label)
66
+ rescue Exception # rubocop:disable Lint/RescueException
67
+ # The caller only knows the old (destroyed) ref; clean up here.
68
+ destroy_vdi(vdi)
69
+ raise
70
+ end
71
+ end
72
+ vdi
73
+ ensure
74
+ FileUtils.rm_rf(tmpdir) if tmpdir
75
+ end
76
+
77
+ # Asked once per session and transfer host: pool members may run
78
+ # different versions.
79
+ def qcow2_supported?(sr, host)
80
+ known = @client.features[feature_key(host)]
81
+ return known unless known.nil?
82
+
83
+ scratch = create_vdi(sr, "vagrant-xapi qcow2 probe", PROBE_SIZE, description: "temporary")
84
+ begin
85
+ status = @client.http.probe("/import_raw_vdi", { "vdi" => scratch, "format" => "qcow2" }, host: host)
86
+ ensure
87
+ destroy_vdi(scratch)
88
+ end
89
+ supported = status ? status.between?(200, 299) : false
90
+ @ctx.logger&.info("qcow2 import probe: HTTP #{status.inspect}; supported=#{supported}")
91
+ # Only remember a real answer; a timeout is asked again next time.
92
+ @client.set_feature(feature_key(host), supported) unless status.nil?
93
+ supported
94
+ end
95
+
96
+ private
97
+
98
+ def feature_key(host)
99
+ :"qcow2_import@#{host || "master"}"
100
+ end
101
+
102
+ # XAPI refuses a format it cannot import with a 4xx before it reads
103
+ # the body, and may close the connection while the body is still
104
+ # being sent (broken pipe, reset). Its qcow2 reader failing on this
105
+ # image is a VDI_IO_ERROR from qcow-stream-tool. Anything else (a
106
+ # timeout, an unreachable host, 401/403 for the session, 5xx) is no
107
+ # refusal and is raised as it is.
108
+ def qcow2_refused?(error)
109
+ case error
110
+ when Api::XapiError
111
+ error.code == "VDI_IO_ERROR" && error.params.join(" ").match?(/qcow/i)
112
+ when Api::HttpError
113
+ error.status.between?(400, 499) && ![401, 403].include?(error.status)
114
+ when Api::TimeoutError
115
+ false
116
+ when Api::ConnectionError
117
+ [Errno::EPIPE, Errno::ECONNRESET].any? { |klass| error.cause.is_a?(klass) }
118
+ else
119
+ false
120
+ end
121
+ end
122
+
123
+ def send_image(vdi, path, format, host, label)
124
+ @client.http.upload("/import_raw_vdi", { "vdi" => vdi, "format" => format }, path,
125
+ host: host,
126
+ progress: @ctx.progress_proc(:uploading, file: File.basename(path)),
127
+ task_label: label, cancel: @ctx.cancel)
128
+ @ctx.ui&.clear_progress
129
+ end
130
+
131
+ # A refused upload may have written part of the image; start the VHD
132
+ # import on a fresh VDI with the same settings.
133
+ def recreate(vdi)
134
+ rec = @client.call("VDI.get_record", vdi)
135
+ destroy_vdi(vdi)
136
+ create_vdi(rec["SR"], rec["name_label"], rec["virtual_size"],
137
+ description: rec["name_description"].to_s, type: rec["type"] || "user",
138
+ other_config: rec["other_config"] || {}, tags: rec["tags"] || [])
139
+ end
140
+
141
+ # @param reason [Symbol, nil] why the image is converted: nil (the host
142
+ # cannot import qcow2), :compressed, :features or :refused
143
+ def convert(path, tmpdir, reason: nil, detail: nil)
144
+ unless @converter
145
+ raise Error.new(:qemu_img_missing) unless reason
146
+
147
+ raise Error.new(:"qemu_img_missing_#{reason}", file: File.basename(path), detail: detail.to_s)
148
+ end
149
+
150
+ dst = File.join(tmpdir, "#{File.basename(path, ".*")}.vhd")
151
+ @ctx.ui&.info(:converting_image, file: File.basename(path))
152
+ @converter.call(path, dst)
153
+ dst
154
+ end
155
+
156
+ # The probe may leave XAPI busy with the VDI for a moment.
157
+ def destroy_vdi(vdi)
158
+ 10.times do
159
+ @client.call("VDI.destroy", vdi)
160
+ return
161
+ rescue Api::XapiError => e
162
+ return if %w[HANDLE_INVALID UUID_INVALID].include?(e.code)
163
+ raise unless %w[VDI_IN_USE OTHER_OPERATION_IN_PROGRESS].include?(e.code)
164
+
165
+ sleep 1
166
+ end
167
+ @ctx.logger&.warn("could not remove the qcow2 probe VDI")
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "base"
6
+ require_relative "template_cache"
7
+
8
+ module VagrantPlugins
9
+ module Xapi
10
+ module Services
11
+ # Overview and cleanup of what the plugin left in a pool: box templates
12
+ # (shared by all users) and orphaned VMs.
13
+ class Inventory
14
+ include Helpers
15
+
16
+ TemplateInfo = Struct.new(:ref, :uuid, :name, :box, :version, :provider, :sr_uuid, :sr_name, :size,
17
+ :clones, :clone_users, keyword_init: true)
18
+ VmInfo = Struct.new(:ref, :uuid, :name, :machine, :project, :user, :client, :power_state,
19
+ keyword_init: true)
20
+ # One object carrying a vagrant-xapi marking.
21
+ # kind: :vm, :snapshot, :template, :vdi, :network, :lock
22
+ Marked = Struct.new(:kind, :uuid, :name, :detail, keyword_init: true)
23
+
24
+ KIND_ORDER = %i[vm snapshot template vdi network lock].freeze
25
+
26
+ def initialize(ctx)
27
+ @ctx = ctx
28
+ @client = ctx.client
29
+ end
30
+
31
+ # @return [Array<TemplateInfo>]
32
+ def templates
33
+ vms = marked_vms
34
+ TemplateCache.new(@ctx, digest_cache: nil).all_templates.map do |ref, rec|
35
+ oc = rec["other_config"] || {}
36
+ clones = vms.select { |v| v.dig(:oc, Marking::TEMPLATE_UUID) == rec["uuid"] }
37
+ TemplateInfo.new(
38
+ ref: ref, uuid: rec["uuid"], name: rec["name_label"],
39
+ box: oc[Marking::BOX], version: oc[Marking::BOX_VERSION], provider: oc[Marking::BOX_PROVIDER],
40
+ sr_uuid: oc[Marking::SR],
41
+ sr_name: sr_name(oc[Marking::SR]),
42
+ size: disk_vdis(ref).sum { |vdi| ignore_missing { @client.call("VDI.get_virtual_size", vdi).to_i } || 0 },
43
+ clones: clones.size,
44
+ clone_users: clones.map { |v| v.dig(:oc, Marking::USER) }.compact.uniq.sort
45
+ )
46
+ end.sort_by { |t| [t.box.to_s, version_key(t.version)] }
47
+ end
48
+
49
+ # Templates to remove so that per box only the newest `keep` versions
50
+ # remain (per SR).
51
+ def prune_candidates(keep)
52
+ # By SR UUID: in a pool every host's local SR is called "Local storage".
53
+ templates.group_by { |t| [t.box, t.provider, t.sr_uuid] }.flat_map do |_key, list|
54
+ sorted = list.sort_by { |t| version_key(t.version) }.reverse
55
+ sorted.drop(keep.to_i)
56
+ end
57
+ end
58
+
59
+ def find_template(name_or_uuid)
60
+ list = templates
61
+ hit = list.select { |t| [t.uuid, t.name, "#{t.box} #{t.version}"].include?(name_or_uuid.to_s) }
62
+ raise Error.new(:template_not_found, name: name_or_uuid.to_s) if hit.empty?
63
+ raise Error.new(:ambiguous_name, kind: "template", name: name_or_uuid.to_s, count: hit.size) if hit.size > 1
64
+
65
+ hit.first
66
+ end
67
+
68
+ # Removing a template is safe while clones exist: the SM layer keeps
69
+ # the shared base VHD.
70
+ def remove_template(info)
71
+ rec = @client.call("VM.get_record", info.ref)
72
+ raise Error.new(:template_not_marked, name: info.name) unless (rec["other_config"] || {}).key?(Marking::BOX_DIGEST)
73
+
74
+ vdis = disk_vdis(info.ref)
75
+ @client.call("VM.destroy", info.ref)
76
+ vdis.each { |vdi| ignore_missing { @client.call("VDI.destroy", vdi) } }
77
+ end
78
+
79
+ # VMs of this Vagrant installation: same client_id, or (for VMs
80
+ # without one) the same user and local hostname.
81
+ # @return [Array<VmInfo>]
82
+ def my_vms(user: Marking.user, client: Marking.client_host, client_id: nil)
83
+ marked_vms.select { |v| mine?(v[:oc], user, client, client_id) }
84
+ .map do |v|
85
+ VmInfo.new(ref: v[:ref], uuid: v[:uuid], name: v[:name], machine: v.dig(:oc, Marking::MACHINE),
86
+ project: v.dig(:oc, Marking::PROJECT), user: v.dig(:oc, Marking::USER),
87
+ client: v.dig(:oc, Marking::CLIENT), power_state: v[:power_state])
88
+ end
89
+ end
90
+
91
+ # Everything that carries the plugin's marking, read through XAPI.
92
+ # With filters, an object is listed when it belongs to any of them:
93
+ # projects: VMs, snapshots and internal networks of these project
94
+ # directories
95
+ # vm_uuids: these VMs, their snapshots and the VDIs they own
96
+ # boxes: templates of these box names
97
+ # mine: client_id of this installation: its VMs (or, for VMs
98
+ # without client id, those of this user and hostname),
99
+ # their snapshots and disks, and the internal networks of
100
+ # their projects; shared templates are not "mine"
101
+ # @return [Array<Marked>]
102
+ def marked(projects: [], vm_uuids: [], boxes: [], mine: nil)
103
+ projects = projects.map(&:to_s)
104
+ vm_uuids = vm_uuids.map(&:to_s)
105
+ boxes = boxes.map(&:to_s)
106
+ filtered = !(projects.empty? && vm_uuids.empty? && boxes.empty? && mine.nil?)
107
+ result = []
108
+ records = @client.call("VM.get_all_records")
109
+
110
+ my_vms = []
111
+ my_projects = []
112
+ if mine
113
+ records.each_value do |rec|
114
+ oc = rec["other_config"] || {}
115
+ next if rec["is_a_template"] || !Marking.marked?(oc)
116
+ next unless mine?(oc, Marking.user, Marking.client_host, mine)
117
+
118
+ my_vms << rec["uuid"]
119
+ my_projects << oc[Marking::PROJECT]
120
+ end
121
+ end
122
+ vm_uuids |= my_vms
123
+ network_projects = projects | my_projects
124
+
125
+ records.each_value do |rec|
126
+ oc = rec["other_config"] || {}
127
+ if rec["is_a_snapshot"] && oc.key?(Marking::SNAPSHOT_OF)
128
+ next if filtered && !vm_uuids.include?(oc[Marking::SNAPSHOT_OF]) && !projects.include?(oc[Marking::PROJECT])
129
+
130
+ result << Marked.new(kind: :snapshot, uuid: rec["uuid"], name: rec["name_label"],
131
+ detail: "of VM #{oc[Marking::SNAPSHOT_OF]}")
132
+ elsif rec["is_a_template"] && oc.key?(Marking::BOX_DIGEST)
133
+ next if filtered && !boxes.include?(oc[Marking::BOX])
134
+
135
+ result << Marked.new(kind: :template, uuid: rec["uuid"], name: rec["name_label"],
136
+ detail: "box #{oc[Marking::BOX]} #{oc[Marking::BOX_VERSION]}")
137
+ elsif !rec["is_a_template"] && Marking.marked?(oc)
138
+ next if filtered && !projects.include?(oc[Marking::PROJECT]) && !vm_uuids.include?(rec["uuid"])
139
+
140
+ result << Marked.new(kind: :vm, uuid: rec["uuid"], name: rec["name_label"],
141
+ detail: "#{oc[Marking::MACHINE]} in #{oc[Marking::PROJECT]}")
142
+ end
143
+ end
144
+
145
+ @client.call("VDI.get_all_records").each_value do |rec|
146
+ oc = rec["other_config"] || {}
147
+ owner = oc[Marking::OWNER_VM]
148
+ if owner
149
+ next if filtered && !vm_uuids.include?(owner)
150
+
151
+ result << Marked.new(kind: :vdi, uuid: rec["uuid"], name: rec["name_label"], detail: "of VM #{owner}")
152
+ elsif oc.key?(Marking::OF_TEMPLATE) && !filtered
153
+ result << Marked.new(kind: :vdi, uuid: rec["uuid"], name: rec["name_label"],
154
+ detail: "of template #{oc[Marking::OF_TEMPLATE]}")
155
+ end
156
+ end
157
+
158
+ @client.call("network.get_all_records").each_value do |rec|
159
+ oc = rec["other_config"] || {}
160
+ next unless oc.key?(Marking::NETWORK)
161
+ next if filtered && !network_projects.include?(oc[Marking::PROJECT])
162
+
163
+ result << Marked.new(kind: :network, uuid: rec["uuid"], name: rec["name_label"],
164
+ detail: "project #{oc[Marking::PROJECT]}")
165
+ end
166
+
167
+ unless filtered
168
+ pool = @client.call("pool.get_all").first
169
+ @client.call("pool.get_other_config", pool).each do |key, value|
170
+ next unless key.start_with?("#{Marking::PREFIX}lock.")
171
+
172
+ owner = begin
173
+ JSON.parse(value)["owner"]
174
+ rescue JSON::ParserError
175
+ value
176
+ end
177
+ result << Marked.new(kind: :lock, uuid: "", name: key.delete_prefix("#{Marking::PREFIX}lock."),
178
+ detail: "held by #{owner}")
179
+ end
180
+ end
181
+
182
+ result.sort_by { |m| [KIND_ORDER.index(m.kind), m.name.to_s] }
183
+ end
184
+
185
+ private
186
+
187
+ def mine?(oc, user, client, client_id)
188
+ return oc[Marking::CLIENT_ID] == client_id.to_s if client_id && oc.key?(Marking::CLIENT_ID)
189
+
190
+ oc[Marking::USER] == user && oc[Marking::CLIENT] == client
191
+ end
192
+
193
+ def marked_vms
194
+ @marked_vms ||= begin
195
+ recs = begin
196
+ @client.call("VM.get_all_records_where", 'field "is_a_template"="false"')
197
+ rescue Api::XapiError
198
+ @client.call("VM.get_all_records")
199
+ end
200
+ recs.filter_map do |ref, rec|
201
+ next if rec["is_a_template"] || rec["is_a_snapshot"] || rec["is_control_domain"]
202
+
203
+ oc = rec["other_config"] || {}
204
+ next unless Marking.marked?(oc)
205
+
206
+ { ref: ref, uuid: rec["uuid"], name: rec["name_label"], oc: oc, power_state: rec["power_state"] }
207
+ end
208
+ end
209
+ end
210
+
211
+ def sr_name(uuid)
212
+ return nil if uuid.to_s.empty?
213
+
214
+ ignore_missing { @client.call("SR.get_name_label", @client.call("SR.get_by_uuid", uuid)) } || uuid
215
+ end
216
+
217
+ def version_key(version)
218
+ Gem::Version.new(version.to_s)
219
+ rescue ArgumentError
220
+ Gem::Version.new("0")
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ require_relative "base"
6
+
7
+ module VagrantPlugins
8
+ module Xapi
9
+ module Services
10
+ # Finds the guest's IP from the configured sources, in order:
11
+ # :guest_metrics VM_guest_metrics.networks "<dev>/ipv4/0", "<dev>/ip"
12
+ # :static ip: of the first VIF (set via cloud-init network-config)
13
+ # :himn network.assigned_ips of the HIMN VIF
14
+ class IpDetector
15
+ include Helpers
16
+
17
+ SOURCES = %i[guest_metrics static himn].freeze
18
+
19
+ # @param static_ip [String, nil] ip: of VIF device 0
20
+ def initialize(ctx, sources:, static_ip: nil, himn_enabled: false)
21
+ @ctx = ctx
22
+ @client = ctx.client
23
+ @sources = Array(sources).map(&:to_sym)
24
+ @static_ip = static_ip
25
+ @himn_enabled = himn_enabled
26
+ end
27
+
28
+ # One look, no waiting.
29
+ # @return [Array(String, Symbol), nil] [ip, source]
30
+ def detect(vm)
31
+ @sources.each do |source|
32
+ ip = case source
33
+ when :guest_metrics then from_guest_metrics(vm)
34
+ when :static then @static_ip.to_s.empty? ? nil : @static_ip.to_s
35
+ when :himn then @himn_enabled ? from_himn(vm) : nil
36
+ end
37
+ return [ip, source] if ip
38
+ end
39
+ nil
40
+ end
41
+
42
+ # Waits until an IP shows up or the timeout passes.
43
+ def wait(vm, timeout:, interval: 2)
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
45
+ loop do
46
+ found = detect(vm)
47
+ return found if found
48
+ raise Api::TaskCancelled, "waiting for IP" if @ctx.cancelled?
49
+
50
+ power = @client.call("VM.get_power_state", vm)
51
+ raise Error.new(:vm_stopped_while_waiting, state: power) unless power == "Running"
52
+
53
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
54
+ raise Error.new(:ip_timeout, timeout: timeout, sources: @sources.join(", "))
55
+ end
56
+
57
+ sleep interval
58
+ end
59
+ end
60
+
61
+ def from_guest_metrics(vm, device: 0)
62
+ metrics = @client.call("VM.get_guest_metrics", vm)
63
+ return nil if null_ref?(metrics)
64
+
65
+ nets = ignore_missing { @client.call("VM_guest_metrics.get_networks", metrics) } || {}
66
+ candidate = nets["#{device}/ipv4/0"] || nets["#{device}/ip"]
67
+ usable?(candidate) ? candidate : nil
68
+ end
69
+
70
+ def from_himn(vm)
71
+ himn_vif = @client.call("VM.get_VIFs", vm).find do |vif|
72
+ net = @client.call("VIF.get_network", vif)
73
+ @client.call("network.get_other_config", net)["is_host_internal_management_network"].to_s == "true"
74
+ end
75
+ return nil unless himn_vif
76
+
77
+ net = @client.call("VIF.get_network", himn_vif)
78
+ ip = (@client.call("network.get_assigned_ips", net) || {})[himn_vif]
79
+ # HIMN leases are link-local (169.254.0.0/16) by design.
80
+ usable?(ip, allow_link_local: true) ? ip : nil
81
+ end
82
+
83
+ # Only a bare IPv4 or IPv6 literal is accepted. The value is written by
84
+ # the guest agent, so it is untrusted: it ends up in an SSH
85
+ # ProxyCommand (%h) and in NFS exports.
86
+ def self.valid_ip?(ip, allow_link_local: false)
87
+ s = ip.to_s
88
+ return false unless s.match?(/\A[0-9A-Fa-f:.]{2,45}\z/)
89
+
90
+ addr = IPAddr.new(s)
91
+ return false if addr.loopback? || addr.to_s == "0.0.0.0" || addr.to_s == "::"
92
+ return false if !allow_link_local && addr.link_local?
93
+
94
+ true
95
+ rescue IPAddr::Error
96
+ false
97
+ end
98
+
99
+ private
100
+
101
+ def usable?(ip, allow_link_local: false)
102
+ self.class.valid_ip?(ip, allow_link_local: allow_link_local)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end