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,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+
6
+ require_relative "base"
7
+ require_relative "rfb"
8
+
9
+ module VagrantPlugins
10
+ module Xapi
11
+ module Services
12
+ # Forwards the VM's VNC console (XAPI console tunnel, RFB) to a local
13
+ # TCP port. Each local connection gets its own HTTP CONNECT to XAPI;
14
+ # the session goes in a cookie. With a password, a viewer must know it
15
+ # before the proxy connects to XAPI (Rfb).
16
+ class Console
17
+ # @param password [String, nil] the one-time VNC password for viewers
18
+ def initialize(ctx, password: nil)
19
+ @ctx = ctx
20
+ @client = ctx.client
21
+ @host_ref = nil
22
+ @password = password
23
+ end
24
+
25
+ # @return [URI] the console location
26
+ def location(vm)
27
+ unless @client.call("VM.get_power_state", vm) == "Running"
28
+ raise Error.new(:console_not_running)
29
+ end
30
+
31
+ consoles = @client.call("VM.get_consoles", vm).map { |c| @client.call("console.get_record", c) }
32
+ rfb = consoles.find { |c| c["protocol"] == "rfb" }
33
+ raise Error.new(:console_missing) unless rfb
34
+
35
+ # The console lives on the host the VM runs on; connect there with
36
+ # that host's own (pinned) certificate.
37
+ @host_ref = @client.call("VM.get_resident_on", vm)
38
+ URI.parse(rfb["location"])
39
+ end
40
+
41
+ # Opens a listening socket on 127.0.0.1.
42
+ # @return [TCPServer]
43
+ def listen(port = 0)
44
+ TCPServer.new("127.0.0.1", port)
45
+ end
46
+
47
+ # Serves connections until stop.call returns true.
48
+ def serve(server, uri, stop:)
49
+ threads = []
50
+ until stop.call
51
+ ready = IO.select([server], nil, nil, 0.5)
52
+ next unless ready
53
+
54
+ local = server.accept
55
+ threads << Thread.new(local) { |sock| bridge(sock, uri) }
56
+ end
57
+ ensure
58
+ server.close unless server.closed?
59
+ threads.each(&:kill)
60
+ end
61
+
62
+ def bridge(local, uri)
63
+ if @password && !Rfb.serve_viewer(local, @password)
64
+ @ctx.logger&.warn("console: a viewer gave the wrong password")
65
+ return
66
+ end
67
+ remote = connect(uri)
68
+ Rfb.join_server(remote) if @password
69
+ pipe(local, remote)
70
+ rescue StandardError => e
71
+ @ctx.logger&.warn("console connection failed: #{e.message}")
72
+ ensure
73
+ [local, remote].compact.each do |s|
74
+ s.close
75
+ rescue StandardError
76
+ nil
77
+ end
78
+ end
79
+
80
+ # HTTP CONNECT to the console location.
81
+ def connect(uri)
82
+ sock = @client.transport_for(@host_ref).open_tls_socket
83
+ path = uri.request_uri
84
+ sock.write("CONNECT #{path} HTTP/1.0\r\nHost: #{uri.host}\r\n" \
85
+ "Cookie: #{@client.session_cookie}\r\n\r\n")
86
+ header = +""
87
+ header << sock.readpartial(1) until header.end_with?("\r\n\r\n") || header.size > 8192
88
+ status = header.lines.first.to_s
89
+ raise Error.new(:console_refused, status: status.strip) unless status =~ %r{\AHTTP/1\.[01] 200}
90
+
91
+ sock
92
+ end
93
+
94
+ private
95
+
96
+ def pipe(a, b)
97
+ loop do
98
+ # Data already decrypted into the SSL buffer is invisible to select.
99
+ readable = [a, b].select { |s| s.respond_to?(:pending) && s.pending.positive? }
100
+ readable, = IO.select([a, b], nil, nil, 1) if readable.empty?
101
+ next unless readable
102
+
103
+ readable.each do |src|
104
+ dst = src == a ? b : a
105
+ data = src.readpartial(65_536)
106
+ dst.write(data)
107
+ end
108
+ end
109
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
110
+ nil
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+
6
+ require_relative "base"
7
+ require_relative "file_diff"
8
+
9
+ module VagrantPlugins
10
+ module Xapi
11
+ module Services
12
+ # The content of changed files for `vagrant xapi diff --content`: a
13
+ # unified diff, made with the local machine's `diff`, of the copies the
14
+ # guest made (GuestFiles#contents). The result is for a terminal, so
15
+ # control and format characters are escaped: a file in the guest must
16
+ # not be able to send escape sequences (also 8-bit C1 ones such as
17
+ # U+009B) or reorder what is shown (bidi overrides).
18
+ module ContentDiff
19
+ DEFAULT_LIMIT = 1024 * 1024
20
+ # What one side may copy in total, so a diff after a large upgrade
21
+ # does not fill the guest's /tmp (often RAM) or the local memory.
22
+ BUDGET = 16 * 1024 * 1024
23
+ # Like diff and git: a NUL byte in the first 8000 bytes means binary.
24
+ # GuestFiles#contents checks the same in the guest.
25
+ BINARY_PROBE = 8000
26
+ # As FileDiff.escape, but tab and newline stay: they make the diff.
27
+ CONTROL = /[[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]&&[^\t\n]]/.freeze
28
+
29
+ module_function
30
+
31
+ # @param old [String, nil] the content before; nil when the file is new
32
+ # @param new [String, nil] the content after; nil when it was removed
33
+ # @return [Hash] { diff: String }, or { skipped: :binary } or
34
+ # { skipped: :identical } (both copies exist and are the same, e.g.
35
+ # when only hashing a file failed)
36
+ def render(path, old, new)
37
+ return { skipped: :binary } if binary?(old) || binary?(new)
38
+
39
+ Dir.mktmpdir("vagrant-xapi-content-") do |dir|
40
+ a = File.join(dir, "a")
41
+ b = File.join(dir, "b")
42
+ File.binwrite(a, old.to_s)
43
+ File.binwrite(b, new.to_s)
44
+ label = FileDiff.escape(path)
45
+ labels = [old ? "a#{label}" : "/dev/null", new ? "b#{label}" : "/dev/null"]
46
+ out, err, status = Open3.capture3("diff", "-u", "--label", labels[0], "--label", labels[1], a, b,
47
+ binmode: true)
48
+ # 0: equal, 1: different, anything else (also a signal, which has
49
+ # no exit status): trouble, and the output may be cut off.
50
+ unless status.success? || status.exitstatus == 1
51
+ detail = err.strip.empty? ? status.to_s : err.strip
52
+ raise Error.new(:diff_local_failed, detail: FileDiff.escape(detail[0, 300]))
53
+ end
54
+ if status.success?
55
+ return { skipped: :identical } if old && new
56
+
57
+ # An empty file that was added or removed: only the header says so.
58
+ out = "--- #{labels[0]}\n+++ #{labels[1]}\n"
59
+ end
60
+
61
+ { diff: sanitize(out) }
62
+ end
63
+ rescue Errno::ENOENT
64
+ raise Error.new(:diff_local_missing)
65
+ end
66
+
67
+ def binary?(data)
68
+ !data.nil? && data.b.byteslice(0, BINARY_PROBE).include?("\0")
69
+ end
70
+
71
+ # The budget per side for a limit per file: a file of limit bytes
72
+ # always fits.
73
+ def budget(limit)
74
+ [BUDGET, limit].max
75
+ end
76
+
77
+ # Valid UTF-8 for the terminal: invalid bytes as \xHH, control and
78
+ # format characters other than tab and newline as \xHH (ASCII) or
79
+ # \u{HHHH}, as FileDiff.escape does for paths.
80
+ def sanitize(text)
81
+ text.dup.force_encoding(Encoding::UTF_8)
82
+ .scrub { |bytes| bytes.bytes.map { |byte| format("\\x%02X", byte) }.join }
83
+ .gsub(CONTROL) { |ch| ch.ord < 0x80 ? format("\\x%02X", ch.ord) : format("\\u{%X}", ch.ord) }
84
+ end
85
+
86
+ # "1M", "512K", "100000" => bytes; nil when it is no size.
87
+ def parse_size(value)
88
+ m = /\A(\d+)\s*([kKmM]?)i?[bB]?\z/.match(value.to_s.strip)
89
+ return nil unless m
90
+
91
+ m[1].to_i * { "" => 1, "k" => 1024, "m" => 1024 * 1024 }.fetch(m[2].downcase)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "diff_disks"
5
+ require_relative "snapshots"
6
+
7
+ module VagrantPlugins
8
+ module Xapi
9
+ module Services
10
+ # Removes a VM and everything the plugin created for it. Only objects
11
+ # carrying this machine's marking are touched; VDIs that belong to a
12
+ # template are never destroyed.
13
+ class Destroyer
14
+ include Helpers
15
+
16
+ def initialize(ctx)
17
+ @ctx = ctx
18
+ @client = ctx.client
19
+ end
20
+
21
+ # @return [Array<String>] internal network refs the VM used, for
22
+ # cleanup by the caller once the VM is gone
23
+ # @param owner [Hash, nil] :machine and :project the VM must be marked
24
+ # with (a copied project directory must not destroy the original's VM)
25
+ def destroy(vm, require_marking: true, owner: nil)
26
+ rec = @client.call("VM.get_record", vm)
27
+ check_owner!(rec, owner) if require_marking
28
+
29
+ vm_uuid = rec["uuid"]
30
+ networks = rec["VIFs"].map { |vif| ignore_missing { @client.call("VIF.get_network", vif) } }.compact.uniq
31
+
32
+ unremoved = remove_diff_leftovers(vm)
33
+ Snapshots.new(@ctx).delete_all(vm)
34
+
35
+ owned = owned_vdis(vm, vm_uuid)
36
+ suspend_vdi = rec["suspend_VDI"]
37
+ @ctx.ui&.info(:destroying_vm)
38
+ @client.call("VM.destroy", vm)
39
+ owned.each { |vdi| ignore_missing { @client.call("VDI.destroy", vdi) } }
40
+ ignore_missing { @client.call("VDI.destroy", suspend_vdi) } unless null_ref?(suspend_vdi)
41
+ DiffDisks.new(@ctx).remove_orphans(unremoved, vm_uuid) unless unremoved.empty?
42
+ networks
43
+ end
44
+
45
+ # Raises unless the VM is marked (and, with owner, marked for that
46
+ # machine and project). Runs before anything touches the VM.
47
+ # @param rec [Hash, String] VM record or ref
48
+ def check_owner!(rec, owner = nil)
49
+ rec = @client.call("VM.get_record", rec) if rec.is_a?(String)
50
+ oc = rec["other_config"] || {}
51
+ raise Error.new(:vm_not_marked, uuid: rec["uuid"]) unless Marking.marked?(oc)
52
+ return unless owner
53
+ return if oc[Marking::MACHINE] == owner[:machine].to_s && oc[Marking::PROJECT] == owner[:project].to_s
54
+
55
+ raise Error.new(:vm_other_owner, uuid: rec["uuid"], machine: oc[Marking::MACHINE].to_s,
56
+ project: oc[Marking::PROJECT].to_s)
57
+ end
58
+
59
+ private
60
+
61
+ # Clones an interrupted `vagrant xapi diff` left. Best effort: the
62
+ # search is pool-wide, and a destroy (also the rollback of a failed
63
+ # create) must not fail on it. Attached ones are removed with the
64
+ # VM's own disks anyway.
65
+ # @return [Array<DiffDisks::Disk>] the ones that could not be removed,
66
+ # tried again once the VM is gone
67
+ def remove_diff_leftovers(vm)
68
+ unremoved = []
69
+ DiffDisks.new(@ctx).cleanup_leftovers(vm, failed: unremoved)
70
+ unremoved
71
+ rescue Api::Error => e
72
+ @ctx.logger&.warn("could not look for leftover diff disks: #{e.message}")
73
+ unremoved || []
74
+ end
75
+
76
+ # VDIs of the VM's VBDs that we may remove: marked as owned by this
77
+ # VM, and no disk of a template, even if one is attached to the VM and
78
+ # carries its marking.
79
+ def owned_vdis(vm, vm_uuid)
80
+ vbds_of(vm).filter_map do |_vbd, rec|
81
+ vdi = rec["VDI"]
82
+ next if null_ref?(vdi)
83
+
84
+ oc = ignore_missing { @client.call("VDI.get_other_config", vdi) } || {}
85
+ next unless Marking.ours?(oc, Marking::OWNER_VM, vm_uuid)
86
+ next if template_disk?(vdi)
87
+
88
+ vdi
89
+ end
90
+ end
91
+
92
+ # Asked only for the VDIs about to be destroyed, so a destroy does
93
+ # not read the disks of every template in the pool.
94
+ def template_disk?(vdi)
95
+ vbds = ignore_missing { @client.call("VDI.get_VBDs", vdi) } || []
96
+ vbds.any? do |vbd|
97
+ owner = ignore_missing { @client.call("VBD.get_VM", vbd) }
98
+ owner && ignore_missing { @client.call("VM.get_is_a_template", owner) }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "file_diff"
5
+ require_relative "lock"
6
+ require_relative "snapshots"
7
+
8
+ module VagrantPlugins
9
+ module Xapi
10
+ module Services
11
+ # The XAPI side of `vagrant xapi diff`: throwaway copy-on-write clones of
12
+ # a snapshot's disks, attached to the running VM so that the guest can
13
+ # mount them. Each clone carries vagrant-xapi.vm and vagrant-xapi.diff,
14
+ # its VBD vagrant-xapi.diff, so the disks of an interrupted run are found
15
+ # and removed: by the next diff, before the VM boots, and on destroy.
16
+ class DiffDisks
17
+ include Helpers
18
+
19
+ # One attached clone. vdi is nil for a leftover VBD whose disk is not
20
+ # ours to destroy.
21
+ Disk = Struct.new(:vbd, :vdi, :device, :size, keyword_init: true)
22
+
23
+ # Linux device names XAPI reports in VBD.device (xvdb, ...)
24
+ DEVICE = /\A(?:xvd|sd|hd|vd)[a-z]{1,3}\z/.freeze
25
+ LOCK_TTL = 300
26
+ LOCK_WAIT = 1800
27
+ # How long boot and package wait for a diff of the same VM: one can
28
+ # only still run there when the VM was halted under it.
29
+ LEFTOVER_LOCK_WAIT = 300
30
+ DEVICE_WAIT = 30
31
+ UNPLUG_TIMEOUT = 120
32
+
33
+ def self.label(vm_uuid)
34
+ "vagrant-xapi diff of #{vm_uuid}"
35
+ end
36
+
37
+ def initialize(ctx, sleeper: ->(seconds) { sleep seconds })
38
+ @ctx = ctx
39
+ @client = ctx.client
40
+ @sleeper = sleeper
41
+ end
42
+
43
+ # Runs the block under the lock diff.<vm uuid>, so that two runs on one
44
+ # VM never remove each other's disks, mounts or directories. Boot and
45
+ # package take it too before they remove leftovers.
46
+ def synchronize(vm, wait: LOCK_WAIT, &block)
47
+ lock(vm, wait: wait).synchronize(&block)
48
+ end
49
+
50
+ # The lock of a VM's diff runs.
51
+ # @return [Lock]
52
+ def lock(vm, wait: LOCK_WAIT)
53
+ Lock.new(@ctx, "diff.#{uuid_of(vm)}", ttl: LOCK_TTL, wait: wait)
54
+ end
55
+
56
+ # Clones every disk of the snapshot and attaches the clones to the
57
+ # running VM. Read-write: XAPI refuses read-only disks for HVM guests
58
+ # (DISK_VBD_MUST_BE_READWRITE_FOR_HVM), and a writable device lets the
59
+ # guest replay the journal of a crash-consistent snapshot. On any
60
+ # error the disks attached so far are removed again.
61
+ # @return [Array<Disk>]
62
+ def attach_snapshot(vm, name, run_id)
63
+ snap = Snapshots.new(@ctx).find(vm, name)
64
+ vm_uuid = uuid_of(vm)
65
+ disks = []
66
+ begin
67
+ vbds_of(snap).sort_by { |_vbd, rec| rec["userdevice"].to_i }.each do |_vbd, rec|
68
+ next unless file_system_disk?(rec)
69
+ raise Api::TaskCancelled, "diff" if @ctx.cancelled?
70
+
71
+ disks << attach_clone(vm, vm_uuid, rec["VDI"], run_id)
72
+ end
73
+ rescue Exception # rubocop:disable Lint/RescueException
74
+ detach(disks)
75
+ raise
76
+ end
77
+ raise Error.new(:diff_snapshot_no_disks, name: name.to_s) if disks.empty?
78
+
79
+ disks
80
+ end
81
+
82
+ # Unplugs and destroys the clones. Never raises: a disk that cannot be
83
+ # removed is reported and removed by the next diff, boot or destroy.
84
+ def detach(disks)
85
+ return if disks.empty?
86
+
87
+ @ctx.ui&.detail(:diff_detaching)
88
+ disks.reverse_each { |disk| remove(disk) }
89
+ end
90
+
91
+ # Removes what an interrupted run left: VBDs of this VM marked as diff
92
+ # disks, and (with unattached) clones that never got a VBD. Only VDIs
93
+ # marked with vagrant-xapi.diff and this VM are destroyed.
94
+ #
95
+ # With required (before the VM boots or is packaged), a copy that is
96
+ # still attached afterwards raises: it has the same file system UUIDs
97
+ # as the VM's own disks, so the guest could mount or boot from it, and
98
+ # a box would take it along without marking.
99
+ # @return [Integer] number of disks removed
100
+ # @param failed [Array<Disk>, nil] collects the disks that could not be
101
+ # removed instead of warning (destroy retries them once the VM is
102
+ # gone, see #remove_orphans)
103
+ def cleanup_leftovers(vm, unattached: true, required: false, failed: nil)
104
+ vm_uuid = uuid_of(vm)
105
+ found = []
106
+ vbds_of(vm).each do |vbd, rec|
107
+ next unless diff_vbd?(rec)
108
+
109
+ vdi = rec["VDI"]
110
+ found << Disk.new(vbd: vbd, vdi: !null_ref?(vdi) && diff_vdi?(vdi_other_config(vdi), vm_uuid) ? vdi : nil)
111
+ end
112
+ found.concat(unattached_vdis(vm_uuid).map { |vdi| Disk.new(vdi: vdi) }) if unattached
113
+ return 0 if found.empty?
114
+
115
+ @ctx.ui&.detail(:diff_leftovers, count: found.size)
116
+ found.each { |disk| remove(disk, failed: failed) }
117
+ check_none_attached!(vm, vm_uuid) if required
118
+ found.size
119
+ end
120
+
121
+ private
122
+
123
+ # Disks with file systems to compare: not CD drives, not read-only
124
+ # disks, not uploaded ISOs (such as the cloud-init ISO, which is
125
+ # attached as a read-only disk when the SR refuses a CD).
126
+ def file_system_disk?(rec)
127
+ return false if rec["type"] != "Disk" || rec["mode"] == "RO" || null_ref?(rec["VDI"])
128
+
129
+ oc = vdi_other_config(rec["VDI"])
130
+ !oc.key?(Marking::CLOUD_INIT) && !oc.key?(Marking::CONTENT_SHA256)
131
+ end
132
+
133
+ def attach_clone(vm, vm_uuid, source, run_id)
134
+ vdi = clone(source, vm_uuid, run_id)
135
+ disk = Disk.new(vdi: vdi)
136
+ begin
137
+ mark(vdi, vm_uuid, run_id)
138
+ disk.vbd = create_vbd(vm, vdi, mode: "RW", type: "Disk", other_config: { Marking::DIFF => run_id.to_s })
139
+ disk.size = @client.call("VDI.get_virtual_size", vdi).to_i
140
+ plug(disk.vbd)
141
+ disk.device = wait_for_device(disk.vbd)
142
+ rescue Exception # rubocop:disable Lint/RescueException
143
+ remove(disk)
144
+ raise
145
+ end
146
+ disk
147
+ end
148
+
149
+ # Not cancellable: a clone cancelled half-way could exist without our
150
+ # marking; Ctrl-C takes effect right after it. When waiting for it
151
+ # fails all the same (task_timeout, or a lost connection), XAPI may
152
+ # still have made the clone, without our marking: it is looked for
153
+ # among the VDIs that were not there before, by what a clone
154
+ # inherits from its source (name, SR, other_config, no VBD), marked
155
+ # and removed. One that is not there yet is reported.
156
+ # @return [String] the clone's ref
157
+ def clone(source, vm_uuid, run_id)
158
+ src = @client.call("VDI.get_record", source)
159
+ before = clones_of(src)
160
+ begin
161
+ vdi = @client.run_task("VDI.clone", source, {}, label: "clone snapshot disk").ref
162
+ rescue Api::TaskTimeout, Api::ConnectionError
163
+ late = begin
164
+ clones_of(src) - before
165
+ rescue Api::Error
166
+ []
167
+ end
168
+ late.each do |ref|
169
+ # Marked first: if removing it fails, the next diff or destroy
170
+ # finds it.
171
+ mark(ref, vm_uuid, run_id)
172
+ remove(Disk.new(vdi: ref))
173
+ rescue Api::Error => e
174
+ @ctx.logger&.warn("late clone #{ref} not removed: #{e.message}")
175
+ end
176
+ @ctx.ui&.warn(:diff_clone_unfinished, name: FileDiff.escape(src["name_label"].to_s)) if late.empty?
177
+ raise
178
+ end
179
+ raise Error.new(:diff_clone_failed) unless vdi
180
+
181
+ vdi
182
+ end
183
+
184
+ # Unattached VDIs that look like clones of the source: its name, SR
185
+ # and other_config, not a snapshot. @return [Array<String>] refs
186
+ def clones_of(src)
187
+ name = src["name_label"].to_s
188
+ records = begin
189
+ @client.call("VDI.get_all_records_where", %(field "name__label"="#{Helpers.where_value(name)}"))
190
+ rescue Api::XapiError
191
+ @client.call("VDI.get_all_records")
192
+ end
193
+ records.filter_map do |ref, rec|
194
+ next unless rec["name_label"] == name && rec["SR"] == src["SR"] && !rec["is_a_snapshot"]
195
+ next unless Array(rec["VBDs"]).empty? && (rec["other_config"] || {}) == (src["other_config"] || {})
196
+
197
+ ref
198
+ end
199
+ end
200
+
201
+ # The name first: it is how a clone without VBD is found again, and
202
+ # until then the clone carries the source's name. Then
203
+ # vagrant-xapi.diff, so that a run that dies from here on leaves a
204
+ # clone the next diff or destroy finds (#unattached_vdis), and only
205
+ # then the rest. VDI.clone copies other_config, so the clone already
206
+ # carries vagrant-xapi.vm of this VM when the snapshot disk did; it is
207
+ # only rewritten when it differs, so it never goes missing in
208
+ # between. The snapshot disk's other markings (disk name, cloud-init,
209
+ # checksum) are removed: the clone could otherwise be taken for one of
210
+ # the VM's own disks. It only belongs to this run.
211
+ def mark(vdi, vm_uuid, run_id)
212
+ @client.call("VDI.set_name_label", vdi, self.class.label(vm_uuid))
213
+ set_oc("VDI", vdi, Marking::DIFF, run_id)
214
+ oc = @client.call("VDI.get_other_config", vdi)
215
+ set_oc("VDI", vdi, Marking::OWNER_VM, vm_uuid) unless oc[Marking::OWNER_VM] == vm_uuid
216
+ oc.each_key do |key|
217
+ next if [Marking::OWNER_VM, Marking::DIFF].include?(key)
218
+
219
+ remove_oc("VDI", vdi, key) if key.start_with?(Marking::PREFIX)
220
+ end
221
+ end
222
+
223
+ def plug(vbd)
224
+ @ctx.run_task("VBD.plug", vbd, label: "attach snapshot disk")
225
+ rescue Api::XapiError => e
226
+ raise Error.new(:diff_attach_failed, reason: e.code, detail: e.params.join(", "))
227
+ end
228
+
229
+ # XAPI fills in VBD.device from the xenopsd event of the plug, which
230
+ # may come a moment after the plug task finished.
231
+ def wait_for_device(vbd)
232
+ DEVICE_WAIT.times do
233
+ device = @client.call("VBD.get_device", vbd).to_s
234
+ return device if DEVICE.match?(device)
235
+ raise Api::TaskCancelled, "diff" if @ctx.cancelled?
236
+
237
+ @sleeper.call(1)
238
+ end
239
+ raise Error.new(:diff_device_unknown, device: @client.call("VBD.get_device", vbd).to_s)
240
+ end
241
+
242
+ # Runs after errors and Ctrl-C too, so nothing here is cancellable.
243
+ def remove(disk, failed: nil)
244
+ if disk.vbd
245
+ ignore_missing { unplug(disk.vbd) } if ignore_missing { @client.call("VBD.get_currently_attached", disk.vbd) }
246
+ ignore_missing { @client.call("VBD.destroy", disk.vbd) }
247
+ end
248
+ ignore_missing { @client.call("VDI.destroy", disk.vdi) } if disk.vdi
249
+ true
250
+ rescue Api::Error => e
251
+ @ctx.logger&.warn("diff disk #{disk.vdi || disk.vbd} not removed: #{e.message}")
252
+ if failed
253
+ failed << disk
254
+ else
255
+ @ctx.ui&.warn(:diff_detach_failed, vdi: disk.vdi.to_s, reason: e.message)
256
+ end
257
+ false
258
+ end
259
+
260
+ public
261
+
262
+ # Destroys the copies #cleanup_leftovers could not remove during
263
+ # destroy, now that the VM and its VBDs are gone. Nothing looks for a
264
+ # copy of a destroyed VM later, so one that still cannot be removed is
265
+ # named, with how to find and remove it. Never raises.
266
+ def remove_orphans(disks, vm_uuid)
267
+ disks.each do |disk|
268
+ next unless disk.vdi
269
+
270
+ begin
271
+ ignore_missing { @client.call("VDI.destroy", disk.vdi) }
272
+ rescue Api::Error => e
273
+ @ctx.logger&.warn("diff disk #{disk.vdi} not removed after destroy: #{e.message}")
274
+ @ctx.ui&.warn(:diff_leftover_orphaned, uuid: vdi_uuid(disk.vdi), vm: vm_uuid, reason: e.message)
275
+ end
276
+ end
277
+ end
278
+
279
+ private
280
+
281
+ def vdi_uuid(vdi)
282
+ @client.call("VDI.get_uuid", vdi)
283
+ rescue Api::Error
284
+ vdi
285
+ end
286
+
287
+ # A clone the guest still holds (a busy umount) refuses a clean unplug,
288
+ # or makes it wait. It is a throwaway copy: force it off rather than
289
+ # leave a disk with the same file system UUIDs attached to the VM. As
290
+ # tasks without cancel, so that Ctrl-C does not stop them and a slow
291
+ # unplug is not cut off by the timeout of a single call.
292
+ def unplug(vbd)
293
+ @client.run_task("VBD.unplug", vbd, label: "detach snapshot disk", timeout: UNPLUG_TIMEOUT)
294
+ rescue Api::XapiError, Api::TaskTimeout => e
295
+ raise if e.is_a?(Api::XapiError) && e.code == "HANDLE_INVALID"
296
+
297
+ @ctx.logger&.warn("unplug of diff disk failed (#{e.message}); forcing")
298
+ @client.run_task("VBD.unplug_force", vbd, label: "force-detach snapshot disk", timeout: UNPLUG_TIMEOUT)
299
+ end
300
+
301
+ # Clones without VBD of this VM's runs: marked with vagrant-xapi.diff
302
+ # and this VM, or, when a run died while marking a clone of a snapshot
303
+ # disk without vagrant-xapi.vm, with vagrant-xapi.diff and the name
304
+ # #mark gives (which holds the VM's UUID) but no vagrant-xapi.vm yet.
305
+ def unattached_vdis(vm_uuid)
306
+ label = self.class.label(vm_uuid)
307
+ records = begin
308
+ @client.call("VDI.get_all_records_where", %(field "name__label"="#{Helpers.where_value(label)}"))
309
+ rescue Api::XapiError
310
+ @client.call("VDI.get_all_records")
311
+ end
312
+ records.filter_map do |ref, rec|
313
+ oc = rec["other_config"] || {}
314
+ unmarked_vm = oc.key?(Marking::DIFF) && !oc.key?(Marking::OWNER_VM) && rec["name_label"] == label
315
+ next unless diff_vdi?(oc, vm_uuid) || unmarked_vm
316
+ next unless Array(rec["VBDs"]).empty?
317
+
318
+ ref
319
+ end
320
+ end
321
+
322
+ def diff_vdi?(other_config, vm_uuid)
323
+ oc = other_config || {}
324
+ oc.key?(Marking::DIFF) && Marking.ours?(oc, Marking::OWNER_VM, vm_uuid)
325
+ end
326
+
327
+ def diff_vbd?(rec)
328
+ (rec["other_config"] || {}).key?(Marking::DIFF)
329
+ end
330
+
331
+ def check_none_attached!(vm, vm_uuid)
332
+ left = vbds_of(vm).count { |_vbd, rec| diff_vbd?(rec) }
333
+ raise Error.new(:diff_leftover_attached, uuid: vm_uuid, count: left) unless left.zero?
334
+ end
335
+
336
+ def vdi_other_config(vdi)
337
+ ignore_missing { @client.call("VDI.get_other_config", vdi) } || {}
338
+ end
339
+
340
+ def uuid_of(vm)
341
+ @client.call("VM.get_uuid", vm)
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end