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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/README.md +460 -4
- data/docs/architecture.md +1420 -0
- data/docs/operations.md +531 -0
- data/lib/vagrant-xapi/action/base.rb +103 -0
- data/lib/vagrant-xapi/action/cloud_init.rb +130 -0
- data/lib/vagrant-xapi/action/create.rb +127 -0
- data/lib/vagrant-xapi/action/destroy.rb +68 -0
- data/lib/vagrant-xapi/action/network.rb +229 -0
- data/lib/vagrant-xapi/action/package.rb +103 -0
- data/lib/vagrant-xapi/action/power.rb +185 -0
- data/lib/vagrant-xapi/action/snapshots.rb +43 -0
- data/lib/vagrant-xapi/action.rb +317 -0
- data/lib/vagrant-xapi/api/client.rb +393 -0
- data/lib/vagrant-xapi/api/errors.rb +98 -0
- data/lib/vagrant-xapi/api/http_transfer.rb +332 -0
- data/lib/vagrant-xapi/api/redactor.rb +77 -0
- data/lib/vagrant-xapi/api/session_registry.rb +59 -0
- data/lib/vagrant-xapi/api/task.rb +156 -0
- data/lib/vagrant-xapi/api/transport.rb +249 -0
- data/lib/vagrant-xapi/cap.rb +90 -0
- data/lib/vagrant-xapi/command/adopt.rb +92 -0
- data/lib/vagrant-xapi/command/base.rb +79 -0
- data/lib/vagrant-xapi/command/console.rb +87 -0
- data/lib/vagrant-xapi/command/diff.rb +267 -0
- data/lib/vagrant-xapi/command/hosts.rb +63 -0
- data/lib/vagrant-xapi/command/info.rb +94 -0
- data/lib/vagrant-xapi/command/marked.rb +85 -0
- data/lib/vagrant-xapi/command/orphans.rb +96 -0
- data/lib/vagrant-xapi/command/root.rb +55 -0
- data/lib/vagrant-xapi/command/templates.rb +91 -0
- data/lib/vagrant-xapi/config.rb +333 -0
- data/lib/vagrant-xapi/errors.rb +113 -0
- data/lib/vagrant-xapi/extensions.rb +80 -0
- data/lib/vagrant-xapi/glue.rb +330 -0
- data/lib/vagrant-xapi/legacy/plugin.rb +61 -0
- data/lib/vagrant-xapi/legacy/xenapi.rb +129 -0
- data/lib/vagrant-xapi/plugin.rb +58 -0
- data/lib/vagrant-xapi/provider.rb +135 -0
- data/lib/vagrant-xapi/services/adoption.rb +73 -0
- data/lib/vagrant-xapi/services/base.rb +242 -0
- data/lib/vagrant-xapi/services/box_source.rb +218 -0
- data/lib/vagrant-xapi/services/console.rb +115 -0
- data/lib/vagrant-xapi/services/content_diff.rb +96 -0
- data/lib/vagrant-xapi/services/destroyer.rb +104 -0
- data/lib/vagrant-xapi/services/diff_disks.rb +346 -0
- data/lib/vagrant-xapi/services/differ.rb +382 -0
- data/lib/vagrant-xapi/services/disk_image.rb +122 -0
- data/lib/vagrant-xapi/services/disks.rb +281 -0
- data/lib/vagrant-xapi/services/exporter.rb +128 -0
- data/lib/vagrant-xapi/services/file_diff.rb +249 -0
- data/lib/vagrant-xapi/services/guest_files.rb +730 -0
- data/lib/vagrant-xapi/services/hosts.rb +55 -0
- data/lib/vagrant-xapi/services/image_upload.rb +172 -0
- data/lib/vagrant-xapi/services/inventory.rb +225 -0
- data/lib/vagrant-xapi/services/ip_detector.rb +107 -0
- data/lib/vagrant-xapi/services/lock.rb +293 -0
- data/lib/vagrant-xapi/services/lookup.rb +181 -0
- data/lib/vagrant-xapi/services/networks.rb +248 -0
- data/lib/vagrant-xapi/services/power.rb +108 -0
- data/lib/vagrant-xapi/services/rfb.rb +129 -0
- data/lib/vagrant-xapi/services/snapshots.rb +81 -0
- data/lib/vagrant-xapi/services/template_cache.rb +263 -0
- data/lib/vagrant-xapi/services/tunnels.rb +217 -0
- data/lib/vagrant-xapi/services/vm_builder.rb +215 -0
- data/lib/vagrant-xapi/version.rb +7 -0
- data/lib/vagrant-xapi/xenserver/config.rb +69 -0
- data/lib/vagrant-xapi/xenserver/migrate.rb +103 -0
- data/lib/vagrant-xapi/xenserver/plugin.rb +96 -0
- data/lib/vagrant-xapi/xenserver/provider.rb +22 -0
- data/lib/vagrant-xapi/xenserver/takeover.rb +79 -0
- data/lib/vagrant-xapi.rb +21 -2
- data/lib/xenapi.rb +22 -0
- data/locales/en.yml +517 -0
- data/locales/legacy.en.yml +6 -0
- 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
|