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,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
|