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,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "disk_image"
|
|
7
|
+
require_relative "lookup"
|
|
8
|
+
require_relative "image_upload"
|
|
9
|
+
require_relative "box_source"
|
|
10
|
+
|
|
11
|
+
module VagrantPlugins
|
|
12
|
+
module Xapi
|
|
13
|
+
module Services
|
|
14
|
+
# config.vm.disk support: grow the primary disk, add named disks, import
|
|
15
|
+
# disk images, attach ISOs (local upload or from an ISO SR), and remove
|
|
16
|
+
# disks that disappeared from the Vagrantfile.
|
|
17
|
+
class Disks
|
|
18
|
+
include Helpers
|
|
19
|
+
|
|
20
|
+
TWO_TIB = 2 * 1024**4
|
|
21
|
+
MIB = 1024**2
|
|
22
|
+
CLOUD_INIT_NAME = "vagrant-cloud_init-disk"
|
|
23
|
+
DESCRIPTION = "vagrant-xapi disk"
|
|
24
|
+
|
|
25
|
+
# @param default_sr [String, nil] provider config `sr`
|
|
26
|
+
# @param converter [#call, nil] qcow2 -> VHD converter (qemu-img)
|
|
27
|
+
# @param digest_cache [DigestCache, nil] avoids re-hashing large ISOs
|
|
28
|
+
# on every boot
|
|
29
|
+
def initialize(ctx, default_sr: nil, converter: nil, digest_cache: nil)
|
|
30
|
+
@ctx = ctx
|
|
31
|
+
@client = ctx.client
|
|
32
|
+
@lookup = Lookup.new(ctx)
|
|
33
|
+
@default_sr = default_sr
|
|
34
|
+
@upload = ImageUpload.new(ctx, converter: converter)
|
|
35
|
+
@digest_cache = digest_cache
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param disks [Array] Vagrant disk configs (type, name, size, primary,
|
|
39
|
+
# file, provider_config)
|
|
40
|
+
# @return [Hash] { disk: [{uuid:, name:}], dvd: [...], floppy: [] }
|
|
41
|
+
def configure(vm, disks)
|
|
42
|
+
result = { disk: [], floppy: [], dvd: [] }
|
|
43
|
+
return result if disks.empty?
|
|
44
|
+
|
|
45
|
+
@ctx.ui&.info(:configuring_disks)
|
|
46
|
+
vm_uuid = @client.call("VM.get_uuid", vm)
|
|
47
|
+
disks.each do |disk|
|
|
48
|
+
case disk.type
|
|
49
|
+
when :disk
|
|
50
|
+
entry = disk.primary ? configure_primary(vm, disk) : configure_named(vm, vm_uuid, disk)
|
|
51
|
+
result[:disk] << entry if entry
|
|
52
|
+
when :dvd
|
|
53
|
+
entry = configure_dvd(vm, vm_uuid, disk)
|
|
54
|
+
result[:dvd] << entry if entry
|
|
55
|
+
when :floppy
|
|
56
|
+
raise Error.new(:floppy_unsupported, name: disk.name.to_s)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param meta [Hash] disk_meta from the last configure ("disk"/"dvd")
|
|
63
|
+
def cleanup(vm, disks, meta)
|
|
64
|
+
vm_uuid = @client.call("VM.get_uuid", vm)
|
|
65
|
+
names = disks.map { |d| d.name.to_s }
|
|
66
|
+
%w[disk dvd].each do |kind|
|
|
67
|
+
Array(meta[kind]).each do |entry|
|
|
68
|
+
next if entry["primary"]
|
|
69
|
+
next if names.include?(entry["name"].to_s)
|
|
70
|
+
|
|
71
|
+
@ctx.ui&.warn(:disk_removed, name: entry["name"])
|
|
72
|
+
remove_disk(vm, vm_uuid, entry["uuid"])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Attaches ISOs from ISO SRs (provider option `isos`) as CD drives and
|
|
78
|
+
# detaches the ones this plugin attached that are no longer listed.
|
|
79
|
+
# The ISO VDIs themselves are never destroyed.
|
|
80
|
+
def sync_isos(vm, names)
|
|
81
|
+
names = Array(names).map(&:to_s)
|
|
82
|
+
attached = {}
|
|
83
|
+
vbds_of(vm).each do |vbd, rec|
|
|
84
|
+
iso = (rec["other_config"] || {})[Marking::ISO]
|
|
85
|
+
next unless iso
|
|
86
|
+
|
|
87
|
+
if names.include?(iso) && !attached.key?(iso)
|
|
88
|
+
attached[iso] = vbd
|
|
89
|
+
else
|
|
90
|
+
unplug_if_running(vm, vbd, rec)
|
|
91
|
+
ignore_missing { @client.call("VBD.destroy", vbd) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
(names - attached.keys).each do |iso|
|
|
95
|
+
vbd = attach(vm, @lookup.iso_vdi(iso), "CD")
|
|
96
|
+
set_oc("VBD", vbd, Marking::ISO, iso)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def unplug_if_running(vm, vbd, rec)
|
|
103
|
+
return unless rec["currently_attached"] && @client.call("VM.get_power_state", vm) == "Running"
|
|
104
|
+
|
|
105
|
+
@client.call("VBD.unplug", vbd)
|
|
106
|
+
rescue Api::XapiError => e
|
|
107
|
+
raise Error.new(:disk_unplug_failed, name: rec["userdevice"].to_s, reason: e.code)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def configure_primary(vm, disk)
|
|
111
|
+
vbd, rec = vbds_of(vm).find { |_ref, r| r["userdevice"].to_s == "0" && r["type"] == "Disk" }
|
|
112
|
+
raise Error.new(:primary_disk_missing) unless vbd
|
|
113
|
+
|
|
114
|
+
vdi = rec["VDI"]
|
|
115
|
+
resize(vm, vdi, disk.size, disk.name) if disk.size
|
|
116
|
+
{ uuid: @client.call("VDI.get_uuid", vdi), name: disk.name.to_s, primary: true }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def configure_named(vm, vm_uuid, disk)
|
|
120
|
+
name = disk.name.to_s
|
|
121
|
+
raise Error.new(:disk_name_required) if name.empty?
|
|
122
|
+
|
|
123
|
+
existing = find_owned(vm, vm_uuid, name, "Disk")
|
|
124
|
+
if existing
|
|
125
|
+
resize(vm, existing, disk.size, name) if disk.size
|
|
126
|
+
return { uuid: @client.call("VDI.get_uuid", existing), name: name }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
sr = sr_for(disk)
|
|
130
|
+
vdi = if disk.file
|
|
131
|
+
import_image(sr, disk.file, name)
|
|
132
|
+
else
|
|
133
|
+
warn_large(disk.size, name)
|
|
134
|
+
create_vdi(sr, name, disk.size.to_i, description: DESCRIPTION)
|
|
135
|
+
end
|
|
136
|
+
mark_vdi(vdi, vm_uuid, name)
|
|
137
|
+
attach(vm, vdi, "Disk")
|
|
138
|
+
{ uuid: @client.call("VDI.get_uuid", vdi), name: name }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def configure_dvd(vm, vm_uuid, disk)
|
|
142
|
+
name = disk.name.to_s
|
|
143
|
+
file = disk.file.to_s
|
|
144
|
+
raise Error.new(:dvd_file_missing, name: name) if file.empty? || !File.file?(file)
|
|
145
|
+
|
|
146
|
+
sha = @digest_cache ? @digest_cache.digest(file) : Digest::SHA256.file(file).hexdigest
|
|
147
|
+
existing = find_owned(vm, vm_uuid, name, nil)
|
|
148
|
+
if existing
|
|
149
|
+
if @client.call("VDI.get_other_config", existing)[Marking::CONTENT_SHA256] == sha
|
|
150
|
+
return { uuid: @client.call("VDI.get_uuid", existing), name: name }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
@ctx.ui&.detail(:replacing_iso, name: name)
|
|
154
|
+
remove_disk(vm, vm_uuid, @client.call("VDI.get_uuid", existing))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
sr = sr_for(disk)
|
|
158
|
+
size = ((File.size(file) + MIB - 1) / MIB) * MIB
|
|
159
|
+
vdi = create_vdi(sr, name, size, description: DESCRIPTION)
|
|
160
|
+
begin
|
|
161
|
+
vdi = @upload.upload(vdi, file, "raw", label: "vagrant-xapi upload iso")
|
|
162
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
163
|
+
ignore_missing { @client.call("VDI.destroy", vdi) }
|
|
164
|
+
raise
|
|
165
|
+
end
|
|
166
|
+
mark_vdi(vdi, vm_uuid, name)
|
|
167
|
+
set_oc("VDI", vdi, Marking::CONTENT_SHA256, sha)
|
|
168
|
+
set_oc("VDI", vdi, Marking::CLOUD_INIT, "true") if name == CLOUD_INIT_NAME
|
|
169
|
+
attach_cd(vm, vdi, name)
|
|
170
|
+
{ uuid: @client.call("VDI.get_uuid", vdi), name: name }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def find_owned(vm, vm_uuid, name, type)
|
|
174
|
+
vbds_of(vm).each do |_vbd, rec|
|
|
175
|
+
next if null_ref?(rec["VDI"])
|
|
176
|
+
next if type && rec["type"] != type
|
|
177
|
+
|
|
178
|
+
oc = ignore_missing { @client.call("VDI.get_other_config", rec["VDI"]) } || {}
|
|
179
|
+
return rec["VDI"] if oc[Marking::DISK_NAME] == name && oc[Marking::OWNER_VM] == vm_uuid
|
|
180
|
+
end
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def resize(vm, vdi, size, name)
|
|
185
|
+
want = size.to_i
|
|
186
|
+
have = @client.call("VDI.get_virtual_size", vdi).to_i
|
|
187
|
+
return if want == have || want.zero?
|
|
188
|
+
raise Error.new(:disk_shrink, name: name.to_s, have: have, want: want) if want < have
|
|
189
|
+
|
|
190
|
+
warn_large(want, name)
|
|
191
|
+
unless @client.call("VM.get_power_state", vm) == "Halted"
|
|
192
|
+
raise Error.new(:resize_needs_halt, name: name.to_s)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@ctx.ui&.detail(:resizing_disk, name: name.to_s, size: want)
|
|
196
|
+
@client.call("VDI.resize", vdi, want)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def import_image(sr, file, name)
|
|
200
|
+
raise Error.new(:disk_image_missing, path: file.to_s) unless File.file?(file.to_s)
|
|
201
|
+
|
|
202
|
+
info = DiskImage.inspect_file(file.to_s)
|
|
203
|
+
warn_large(info.virtual_size, name)
|
|
204
|
+
vdi = create_vdi(sr, name, info.virtual_size, description: DESCRIPTION)
|
|
205
|
+
begin
|
|
206
|
+
vdi = @upload.upload(vdi, file.to_s, info.format, label: "vagrant-xapi import disk")
|
|
207
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
208
|
+
ignore_missing { @client.call("VDI.destroy", vdi) }
|
|
209
|
+
raise
|
|
210
|
+
end
|
|
211
|
+
vdi
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def mark_vdi(vdi, vm_uuid, name)
|
|
215
|
+
set_oc("VDI", vdi, Marking::OWNER_VM, vm_uuid)
|
|
216
|
+
set_oc("VDI", vdi, Marking::DISK_NAME, name)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def attach(vm, vdi, type, mode: type == "CD" ? "RO" : "RW")
|
|
220
|
+
vbd = create_vbd(vm, vdi, mode: mode, type: type)
|
|
221
|
+
@client.call("VBD.plug", vbd) if @client.call("VM.get_power_state", vm) == "Running"
|
|
222
|
+
vbd
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# An uploaded ISO lives on a normal SR. Attach it as a CD; if XAPI
|
|
226
|
+
# refuses that, or all CD slots are taken, fall back to a read-only
|
|
227
|
+
# disk (cloud-init NoCloud finds its "cidata" volume on any block
|
|
228
|
+
# device).
|
|
229
|
+
def attach_cd(vm, vdi, name)
|
|
230
|
+
attach(vm, vdi, "CD")
|
|
231
|
+
rescue Api::XapiError => e
|
|
232
|
+
@ctx.logger&.info("attaching ISO as CD failed (#{e.code}); attaching as read-only disk")
|
|
233
|
+
attach(vm, vdi, "Disk", mode: "RO")
|
|
234
|
+
rescue Error => e
|
|
235
|
+
raise unless e.key == :no_free_cd_slot
|
|
236
|
+
|
|
237
|
+
@ctx.ui&.warn(:dvd_as_disk, name: name, in_use: e.params[:in_use]) unless name == CLOUD_INIT_NAME
|
|
238
|
+
attach(vm, vdi, "Disk", mode: "RO")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def remove_disk(vm, vm_uuid, vdi_uuid)
|
|
242
|
+
vdi = ignore_missing { @client.call("VDI.get_by_uuid", vdi_uuid.to_s) }
|
|
243
|
+
return unless vdi
|
|
244
|
+
|
|
245
|
+
running = @client.call("VM.get_power_state", vm) == "Running"
|
|
246
|
+
vbds_of(vm).each do |vbd, rec|
|
|
247
|
+
next unless rec["VDI"] == vdi
|
|
248
|
+
|
|
249
|
+
if running && rec["currently_attached"]
|
|
250
|
+
begin
|
|
251
|
+
@client.call("VBD.unplug", vbd)
|
|
252
|
+
rescue Api::XapiError => e
|
|
253
|
+
raise Error.new(:disk_unplug_failed, name: vdi_uuid.to_s, reason: e.code)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
ignore_missing { @client.call("VBD.destroy", vbd) }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
oc = @client.call("VDI.get_other_config", vdi)
|
|
260
|
+
# Only destroy what we created; VDIs from an ISO SR stay.
|
|
261
|
+
ignore_missing { @client.call("VDI.destroy", vdi) } if Marking.ours?(oc, Marking::OWNER_VM, vm_uuid)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def sr_for(disk)
|
|
265
|
+
name = provider_option(disk, :sr) || @default_sr
|
|
266
|
+
@lookup.sr(name)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def provider_option(disk, key)
|
|
270
|
+
pc = disk.provider_config || {}
|
|
271
|
+
scoped = pc[:xapi] || pc["xapi"] || {}
|
|
272
|
+
scoped[key] || scoped[key.to_s] || pc[:"xapi__#{key}"] || pc["xapi__#{key}"]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def warn_large(size, name)
|
|
276
|
+
@ctx.ui&.warn(:disk_over_2tib, name: name.to_s) if size.to_i > TWO_TIB
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "base"
|
|
6
|
+
require_relative "lookup"
|
|
7
|
+
|
|
8
|
+
module VagrantPlugins
|
|
9
|
+
module Xapi
|
|
10
|
+
module Services
|
|
11
|
+
# Exports a halted VM as the content of an `xapi` box: XVA (all disks)
|
|
12
|
+
# or the first disk as VHD or qcow2. Works on a temporary clone that is
|
|
13
|
+
# stripped of everything that belongs to this pool.
|
|
14
|
+
class Exporter
|
|
15
|
+
include Helpers
|
|
16
|
+
|
|
17
|
+
FORMATS = %w[xva vhd qcow2].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(ctx)
|
|
20
|
+
@ctx = ctx
|
|
21
|
+
@client = ctx.client
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Hash] the box metadata written to metadata.json
|
|
25
|
+
def export(vm, directory, format:)
|
|
26
|
+
format = format.to_s
|
|
27
|
+
raise Error.new(:package_format_unsupported, image_format: format) unless FORMATS.include?(format)
|
|
28
|
+
|
|
29
|
+
unless @client.call("VM.get_power_state", vm) == "Halted"
|
|
30
|
+
raise Error.new(:package_needs_halt)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
media_devices = iso_userdevices(vm)
|
|
34
|
+
name = "vagrant-xapi package #{@client.call("VM.get_name_label", vm)}"
|
|
35
|
+
@ctx.ui&.info(:package_cloning)
|
|
36
|
+
clone = @ctx.run_task("VM.clone", vm, name, label: "clone for package").ref
|
|
37
|
+
begin
|
|
38
|
+
strip(clone, media_devices, vm)
|
|
39
|
+
dest = File.join(directory.to_s, "box.#{format}")
|
|
40
|
+
@ctx.ui&.info(:package_exporting, image_format: format)
|
|
41
|
+
lookup = Lookup.new(@ctx)
|
|
42
|
+
if format == "xva"
|
|
43
|
+
download("/export", { "uuid" => @client.call("VM.get_uuid", clone), "use_compression" => "zstd" },
|
|
44
|
+
dest, lookup.transfer_host_for_vm(clone))
|
|
45
|
+
else
|
|
46
|
+
vdi = boot_vdi(clone)
|
|
47
|
+
begin
|
|
48
|
+
download("/export_raw_vdi", { "vdi" => vdi, "format" => format }, dest,
|
|
49
|
+
lookup.transfer_host_for_sr(@client.call("VDI.get_SR", vdi)))
|
|
50
|
+
rescue Api::HttpError => e
|
|
51
|
+
raise unless e.status.between?(400, 499)
|
|
52
|
+
|
|
53
|
+
raise Error.new(:export_format_refused, image_format: format, status: e.status)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
ensure
|
|
57
|
+
destroy_clone(clone, vbds_of(vm).map { |_v, r| r["VDI"] })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
metadata = { "provider" => "xapi", "format" => format, "architecture" => "amd64" }
|
|
61
|
+
File.write(File.join(directory.to_s, "metadata.json"), JSON.generate(metadata))
|
|
62
|
+
metadata
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def download(path, query, dest, host)
|
|
68
|
+
@client.http.download(path, query, dest, host: host,
|
|
69
|
+
progress: @ctx.progress_proc(:downloading, file: File.basename(dest)),
|
|
70
|
+
cancel: @ctx.cancel, task_label: "export #{File.basename(dest)}")
|
|
71
|
+
@ctx.ui&.clear_progress
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Userdevices of CDs and uploaded ISOs (incl. cloud-init) on the VM.
|
|
75
|
+
def iso_userdevices(vm)
|
|
76
|
+
vbds_of(vm).filter_map do |_vbd, rec|
|
|
77
|
+
next rec["userdevice"] if rec["type"] == "CD"
|
|
78
|
+
next if null_ref?(rec["VDI"])
|
|
79
|
+
|
|
80
|
+
oc = ignore_missing { @client.call("VDI.get_other_config", rec["VDI"]) } || {}
|
|
81
|
+
rec["userdevice"] if oc.key?(Marking::CLOUD_INIT) || oc.key?(Marking::CONTENT_SHA256)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def strip(clone, media_devices, original)
|
|
86
|
+
original_vdis = vbds_of(original).map { |_v, r| r["VDI"] }
|
|
87
|
+
vbds_of(clone).each do |vbd, rec|
|
|
88
|
+
next unless media_devices.include?(rec["userdevice"])
|
|
89
|
+
|
|
90
|
+
@client.call("VBD.destroy", vbd)
|
|
91
|
+
vdi = rec["VDI"]
|
|
92
|
+
ignore_missing { @client.call("VDI.destroy", vdi) } unless null_ref?(vdi) || original_vdis.include?(vdi)
|
|
93
|
+
end
|
|
94
|
+
@client.call("VM.get_VIFs", clone).each { |vif| @client.call("VIF.destroy", vif) }
|
|
95
|
+
@client.call("VM.get_other_config", clone).each_key do |key|
|
|
96
|
+
remove_oc("VM", clone, key) if key.start_with?(Marking::PREFIX)
|
|
97
|
+
end
|
|
98
|
+
begin
|
|
99
|
+
@client.call("VM.remove_tags", clone, Marking::TAG)
|
|
100
|
+
rescue Api::XapiError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
disk_vdis(clone).each do |vdi|
|
|
104
|
+
@client.call("VDI.get_other_config", vdi).each_key do |key|
|
|
105
|
+
remove_oc("VDI", vdi, key) if key.start_with?(Marking::PREFIX)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def boot_vdi(vm)
|
|
111
|
+
_vbd, rec = vbds_of(vm).find { |_v, r| r["type"] == "Disk" && r["userdevice"].to_s == "0" }
|
|
112
|
+
rec ||= vbds_of(vm).find { |_v, r| r["type"] == "Disk" }&.last
|
|
113
|
+
raise Error.new(:primary_disk_missing) unless rec
|
|
114
|
+
|
|
115
|
+
rec["VDI"]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def destroy_clone(clone, keep)
|
|
119
|
+
vdis = (ignore_missing { disk_vdis(clone) } || []) - keep
|
|
120
|
+
ignore_missing { @client.call("VM.destroy", clone) }
|
|
121
|
+
vdis.each { |vdi| ignore_missing { @client.call("VDI.destroy", vdi) } }
|
|
122
|
+
rescue Api::Error => e
|
|
123
|
+
@ctx.logger&.warn("could not remove package clone: #{e.message}")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
require_relative "base"
|
|
6
|
+
|
|
7
|
+
module VagrantPlugins
|
|
8
|
+
module Xapi
|
|
9
|
+
module Services
|
|
10
|
+
# Plain comparison of two file listings, for `vagrant xapi diff`.
|
|
11
|
+
#
|
|
12
|
+
# A listing is the output of GNU find with PRINTF, run in the root of
|
|
13
|
+
# the tree on "." or on "./<path>": per file eight NUL-terminated fields
|
|
14
|
+
# (type, mode, uid, gid, size, mtime, link target, path). Paths are
|
|
15
|
+
# bytes as the guest wrote them; they are kept as binary strings and
|
|
16
|
+
# only escaped for display. find has no field for the device numbers of
|
|
17
|
+
# a device node, so those are compared by type, mode and owner only.
|
|
18
|
+
module FileDiff
|
|
19
|
+
PRINTF = '%y\0%m\0%U\0%G\0%s\0%T@\0%l\0%p\0'
|
|
20
|
+
FIELDS = 8
|
|
21
|
+
|
|
22
|
+
# tree: index of the tree (file system) the file was listed in;
|
|
23
|
+
# rel: path relative to that tree, as the guest knows it.
|
|
24
|
+
Entry = Struct.new(:type, :mode, :uid, :gid, :size, :mtime, :target, :tree, :rel)
|
|
25
|
+
|
|
26
|
+
# kind: :added, :removed, :changed (content, possibly also metadata),
|
|
27
|
+
# :metadata (mode or owner only), :type (e.g. file became symlink).
|
|
28
|
+
# unreadable: the content of one side could not be read.
|
|
29
|
+
Change = Struct.new(:kind, :path, :from, :to, :unreadable, keyword_init: true)
|
|
30
|
+
|
|
31
|
+
# Paths left out unless --no-default-excludes: they change on every
|
|
32
|
+
# boot, hold caches or logs, or (/usr/src) hold kernel headers and
|
|
33
|
+
# module sources that every kernel update replaces.
|
|
34
|
+
DEFAULT_EXCLUDES = %w[/tmp /var/tmp /var/cache /var/log /var/lib/systemd/random-seed /usr/src].freeze
|
|
35
|
+
|
|
36
|
+
TYPE_NAMES = { "f" => "file", "d" => "directory", "l" => "symlink", "b" => "block_device",
|
|
37
|
+
"c" => "char_device", "p" => "fifo", "s" => "socket" }.freeze
|
|
38
|
+
|
|
39
|
+
# Which paths to compare. A path is compared when it lies below one of
|
|
40
|
+
# `paths` (all, when empty) and neither it nor one of its parent
|
|
41
|
+
# directories matches an exclude pattern (a shell glob over the whole
|
|
42
|
+
# path; `*` also matches `/`). A pattern that does not start with `/`
|
|
43
|
+
# matches at any depth, as in rsync: `node_modules` leaves out every
|
|
44
|
+
# file or directory of that name, `app/cache` every .../app/cache.
|
|
45
|
+
# `internal` paths (the run's own temporary directory) are always
|
|
46
|
+
# left out.
|
|
47
|
+
class Filter
|
|
48
|
+
GLOB = /[*?\[{]/.freeze
|
|
49
|
+
|
|
50
|
+
# --path directories, normalized
|
|
51
|
+
attr_reader :paths
|
|
52
|
+
|
|
53
|
+
def initialize(paths: [], excludes: [], internal: [])
|
|
54
|
+
@options = { paths: paths, excludes: excludes, internal: internal }
|
|
55
|
+
@paths = paths.map { |p| FileDiff.normalize(p) }
|
|
56
|
+
literal, globs = excludes.map { |p| FileDiff.normalize(p) }.partition { |p| !GLOB.match?(p) }
|
|
57
|
+
# At any depth: a relative literal matches the end of a parent
|
|
58
|
+
# path, a relative glob gets "*/" before it.
|
|
59
|
+
relative, @literal = literal.partition { |p| !p.start_with?("/") }
|
|
60
|
+
@relative = relative.map { |p| "/#{p}".b }
|
|
61
|
+
@globs = globs.map { |g| g.start_with?("/") ? g : "*/#{g}".b }
|
|
62
|
+
@internal = internal.map { |p| FileDiff.normalize(p) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The same filter that also leaves out these paths.
|
|
66
|
+
def with_internal(*paths)
|
|
67
|
+
self.class.new(**@options, internal: @options[:internal] + paths.compact)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def include?(path)
|
|
71
|
+
return false if @internal.any? { |i| FileDiff.below?(path, i) }
|
|
72
|
+
return false unless @paths.empty? || @paths.any? { |p| FileDiff.below?(path, p) }
|
|
73
|
+
|
|
74
|
+
!excluded?(path)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def excluded?(path)
|
|
80
|
+
return true if @literal.any? { |p| FileDiff.below?(path, p) }
|
|
81
|
+
return false if @globs.empty? && @relative.empty?
|
|
82
|
+
|
|
83
|
+
FileDiff.ancestors(path).any? do |a|
|
|
84
|
+
@relative.any? { |r| a.end_with?(r) } ||
|
|
85
|
+
@globs.any? { |g| File.fnmatch?(g, a, File::FNM_DOTMATCH | File::FNM_EXTGLOB) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module_function
|
|
91
|
+
|
|
92
|
+
# @param data [String] listing as written by find
|
|
93
|
+
# @param mountpoint [String] where the tree belongs in the comparison
|
|
94
|
+
# @param tree [Integer] index of the tree
|
|
95
|
+
# @param into [Hash] entries are added to this hash
|
|
96
|
+
# @param hidden [Array<String>] mount points below the tree's: what is
|
|
97
|
+
# below them is left out (the running guest cannot see it either)
|
|
98
|
+
# @return [Hash{String => Entry}] full path => entry
|
|
99
|
+
def parse(data, mountpoint, tree, into = {}, hidden: [])
|
|
100
|
+
data = data.to_s.b
|
|
101
|
+
# Every field ends with NUL: a listing cut off inside its last path
|
|
102
|
+
# still has whole groups of fields, but no NUL at the end.
|
|
103
|
+
raise Error.new(:diff_listing_invalid) unless data.empty? || data.end_with?("\0")
|
|
104
|
+
|
|
105
|
+
fields = data.split("\0", -1)
|
|
106
|
+
fields.pop
|
|
107
|
+
raise Error.new(:diff_listing_invalid) unless (fields.size % FIELDS).zero?
|
|
108
|
+
|
|
109
|
+
prefix = normalize(mountpoint)
|
|
110
|
+
hidden = hidden.map { |h| normalize(h) }
|
|
111
|
+
fields.each_slice(FIELDS) do |type, mode, uid, gid, size, mtime, target, found|
|
|
112
|
+
# "." is the root of the tree: its mode and owner belong to the
|
|
113
|
+
# mount point's path, which the tree above leaves out.
|
|
114
|
+
rel = found == "." ? "." : found.delete_prefix("./")
|
|
115
|
+
path = rel == "." ? prefix : join(prefix, rel)
|
|
116
|
+
# A mount point and what is below it belong to another tree; the
|
|
117
|
+
# entry of the mount point itself differs per side (the mounted
|
|
118
|
+
# root live, the empty directory below it in a snapshot).
|
|
119
|
+
next if hidden.any? { |h| path == h || path.start_with?("#{h}/") }
|
|
120
|
+
|
|
121
|
+
into[path] = Entry.new(type, mode, uid.to_i, gid.to_i, size.to_i, mtime,
|
|
122
|
+
type == "l" ? target : nil, tree, rel)
|
|
123
|
+
end
|
|
124
|
+
into
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Regular files of the same size whose content the quick check cannot
|
|
128
|
+
# tell: a different modification time, or every one with checksum.
|
|
129
|
+
# @return [Array<String>]
|
|
130
|
+
def hash_candidates(from, to, checksum: false)
|
|
131
|
+
from.filter_map do |path, a|
|
|
132
|
+
b = to[path]
|
|
133
|
+
next unless b && a.type == "f" && b.type == "f" && a.size == b.size
|
|
134
|
+
next unless checksum || a.mtime != b.mtime
|
|
135
|
+
|
|
136
|
+
path
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @param hashed [Set<String>] paths whose content was hashed
|
|
141
|
+
# @param hashes_from, hashes_to [Hash{String => String}] path => SHA-256
|
|
142
|
+
# @return [Array<Change>] sorted by path
|
|
143
|
+
def compare(from, to, hashed: Set.new, hashes_from: {}, hashes_to: {})
|
|
144
|
+
changes = []
|
|
145
|
+
from.each do |path, a|
|
|
146
|
+
b = to[path]
|
|
147
|
+
if b.nil?
|
|
148
|
+
changes << Change.new(kind: :removed, path: path, from: a)
|
|
149
|
+
else
|
|
150
|
+
change = compare_entry(path, a, b, hashed, hashes_from, hashes_to)
|
|
151
|
+
changes << change if change
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
to.each { |path, b| changes << Change.new(kind: :added, path: path, to: b) unless from.key?(path) }
|
|
155
|
+
changes.sort_by(&:path)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def compare_entry(path, a, b, hashed, hashes_from, hashes_to)
|
|
159
|
+
return Change.new(kind: :type, path: path, from: a, to: b) if a.type != b.type
|
|
160
|
+
|
|
161
|
+
unreadable = false
|
|
162
|
+
content = case a.type
|
|
163
|
+
when "f"
|
|
164
|
+
if a.size != b.size
|
|
165
|
+
true
|
|
166
|
+
elsif hashed.include?(path)
|
|
167
|
+
ha = hashes_from[path]
|
|
168
|
+
hb = hashes_to[path]
|
|
169
|
+
unreadable = ha.nil? || hb.nil?
|
|
170
|
+
unreadable || ha != hb
|
|
171
|
+
else
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
when "l" then a.target != b.target
|
|
175
|
+
else false
|
|
176
|
+
end
|
|
177
|
+
metadata = a.mode != b.mode || a.uid != b.uid || a.gid != b.gid
|
|
178
|
+
return nil unless content || metadata
|
|
179
|
+
|
|
180
|
+
Change.new(kind: content ? :changed : :metadata, path: path, from: a, to: b,
|
|
181
|
+
unreadable: unreadable ? true : nil)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Guest bytes for a terminal or JSON: valid UTF-8 without control,
|
|
185
|
+
# format (zero-width, bidirectional, soft hyphen, BOM) or line and
|
|
186
|
+
# paragraph separator characters, which could hide or rewrite what the
|
|
187
|
+
# user sees. Escapes are unambiguous: \xHH is an ASCII control or a
|
|
188
|
+
# byte that is not UTF-8, \u{H} a character, \\ a backslash.
|
|
189
|
+
def escape(value)
|
|
190
|
+
value.to_s.dup.force_encoding(Encoding::UTF_8).each_char.map do |ch|
|
|
191
|
+
if !ch.valid_encoding?
|
|
192
|
+
ch.bytes.map { |byte| format("\\x%02X", byte) }.join
|
|
193
|
+
elsif ch == "\\"
|
|
194
|
+
"\\\\"
|
|
195
|
+
elsif ch.match?(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/)
|
|
196
|
+
ch.ord < 0x80 ? format("\\x%02X", ch.ord) : format("\\u{%X}", ch.ord)
|
|
197
|
+
else
|
|
198
|
+
ch
|
|
199
|
+
end
|
|
200
|
+
end.join
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def mode(entry)
|
|
204
|
+
format("%04o", entry.mode.to_i(8))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def owner(entry)
|
|
208
|
+
"#{entry.uid}:#{entry.gid}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Squeezes slashes and drops a trailing one. In an absolute path "."
|
|
212
|
+
# and ".." are resolved lexically: the paths of a listing never
|
|
213
|
+
# contain them, so `--path /etc/./nginx` means /etc/nginx.
|
|
214
|
+
def normalize(path)
|
|
215
|
+
path = path.to_s.b
|
|
216
|
+
path = path.squeeze("/")
|
|
217
|
+
if path.start_with?("/") && path.match?(%r{(?:\A|/)\.\.?(?:/|\z)}n)
|
|
218
|
+
parts = []
|
|
219
|
+
path.split("/").each do |part|
|
|
220
|
+
next if part.empty? || part == "."
|
|
221
|
+
|
|
222
|
+
part == ".." ? parts.pop : parts << part
|
|
223
|
+
end
|
|
224
|
+
path = "/#{parts.join("/")}".b
|
|
225
|
+
end
|
|
226
|
+
path.length > 1 ? path.chomp("/") : path
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def join(prefix, rel)
|
|
230
|
+
prefix == "/" ? "/#{rel}".b : "#{prefix}/#{rel}".b
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def below?(path, dir)
|
|
234
|
+
dir == "/" || path == dir || path.start_with?("#{dir}/")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# The path and its parent directories, without "/".
|
|
238
|
+
def ancestors(path)
|
|
239
|
+
list = [path]
|
|
240
|
+
while (i = path.rindex("/")) && i.positive?
|
|
241
|
+
path = path[0, i]
|
|
242
|
+
list << path
|
|
243
|
+
end
|
|
244
|
+
list
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|