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,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "set"
5
+
6
+ require_relative "base"
7
+ require_relative "content_diff"
8
+ require_relative "diff_disks"
9
+ require_relative "file_diff"
10
+ require_relative "guest_files"
11
+
12
+ module VagrantPlugins
13
+ module Xapi
14
+ module Services
15
+ # `vagrant xapi diff`: which files differ between a snapshot and the
16
+ # running guest, two snapshots, or two running guests.
17
+ #
18
+ # A snapshot is read by attaching throwaway clones of its disks to the
19
+ # running VM and mounting them read-only in the guest itself: the
20
+ # snapshot comes from that guest, so its file systems cross no new trust
21
+ # boundary (dom0 or the local machine would parse a file system the
22
+ # guest wrote). The current state is the live tree, which also holds
23
+ # writes that are not on disk yet. File systems are paired by UUID
24
+ # (a snapshot with the running guest) or by mount point (two guests).
25
+ class Differ
26
+ # Paths of --path that may go into a find command.
27
+ SAFE_REL = %r{\A[A-Za-z0-9._@+=,:~%/-]+\z}.freeze
28
+
29
+ # A file system to list: where it belongs in the comparison, and where
30
+ # the guest can read it.
31
+ Tree = Struct.new(:mountpoint, :root, keyword_init: true)
32
+ # targets: the mount points of the running guest, or with two
33
+ # machines those of both. A mount point nested in a tree, and what
34
+ # lies below it, is left out of that tree on every side: a snapshot
35
+ # file system is mounted on its own, so it shows files the running
36
+ # guest has hidden below another mount, and a live tree would show the
37
+ # content of a bind mount of the same device. With two machines, a
38
+ # path that is a mount point on only one of them would otherwise show
39
+ # as all added or removed on the other.
40
+ Side = Struct.new(:files, :trees, :label, :targets, keyword_init: true)
41
+ # contents (with content): path => { diff: } or { skipped: :binary,
42
+ # :too_large (with size:) or :unreadable }
43
+ Result = Struct.new(:changes, :mountpoints, :read_errors, :contents, keyword_init: true)
44
+ # Changes whose content --content shows: of regular files only.
45
+ CONTENT_KINDS = %i[added removed changed].freeze
46
+
47
+ # @param tmpdir [String] local directory for downloaded listings
48
+ def initialize(ctx, tmpdir:)
49
+ @ctx = ctx
50
+ @client = ctx.client
51
+ @tmpdir = tmpdir
52
+ end
53
+
54
+ # Snapshot `from` against the running guest, or against snapshot `to`.
55
+ # @param guest see GuestFiles
56
+ # @param filter [FileDiff::Filter]
57
+ # @param content [Integer, nil] also show what changed in files up to
58
+ # this many bytes (--content)
59
+ # @return [Result]
60
+ def snapshot(vm, guest, from:, filter:, to: nil, checksum: false, content: nil)
61
+ files = GuestFiles.new(guest, @ctx)
62
+ disks = DiffDisks.new(@ctx)
63
+ run_id = SecureRandom.hex(8)
64
+ # Everything under the lock, so that a run never removes what another
65
+ # run on this VM is using: its disks, mounts and directory.
66
+ disks.synchronize(vm) do
67
+ files.prepare!
68
+ begin
69
+ files.layout # what an interrupted run left mounted
70
+ files.remove_stale!
71
+ disks.cleanup_leftovers(vm)
72
+ # Only now: a leftover copy with LUKS or RAID would fail the
73
+ # storage check of every later diff.
74
+ live = files.layout
75
+ files.check_storage!
76
+ attached = []
77
+ begin
78
+ targets = files.mount_targets
79
+ in_snapshot = Set.new
80
+ a = Side.new(files: files, label: [:snapshot, from], targets: targets,
81
+ trees: snapshot_trees(vm, files, disks, from, "a", live, run_id, attached, in_snapshot))
82
+ b = if to
83
+ side = Side.new(files: files, label: [:snapshot, to], targets: targets,
84
+ trees: snapshot_trees(vm, files, disks, to, "b", live, run_id, attached,
85
+ in_snapshot))
86
+ # One only one snapshot has is named by pair; one
87
+ # neither has would stay hidden below its mount point.
88
+ only_in_live(live, in_snapshot)
89
+ side
90
+ else
91
+ only_in_live(live, in_snapshot)
92
+ # Every snapshot file system was paired with a live one,
93
+ # so a missing one is a live mount point that failed the
94
+ # character check (already reported).
95
+ Side.new(files: files, label: [:live, nil], targets: targets,
96
+ trees: live_trees(live.select { |fs| a.trees.any? { |t| t.mountpoint == fs.target } }))
97
+ end
98
+ pair(a, b, report: !to.nil?)
99
+ compare(a, b, filter.with_internal(files.tmp), checksum, content)
100
+ ensure
101
+ files.unmount_all
102
+ files.deactivate_lvm
103
+ disks.detach(attached)
104
+ end
105
+ ensure
106
+ files.cleanup
107
+ end
108
+ end
109
+ end
110
+
111
+ # Two running guests, file systems paired by mount point.
112
+ # @param vms [Array(Array(Context, String), Array(Context, String))]
113
+ # per machine the context of its pool and its VM
114
+ # @param names [Array(String, String)] machine names, for messages
115
+ def machines(guest_from, guest_to, vms:, names:, filter:, checksum: false, content: nil)
116
+ from_files = GuestFiles.new(guest_from, @ctx)
117
+ to_files = GuestFiles.new(guest_to, @ctx)
118
+ # Under the diff locks of both VMs, like a snapshot run, because each
119
+ # removes what interrupted runs left in its guest.
120
+ synchronize_all(vms) do
121
+ from_files.prepare!
122
+ begin
123
+ to_files.prepare!
124
+ begin
125
+ [from_files, to_files].each do |files|
126
+ files.layout # what an interrupted run left mounted
127
+ files.remove_stale!
128
+ end
129
+ from_live = live_trees(from_files.layout)
130
+ to_live = live_trees(to_files.layout)
131
+ # Both sides leave out the mount points of both machines.
132
+ targets = from_files.mount_targets | to_files.mount_targets
133
+ a = Side.new(files: from_files, trees: from_live, label: [:machine, names[0]], targets: targets)
134
+ b = Side.new(files: to_files, trees: to_live, label: [:machine, names[1]], targets: targets)
135
+ pair(a, b)
136
+ compare(a, b, filter.with_internal(from_files.tmp, to_files.tmp), checksum, content)
137
+ ensure
138
+ to_files.cleanup
139
+ end
140
+ ensure
141
+ from_files.cleanup
142
+ end
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ # Runs the block under the diff locks of several VMs, taken in the
149
+ # order of their names (diff.<vm uuid>), so that two runs that need
150
+ # the same VMs cannot deadlock: one waits for the other, never both
151
+ # for each other.
152
+ # @param vms [Array(Context, String)] per VM the context of its pool
153
+ def synchronize_all(vms, &block)
154
+ locks = vms.map { |ctx, vm| DiffDisks.new(ctx).lock(vm) }.uniq(&:name).sort_by(&:name)
155
+ locks.reverse.reduce(block) { |inner, lock| -> { lock.synchronize(&inner) } }.call
156
+ end
157
+
158
+ # @param in_snapshot [Set<String>, nil] gets the UUID of every file
159
+ # system on the snapshot's disks, also of those that are skipped
160
+ def snapshot_trees(vm, files, disks, name, side, live, run_id, attached, in_snapshot = nil)
161
+ @ctx.ui&.info(:diff_attaching, name: name.to_s)
162
+ list = disks.attach_snapshot(vm, name, run_id)
163
+ attached.concat(list)
164
+ devices = list.map { |disk| files.probe(disk.device, disk.size) }
165
+ # After all disks are there: an LVM volume group may span several.
166
+ files.import_lvm(devices, "#{GuestFiles::VG_PREFIX}#{run_id}_#{side}")
167
+ by_uuid = live.reject { |fs| fs.uuid.empty? }.to_h { |fs| [fs.uuid, fs] }
168
+ trees = []
169
+ seen = Set.new
170
+ listed = Set.new
171
+ devices.each do |device|
172
+ files.partitions(device).each do |part|
173
+ next if part.fs_type == "swap"
174
+ # An LV with extents on two copies is listed under both disks.
175
+ next unless listed.add?(part.name)
176
+
177
+ in_snapshot&.add(part.uuid)
178
+
179
+ fs = by_uuid[part.uuid]
180
+ unless fs && files.mountable?(part)
181
+ @ctx.ui&.warn(:diff_fs_skipped, name: name.to_s, uuid: part.uuid, fs_type: part.fs_type)
182
+ next
183
+ end
184
+ # Two disks with one file system UUID (e.g. a snapshot taken while
185
+ # a diff had its copy attached): neither can be trusted to be the
186
+ # one the running guest has, so only the first is used.
187
+ unless seen.add?(part.uuid)
188
+ @ctx.ui&.warn(:diff_fs_duplicate, name: name.to_s, uuid: part.uuid)
189
+ next
190
+ end
191
+ root = files.mount(part, "#{side}#{trees.size}", like: fs)
192
+ trees << Tree.new(mountpoint: fs.target, root: root)
193
+ end
194
+ end
195
+ trees
196
+ end
197
+
198
+ # The live file systems; their mount points go into a command, so only
199
+ # those that pass the character check.
200
+ def live_trees(list)
201
+ list.filter_map do |fs|
202
+ unless fs.safe?
203
+ @ctx.ui&.warn(:diff_unsafe_mountpoint, target: FileDiff.escape(fs.target))
204
+ next
205
+ end
206
+ Tree.new(mountpoint: fs.target, root: fs.target)
207
+ end
208
+ end
209
+
210
+ # Live file systems the snapshot does not have at all (e.g. a disk
211
+ # added since): they are not compared, and their mount points stay
212
+ # left out of the tree above them, so they are named. One the
213
+ # snapshot has but could not mount was named already (diff_fs_skipped).
214
+ def only_in_live(live, in_snapshot)
215
+ live.each do |fs|
216
+ next if in_snapshot.include?(fs.uuid)
217
+
218
+ @ctx.ui&.warn(:diff_only_in_live, target: FileDiff.escape(fs.target), name: "")
219
+ end
220
+ end
221
+
222
+ # Only file systems both sides have are compared; with report, the
223
+ # others are named.
224
+ def pair(from, to, report: true)
225
+ common = from.trees.map(&:mountpoint) & to.trees.map(&:mountpoint)
226
+ [from, to].each do |side|
227
+ side.trees.each do |t|
228
+ next if common.include?(t.mountpoint) || !report
229
+
230
+ @ctx.ui&.warn(:"diff_only_in_#{side.label[0]}", target: FileDiff.escape(t.mountpoint),
231
+ name: side.label[1].to_s)
232
+ end
233
+ side.trees = side.trees.select { |t| common.include?(t.mountpoint) }.sort_by(&:mountpoint)
234
+ end
235
+ raise Error.new(:diff_nothing_to_compare) if common.empty?
236
+ end
237
+
238
+ def compare(from, to, filter, checksum, content)
239
+ from_entries, from_errors = list(from, filter.paths)
240
+ to_entries, to_errors = list(to, filter.paths)
241
+ [from_entries, to_entries].each { |entries| entries.select! { |path, _| filter.include?(path) } }
242
+
243
+ candidates = FileDiff.hash_candidates(from_entries, to_entries, checksum: checksum)
244
+ hashes_from = {}
245
+ hashes_to = {}
246
+ unless candidates.empty?
247
+ @ctx.ui&.info(:diff_hashing, count: candidates.size)
248
+ hashes_from = hashes(from, from_entries, candidates)
249
+ hashes_to = hashes(to, to_entries, candidates)
250
+ end
251
+ changes = FileDiff.compare(from_entries, to_entries, hashed: candidates.to_set,
252
+ hashes_from: hashes_from, hashes_to: hashes_to)
253
+ Result.new(changes: changes, mountpoints: from.trees.map(&:mountpoint), read_errors: from_errors + to_errors,
254
+ contents: content && contents(from, to, from_entries, to_entries, changes, content))
255
+ end
256
+
257
+ # What changed in regular files up to limit bytes, while the snapshot
258
+ # copies are still mounted.
259
+ def contents(from, to, from_entries, to_entries, changes, limit)
260
+ result = {}
261
+ wanted = {}
262
+ changes.each do |change|
263
+ next unless CONTENT_KINDS.include?(change.kind)
264
+
265
+ a = change.kind == :added ? nil : from_entries[change.path]
266
+ b = change.kind == :removed ? nil : to_entries[change.path]
267
+ sides = [a, b].compact
268
+ next unless sides.all? { |entry| entry.type == "f" }
269
+
270
+ size = sides.map(&:size).max
271
+ if size > limit
272
+ result[change.path] = { skipped: :too_large, size: size }
273
+ else
274
+ wanted[change.path] = [a, b]
275
+ end
276
+ end
277
+ return result if wanted.empty?
278
+
279
+ @ctx.ui&.info(:diff_fetching_content, count: wanted.size)
280
+ old = fetch(from, wanted.transform_values(&:first).compact, limit)
281
+ new = fetch(to, wanted.transform_values(&:last).compact, limit)
282
+ wanted.each { |path, (a, b)| result[path] = content(path, a && old[path], b && new[path], a, b) }
283
+ result
284
+ end
285
+
286
+ # One file's ContentDiff result from what both sides copied.
287
+ def content(path, old, new, from_entry, to_entry)
288
+ copied = [old, new]
289
+ if (from_entry && old.nil?) || (to_entry && new.nil?)
290
+ { skipped: :unreadable }
291
+ elsif copied.include?(:budget)
292
+ { skipped: :budget }
293
+ elsif (large = copied.grep(GuestFiles::TooLarge).first)
294
+ { skipped: :too_large, size: large.size }
295
+ elsif copied.include?(:binary)
296
+ { skipped: :binary }
297
+ else
298
+ ContentDiff.render(path, old, new)
299
+ end
300
+ end
301
+
302
+ # One copy run per side, all trees together, so the budget counts
303
+ # for the whole side.
304
+ # @param entries [Hash{String => FileDiff::Entry}] full path => entry
305
+ # @return [Hash{String => String, Symbol}] full path => content, or
306
+ # :binary / :budget (GuestFiles#contents)
307
+ def fetch(side, entries, limit)
308
+ raise Api::TaskCancelled, "diff" if @ctx.cancelled?
309
+
310
+ list = entries.to_a
311
+ guest_paths = list.map { |_path, entry| FileDiff.join(FileDiff.normalize(side.trees[entry.tree].root), entry.rel) }
312
+ copied = side.files.contents(guest_paths, @tmpdir, limit: limit, budget: ContentDiff.budget(limit))
313
+ list.each_with_index.to_h { |(path, _entry), i| [path, copied[i]] }.compact
314
+ end
315
+
316
+ def list(side, paths)
317
+ @ctx.ui&.info(:"diff_listing_#{side.label[0]}", name: side.label[1].to_s)
318
+ entries = {}
319
+ errors = 0
320
+ side.trees.each_with_index do |tree, i|
321
+ raise Api::TaskCancelled, "diff" if @ctx.cancelled?
322
+
323
+ starts = starts_for(tree, side.targets, paths)
324
+ next if starts&.empty?
325
+
326
+ local, failed = side.files.listing(tree.root, @tmpdir, starts: starts)
327
+ FileDiff.parse(File.binread(local), tree.mountpoint, i, entries, hidden: nested(tree, side.targets))
328
+ File.delete(local)
329
+ errors += failed
330
+ end
331
+ if errors.positive?
332
+ @ctx.ui&.warn(:"diff_read_errors_#{side.label[0]}", count: errors, name: side.label[1].to_s)
333
+ end
334
+ [entries, errors]
335
+ end
336
+
337
+ # With --path: where find starts in the tree, relative to its root.
338
+ # nil lists the whole tree, [] nothing of it. A path belongs to the
339
+ # tree of the deepest mount point above it. A path that cannot go into
340
+ # a command lists the whole tree; the filter narrows it afterwards.
341
+ def starts_for(tree, targets, paths)
342
+ return nil if paths.empty?
343
+
344
+ mountpoint = tree.mountpoint
345
+ starts = []
346
+ paths.each do |path|
347
+ return nil if FileDiff.below?(mountpoint, path)
348
+ next unless FileDiff.below?(path, mountpoint)
349
+
350
+ owner = (targets + [mountpoint]).select { |t| FileDiff.below?(path, t) }.max_by(&:length)
351
+ next unless owner == mountpoint
352
+
353
+ rel = mountpoint == "/" ? path.delete_prefix("/") : path.delete_prefix("#{mountpoint}/")
354
+ return nil unless SAFE_REL.match?(rel) && rel.split("/").none? { |c| %w[. ..].include?(c) || c.empty? }
355
+
356
+ starts << rel
357
+ end
358
+ starts.uniq
359
+ end
360
+
361
+ # Mount points of the running guest below the tree's own.
362
+ def nested(tree, targets)
363
+ targets.select { |t| t != tree.mountpoint && FileDiff.below?(t, tree.mountpoint) }
364
+ end
365
+
366
+ # @return [Hash{String => String}] full path => SHA-256
367
+ def hashes(side, entries, candidates)
368
+ result = {}
369
+ candidates.group_by { |path| entries[path].tree }.each do |tree, paths|
370
+ raise Api::TaskCancelled, "diff" if @ctx.cancelled?
371
+
372
+ by_rel = paths.to_h { |path| [entries[path].rel, path] }
373
+ side.files.hashes(side.trees[tree].root, by_rel.keys, @tmpdir).each do |rel, hex|
374
+ result[by_rel[rel]] = hex
375
+ end
376
+ end
377
+ result
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module VagrantPlugins
6
+ module Xapi
7
+ module Services
8
+ # Reads format and virtual size straight from a disk image header, so
9
+ # no qemu-img is needed:
10
+ # VHD: 512-byte footer, "conectix", current size at offset 48
11
+ # qcow2: "QFI\xFB" magic, size at offset 24 (8 bytes, big endian)
12
+ # raw: file size
13
+ module DiskImage
14
+ QCOW2_MAGIC = "QFI\xFB".b
15
+ VHD_COOKIE = "conectix".b
16
+ QCOW2_OFFSET_MASK = 0x00ff_ffff_ffff_fe00
17
+ QCOW2_COMPRESSED = 1 << 62
18
+ QCOW2_MAX_L1 = 1 << 20
19
+ # The header of qcow2 version 2; version 3 adds to it.
20
+ QCOW2_HEADER = 72
21
+
22
+ Info = Struct.new(:format, :virtual_size, :file_size, keyword_init: true)
23
+
24
+ module_function
25
+
26
+ # @return [Info]
27
+ def inspect_file(path)
28
+ raise Error.new(:disk_image_missing, path: path) unless File.file?(path)
29
+
30
+ size = File.size(path)
31
+ File.open(path, "rb") do |f|
32
+ head = f.read(512) || "".b
33
+ if head.byteslice(0, 4) == QCOW2_MAGIC
34
+ raise Error.new(:disk_image_truncated, path: path.to_s) if head.bytesize < QCOW2_HEADER
35
+
36
+ check_qcow2!(path, head)
37
+ return Info.new(format: "qcow2", virtual_size: head.byteslice(24, 8).unpack1("Q>"), file_size: size)
38
+ end
39
+
40
+ footer = read_vhd_footer(f, size, head)
41
+ if footer
42
+ return Info.new(format: "vhd", virtual_size: footer.byteslice(48, 8).unpack1("Q>"), file_size: size)
43
+ end
44
+ end
45
+ Info.new(format: "raw", virtual_size: size, file_size: size)
46
+ end
47
+
48
+ # A qcow2 may refer to other files: a backing file (header offset 8,
49
+ # 8 bytes) or, in version 3, an external data file (incompatible
50
+ # feature bit 2 at offset 72). qemu-img would read those files on the
51
+ # local machine and copy them into the image, so such images are refused.
52
+ def check_qcow2!(path, head)
53
+ raise Error.new(:disk_image_backing_file, path: path.to_s) unless head.byteslice(8, 8).unpack1("Q>").zero?
54
+
55
+ version = head.byteslice(4, 4).unpack1("N")
56
+ return unless version >= 3 && head.bytesize >= 80
57
+
58
+ incompatible = head.byteslice(72, 8).unpack1("Q>")
59
+ raise Error.new(:disk_image_data_file, path: path.to_s) unless (incompatible & 0b100).zero?
60
+ end
61
+
62
+ # Why XAPI's qcow2 import (qcow-stream-tool) cannot take this image as
63
+ # it is, or nil. It refuses compressed clusters ("Compressed_unsupported"),
64
+ # which cloud images and many libvirt boxes use, after the whole file
65
+ # was uploaded. Incompatible features other than the dirty bit (zstd
66
+ # compression, extended L2 entries) are not taken either.
67
+ # @return [Symbol, nil] :compressed or :features
68
+ def qcow2_import_obstacle(path)
69
+ File.open(path, "rb") do |f|
70
+ head = f.read(104) || "".b
71
+ return nil unless head.byteslice(0, 4) == QCOW2_MAGIC && head.bytesize >= 48
72
+
73
+ if head.byteslice(4, 4).unpack1("N") >= 3 && head.bytesize >= 80
74
+ return :features unless (head.byteslice(72, 8).unpack1("Q>") & ~0b1).zero?
75
+ end
76
+ cluster_bits = head.byteslice(20, 4).unpack1("N")
77
+ l1_size = head.byteslice(36, 4).unpack1("N")
78
+ return nil if l1_size.zero?
79
+ return :features unless cluster_bits.between?(9, 21) && l1_size <= QCOW2_MAX_L1
80
+
81
+ compressed_clusters?(f, head.byteslice(40, 8).unpack1("Q>"), l1_size, 1 << cluster_bits) ? :compressed : nil
82
+ end
83
+ end
84
+
85
+ # Reads the L2 tables (a few MB even for big images) and stops at the
86
+ # first compressed cluster.
87
+ def compressed_clusters?(file, l1_offset, l1_size, cluster_size)
88
+ file.seek(l1_offset)
89
+ (file.read(8 * l1_size) || "").unpack("Q>*").any? do |l1_entry|
90
+ l2_offset = l1_entry & QCOW2_OFFSET_MASK
91
+ next false if l2_offset.zero?
92
+
93
+ file.seek(l2_offset)
94
+ (file.read(cluster_size) || "").unpack("Q>*").any? { |entry| (entry & QCOW2_COMPRESSED).nonzero? }
95
+ end
96
+ end
97
+
98
+ # The format XAPI's /import_raw_vdi expects for a file.
99
+ def format_of(path)
100
+ inspect_file(path).format
101
+ end
102
+
103
+ def read_vhd_footer(file, size, head)
104
+ if size >= 512
105
+ file.seek(size - 512)
106
+ tail = file.read(512)
107
+ return tail if tail&.byteslice(0, 8) == VHD_COOKIE
108
+
109
+ # Some tools write a 511-byte footer.
110
+ if size >= 511
111
+ file.seek(size - 511)
112
+ tail = file.read(511)
113
+ return tail if tail&.byteslice(0, 8) == VHD_COOKIE
114
+ end
115
+ end
116
+ # Dynamic VHDs carry a copy of the footer at the start.
117
+ head.byteslice(0, 8) == VHD_COOKIE ? head : nil
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end