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,730 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/package"
4
+ require "shellwords"
5
+
6
+ require_relative "base"
7
+ require_relative "file_diff"
8
+
9
+ module VagrantPlugins
10
+ module Xapi
11
+ module Services
12
+ # The guest side of `vagrant xapi diff`, for Linux guests. Talks to the
13
+ # guest through an object that runs commands as root:
14
+ # run(command) -> [exit_status, stdout, stderr]
15
+ # upload(local_path, remote_path)
16
+ # download(remote_path, local_path)
17
+ #
18
+ # Only values this class created or checked go into a command: its
19
+ # temporary directory, kernel device names, file system types, VG names
20
+ # and mount points that pass a strict character check. Paths of files
21
+ # never do; they travel in files. Large output (listings, checksums) is
22
+ # written to files in the guest and downloaded, because Vagrant's SSH
23
+ # communicator rewrites escape sequences in stdout.
24
+ #
25
+ # LVM on a snapshot copy has the identifiers of the running guest's
26
+ # volume groups. #import_lvm gives the copy new ones (vgimportclone) and a
27
+ # VG name of its own, with LVM limited to the copy's devices and without
28
+ # metadata backups, so the running system is never touched.
29
+ class GuestFiles
30
+ # A mounted file system of the running guest.
31
+ Fs = Struct.new(:target, :source, :fs_type, :uuid, :options, keyword_init: true) do
32
+ def safe?
33
+ GuestFiles::SAFE_TARGET.match?(target)
34
+ end
35
+ end
36
+ # A file system on an attached snapshot disk; name is the kernel name
37
+ # (xvdb1, dm-3).
38
+ Part = Struct.new(:name, :fs_type, :uuid, keyword_init: true)
39
+ # What #contents gives for a file that grew past the limit after it
40
+ # was listed: not cut off, but named with its size.
41
+ TooLarge = Struct.new(:size)
42
+ # LVM in the guest: whether commands take --devices (lvm2 2.03.12 and
43
+ # later, which also bypasses a devices file).
44
+ Lvm = Struct.new(:devices_option, keyword_init: true)
45
+
46
+ TOOLS = %w[find findmnt lsblk mount umount sha256sum xargs mktemp realpath].freeze
47
+ # File systems the snapshot side can mount next to the live system
48
+ # (same UUIDs): XFS needs nouuid, ext* and vfat need nothing.
49
+ MOUNTABLE = %w[ext2 ext3 ext4 xfs vfat].freeze
50
+ # Not compared at all: media and images.
51
+ SKIP_FS = %w[iso9660 udf squashfs].freeze
52
+ # Storage that must not appear twice in one guest and that cannot get
53
+ # new identifiers here: LUKS, btrfs device ids, ZFS pools, RAID
54
+ # members, bcache, other device-mapper targets.
55
+ UNSUPPORTED_FS = %w[crypto_LUKS btrfs zfs_member linux_raid_member bcache].freeze
56
+ UNSUPPORTED_TYPE = /\A(?:crypt|dm|mpath|raid\d*|md)\z/.freeze
57
+ LVM_FS = "LVM2_member"
58
+ LVM_TOOLS = %w[lvm pvs vgchange vgimportclone dmsetup].freeze
59
+ LVM_DEVICES_SINCE = Gem::Version.new("2.03.12")
60
+ # VG names of the copies: <prefix><run id>_<side><n>. Device-mapper
61
+ # devices with this prefix belong to a diff run.
62
+ VG_PREFIX = "vagrant_xapi_diff_"
63
+ OUR_VG = /\Avagrant_xapi_diff_\h{16}_[ab]\d+\z/.freeze
64
+ VG_NAME = /\A[A-Za-z0-9+_.-]+\z/.freeze
65
+ NO_BACKUP = "backup { backup = 0 archive = 0 }"
66
+
67
+ SAFE_TARGET = %r{\A/[A-Za-z0-9._@+=,:~%/-]*\z}.freeze
68
+ NAME = /\A[a-z][a-z0-9-]*\z/.freeze
69
+ UUID = /\A[0-9A-Za-z-]+\z/.freeze
70
+ FS_TYPE = /\A[A-Za-z0-9_]+\z/.freeze
71
+ # vfat stores no owners or permissions, and the names it shows depend
72
+ # on the mount options (character set, code page, short names). These
73
+ # come over from the live mount; they are values from the guest, so
74
+ # checked strictly before they go into a command.
75
+ VFAT_OPTION = /\A(?:(?:uid|gid)=\d{1,10}|(?:umask|fmask|dmask)=[0-7]{1,4}|codepage=\d{1,4}|
76
+ iocharset=[A-Za-z0-9_-]{1,32}|shortname=(?:lower|win95|winnt|mixed)|
77
+ utf8(?:=(?:0|1|yes|no|true|false))?|uni_xlate)\z/x.freeze
78
+ # The udev rule of a run (#shield_live_devices); in /run, so a reboot
79
+ # removes it at the latest.
80
+ UDEV_RULE = "/run/udev/rules.d/99-vagrant-xapi-diff.rules"
81
+ TMP_PREFIX = "/tmp/vagrant-xapi-diff."
82
+ TMP = %r{\A/tmp/vagrant-xapi-diff\.[A-Za-z0-9]{10}\z}.freeze
83
+ # A snapshot mount of a run: <temporary directory>/a0, /b1, ...
84
+ # The last field of every listing: one cut off by a full /tmp lacks it,
85
+ # also when the cut falls between two records.
86
+ LISTING_END = "vagrant-xapi-listing-end"
87
+ RUN_MOUNT = %r{\A(/tmp/vagrant-xapi-diff\.[A-Za-z0-9]{10})/[ab]\d+\z}.freeze
88
+ DEVICE_WAIT = 30
89
+ # The hashing step of #hashes, for `xargs -0 sh -c '...' out <path>...`.
90
+ # The filter of #hashes, for `xargs -0 sh -c '...' sh <path>...`:
91
+ # passes on, NUL-separated, only the paths that are still a regular
92
+ # file and not a symlink, right before sha256sum reads them in one
93
+ # batch. A path that has become a symlink, a FIFO or a device since it
94
+ # was listed is skipped, never followed or waited on. Only shell
95
+ # builtins per path: with --checksum every file of both sides is
96
+ # hashed. (A path that changes in the milliseconds between this check
97
+ # and the read can still make sha256sum wait on a FIFO; Ctrl-C stops
98
+ # the run.) No single quote in here: the script goes into the command
99
+ # between single quotes.
100
+ REGULAR = %(for f; do if [ -f "$f" ] && [ ! -h "$f" ]; then printf "%s\\0" "$f"; fi; done)
101
+ # The copy step of #contents, for `xargs -0 sh -c '...' dir limit
102
+ # budget <index><path>...`. xargs may start it more than once, so the
103
+ # bytes used so far live in dir.used. It reads one byte more than the
104
+ # limit: a file that grew past it since it was listed is not cut off
105
+ # but marked, with its size. No single quote in here: the script goes
106
+ # into the command between single quotes.
107
+ COPY = <<~'SH'
108
+ d=$0 limit=$1 budget=$2
109
+ shift 2
110
+ used=$(cat "$d.used")
111
+ for a; do
112
+ i=${a%%/*} f=/${a#*/}
113
+ [ -f "$f" ] && [ ! -h "$f" ] || continue
114
+ n=$(head -c 8000 -- "$f" | wc -c) t=$(head -c 8000 -- "$f" | tr -d \\000 | wc -c)
115
+ if [ $((n - t)) -ne 0 ]; then : > "$d/$i.binary"; continue; fi
116
+ head -c "$((limit + 1))" -- "$f" > "$d/$i" || { rm -f "$d/$i"; continue; }
117
+ n=$(wc -c < "$d/$i")
118
+ if [ "$n" -gt "$limit" ]; then rm -f "$d/$i"; wc -c < "$f" > "$d/$i.large"; continue; fi
119
+ if [ $((used + n)) -gt "$budget" ]; then rm -f "$d/$i"; : > "$d/$i.budget"; continue; fi
120
+ used=$((used + n))
121
+ done
122
+ echo "$used" > "$d.used"
123
+ SH
124
+
125
+ attr_reader :tmp, :mount_targets
126
+
127
+ def initialize(guest, ctx)
128
+ @guest = guest
129
+ @ctx = ctx
130
+ @mounts = []
131
+ @files = 0
132
+ @mount_targets = []
133
+ @imported = []
134
+ end
135
+
136
+ # Checks the tools and creates the temporary directory. It is owned by
137
+ # the SSH user, so that upload and download work; that user can sudo
138
+ # anyway, and nobody else can enter it (0700).
139
+ def prepare!
140
+ checks = TOOLS.map { |t| "command -v #{t} >/dev/null 2>&1 || { echo missing:#{t}; exit 3; }" }
141
+ status, out, err = @guest.run([
142
+ *checks,
143
+ "find / -maxdepth 0 -printf '' >/dev/null 2>&1 || { echo 'missing:GNU find'; exit 3; }",
144
+ "d=$(mktemp -d /tmp/vagrant-xapi-diff.XXXXXXXXXX) || exit 4",
145
+ 'chown "${SUDO_UID:-0}" "$d" && chmod 0700 "$d" && echo "dir:$d"'
146
+ ].join("\n"))
147
+ tool = out.to_s[/^missing:(.+)$/, 1]
148
+ raise Error.new(:diff_guest_tool_missing, tool: FileDiff.escape(tool.strip)) if tool
149
+
150
+ dir = out.to_s[/^dir:(.+)$/, 1].to_s.strip
151
+ raise Error.new(:diff_guest_failed, step: "mktemp", detail: detail(err)) unless status.zero? && TMP.match?(dir)
152
+
153
+ @tmp = dir
154
+ end
155
+
156
+ # Mounted block-device file systems of the running guest, without
157
+ # bind mounts, loop devices, media and the snapshot mounts of runs.
158
+ # Also remembers every mount point (mount_targets): what is below one
159
+ # is hidden in the running guest.
160
+ # @return [Array<Fs>]
161
+ def layout
162
+ out = run!("findmnt", "findmnt -rn -o TARGET,SOURCE,FSTYPE,UUID,OPTIONS")
163
+ mounts = out.lines.filter_map do |line|
164
+ target, source, fs_type, uuid, options = line.chomp.split(/ /, -1).map { |f| unescape(f) }
165
+ next if target.to_s.empty?
166
+
167
+ [FileDiff.normalize(target), source.to_s, fs_type.to_s, uuid.to_s, options.to_s]
168
+ end
169
+ @mount_targets = mounts.map(&:first)
170
+ # Two mounts on one mount point: only the one mounted last (findmnt
171
+ # lists it later) is visible, also when it is a tmpfs, a bind or a
172
+ # loop mount; the one below is hidden in the running guest, so it is
173
+ # not compared either.
174
+ visible = mounts.reverse.uniq(&:first).reverse
175
+ list = visible.filter_map do |target, source, fs_type, uuid, options|
176
+ next if target.start_with?(TMP_PREFIX)
177
+ next unless source.start_with?("/dev/") && !source.include?("[")
178
+ next if source.start_with?("/dev/loop") || SKIP_FS.include?(fs_type)
179
+
180
+ Fs.new(target: target, source: source, fs_type: fs_type, uuid: uuid, options: options)
181
+ end
182
+ # The same device mounted twice: keep the shortest mount point.
183
+ list.sort_by { |fs| [fs.target.length, fs.target] }.uniq(&:source)
184
+ end
185
+
186
+ # Removes what interrupted runs left in the guest: their snapshot
187
+ # mounts (seen by #layout), the device-mapper devices of their LVM
188
+ # copies, and their temporary directories, also those without a mount
189
+ # left. A directory only counts as a run's when it is a real directory
190
+ # (not a symlink) owned by root or the SSH user, as #prepare! makes
191
+ # it; what another user of the guest made under that name is left
192
+ # alone, with its mounts. /tmp is sticky, so once the check passed no
193
+ # other user can rename or replace the directory. Only under the diff
194
+ # lock of the VM, so a run in progress is never touched. Never raises.
195
+ def remove_stale!
196
+ stale = @mount_targets.select { |t| RUN_MOUNT.match?(t) && !t.start_with?("#{@tmp}/") }
197
+ by_dir = stale.group_by { |t| t[RUN_MOUNT, 1] }
198
+ safely do
199
+ @guest.run([
200
+ "u=${SUDO_UID:-0}",
201
+ 'ours() { [ -n "$(find "$1" -maxdepth 0 -type d \( -uid 0 -o -uid "$u" \) -print 2>/dev/null)" ]; }',
202
+ *by_dir.map { |dir, targets| "if ours #{dir}; then #{targets.map { |t| unmount(t) }.join("; ")}; fi" },
203
+ "if command -v dmsetup >/dev/null 2>&1; then",
204
+ " for d in $(dmsetup ls 2>/dev/null | awk '{print $1}' | grep -E '^#{VG_PREFIX}[0-9a-f]{16}_[ab][0-9]+-'); do",
205
+ " dmsetup remove --deferred \"$d\"",
206
+ " done",
207
+ "fi",
208
+ "for d in #{TMP_PREFIX}??????????; do",
209
+ " case ${d#\"#{TMP_PREFIX}\"} in *[!A-Za-z0-9]*) continue ;; esac",
210
+ " [ \"$d\" = \"#{@tmp}\" ] && continue",
211
+ " ours \"$d\" && { #{remove_tmp('"$d"')}; }",
212
+ "done",
213
+ # The udev rule of a killed run (only under the lock, so of none
214
+ # that is running). Devices it covered keep what udev gave them.
215
+ "if [ -e #{UDEV_RULE} ]; then rm -f #{UDEV_RULE}; udevadm control --reload 2>/dev/null; fi",
216
+ "true"
217
+ ].join("\n"))
218
+ end
219
+ end
220
+
221
+ # Refuses storage that would appear twice once a snapshot of it is
222
+ # attached and cannot get new identifiers (checked before anything is
223
+ # attached). With LVM, checks the LVM tools early. Then keeps udev and
224
+ # systemd of the running guest on its own devices (#shield_live_devices).
225
+ def check_storage!
226
+ rows = parse_rows(run!("lsblk", "lsblk -rn -o KNAME,TYPE,FSTYPE"))
227
+ kinds = unsupported(rows)
228
+ raise Error.new(:diff_storage_unsupported, kinds: kinds.join(", ")) unless kinds.empty?
229
+
230
+ lvm! if rows.any? { |_name, type, fs_type| fs_type == LVM_FS || type == "lvm" }
231
+ shield_live_devices
232
+ end
233
+
234
+ # A copy has the file system UUIDs (and GPT partition UUIDs) of the
235
+ # running guest's disks. udev could then point /dev/disk/by-uuid/...
236
+ # at the copy, and systemd tie the device unit of that name to it. A
237
+ # mount from fstab by UUID= (/boot, /boot/efi, /home) is bound to its
238
+ # device unit (BindsTo=, or StopPropagatedFrom= in newer systemd), so
239
+ # it could be unmounted when the copy goes away. A temporary udev rule
240
+ # gives every new block device with one of those identifiers
241
+ # SYSTEMD_READY=0, so systemd ignores it, and a low link priority, so
242
+ # the links stay on the guest's own device. The copies are mounted
243
+ # by their kernel name, and systemd does not bind a mount it only
244
+ # learns from /proc/self/mountinfo to its device. Values from lsblk
245
+ # only go into the rule after a strict check. Best effort: without
246
+ # udevadm nothing is done, and a failure is reported but does not
247
+ # stop the run. #cleanup removes the rule after the copies are gone.
248
+ def shield_live_devices
249
+ ids = Hash.new { |h, k| h[k] = [] }
250
+ parse_rows(run!("lsblk", "lsblk -rn -o KNAME,UUID,PARTUUID")).each do |kname, uuid, partuuid|
251
+ next unless NAME.match?(kname.to_s)
252
+
253
+ ids[["ID_FS_UUID", uuid]] << kname if UUID.match?(uuid.to_s)
254
+ ids[["ID_PART_ENTRY_UUID", partuuid]] << kname if UUID.match?(partuuid.to_s)
255
+ end
256
+ return if ids.empty?
257
+
258
+ rules = ids.map do |(key, value), knames|
259
+ %(SUBSYSTEM=="block", ENV{#{key}}=="#{value}", KERNEL!="#{knames.uniq.join("|")}", ) +
260
+ %(ENV{SYSTEMD_READY}="0", OPTIONS+="link_priority=-100")
261
+ end
262
+ status, out, err = @guest.run([
263
+ "if ! command -v udevadm >/dev/null 2>&1 || [ ! -d #{File.dirname(File.dirname(UDEV_RULE))} ]; then exit 0; fi",
264
+ "mkdir -p #{File.dirname(UDEV_RULE)} || exit 4",
265
+ "printf '%s\\n' #{rules.map { |rule| "'#{rule}'" }.join(" ")} > #{UDEV_RULE} || exit 4",
266
+ # As soon as the file is there, so that #cleanup removes it also
267
+ # when the reload fails.
268
+ "echo written",
269
+ "udevadm control --reload || exit 4"
270
+ ].join("\n"))
271
+ @shielded = true if out.to_s.include?("written")
272
+ @ctx.ui&.warn(:diff_shield_failed, detail: detail(err)) unless status.zero?
273
+ end
274
+
275
+ # Waits for an attached disk to appear and checks that its size
276
+ # matches the VDI. XAPI may name a disk of an HVM guest hdX or sdX,
277
+ # while Linux's blkfront shows it as xvdX, so both names are tried.
278
+ # @return [String] the kernel name of the disk
279
+ def probe(device, size)
280
+ names = [device.to_s, "xvd#{device.to_s.sub(/\A(?:xvd|sd|hd|vd)/, "")}"].uniq
281
+ raise Error.new(:diff_device_unknown, device: device.to_s) unless names.all? { |n| NAME.match?(n) }
282
+
283
+ status, out, err = @guest.run(<<~SH)
284
+ dev=
285
+ i=0
286
+ while [ -z "$dev" ] && [ $i -lt #{DEVICE_WAIT} ]; do
287
+ for d in #{names.join(" ")}; do [ -b /dev/$d ] && { dev=$d; break; }; done
288
+ [ -n "$dev" ] || { sleep 1; i=$((i+1)); }
289
+ done
290
+ [ -n "$dev" ] || { echo missing; exit 4; }
291
+ udevadm settle >/dev/null 2>&1 || sleep 2
292
+ echo "dev:$dev"
293
+ SH
294
+ raise Error.new(:diff_device_missing, device: names.join(", ")) if status == 4
295
+ raise Error.new(:diff_guest_failed, step: "probe", detail: detail(err)) unless status.zero?
296
+
297
+ found = out.to_s[/^dev:(\S+)$/, 1].to_s
298
+ raise Error.new(:diff_device_missing, device: names.join(", ")) unless names.include?(found)
299
+
300
+ rows = rows(found)
301
+ disk = rows.find { |r| r[1] == "disk" }
302
+ unless disk && disk[4].to_i == size.to_i
303
+ raise Error.new(:diff_device_mismatch, device: found, expected: size.to_i, actual: disk ? disk[4].to_i : 0)
304
+ end
305
+ # The live system was checked before attaching, but the snapshot may
306
+ # be older than a change of the disk layout.
307
+ kinds = unsupported(rows)
308
+ raise Error.new(:diff_snapshot_storage_unsupported, kinds: kinds.join(", ")) unless kinds.empty?
309
+
310
+ found
311
+ end
312
+
313
+ # Gives the LVM volume groups on the attached copies new identifiers
314
+ # and a name of their own, and activates them, so that their logical
315
+ # volumes can be mounted next to the running guest's. All PVs of a VG
316
+ # are imported together (a VG may span disks). LVM only sees the
317
+ # copies' devices (--devices, and a filter) and writes no metadata
318
+ # backups.
319
+ # @param devices [Array<String>] kernel names of the attached disks
320
+ # @param base [String] VG name prefix for this side: <VG_PREFIX><run>_<side>
321
+ # @return [Array<String>] the new VG names
322
+ def import_lvm(devices, base)
323
+ by_disk = devices.to_h { |d| [d, rows(d)] }
324
+ pvs = by_disk.values.flatten(1).filter_map { |kname, _type, fs_type| kname if fs_type == LVM_FS }.uniq
325
+ return [] if pvs.empty?
326
+ raise Error.new(:diff_guest_failed, step: "lsblk", detail: "unexpected device name") unless pvs.all? { |p| NAME.match?(p) }
327
+
328
+ # udev may have activated a copy when it appeared (when the running
329
+ # guest has no VG of that name). It must be inactive to be imported.
330
+ # Only device-mapper devices on top of the copies are removed, so
331
+ # never one of the running guest.
332
+ active = by_disk.values.flatten(1).filter_map { |kname, type| kname if type == "lvm" && NAME.match?(kname.to_s) }
333
+ run!("dmsetup", active.reverse.uniq.map { |k| "dmsetup remove /dev/#{k}" }.join(" && ")) unless active.empty?
334
+
335
+ groups = volume_groups(pvs)
336
+ names = groups.each_with_index.map do |(_vg, members), i|
337
+ name = "#{base}#{i}"
338
+ raise Error.new(:diff_guest_failed, step: "vgimportclone", detail: "bad VG name") unless OUR_VG.match?(name)
339
+
340
+ run!("vgimportclone", "vgimportclone #{scope(members, NO_BACKUP)} --basevgname #{name} #{paths(members)}")
341
+ @imported << [name, members]
342
+ run!("vgchange", "vgchange -ay #{scope(members, "#{NO_BACKUP} activation { volume_list = [ \"#{name}\" ] }")} #{name}")
343
+ name
344
+ end
345
+ @guest.run("udevadm settle >/dev/null 2>&1 || sleep 2")
346
+ names
347
+ end
348
+
349
+ # File systems on an attached disk, including the logical volumes of
350
+ # an imported copy.
351
+ # @return [Array<Part>]
352
+ def partitions(device)
353
+ rows = rows(device)
354
+ kinds = unsupported(rows)
355
+ raise Error.new(:diff_snapshot_storage_unsupported, kinds: kinds.join(", ")) unless kinds.empty?
356
+
357
+ rows.filter_map do |name, type, fs_type, uuid, _size|
358
+ next unless %w[disk part lvm].include?(type)
359
+ next if fs_type == LVM_FS
360
+ next unless NAME.match?(name.to_s) && FS_TYPE.match?(fs_type.to_s) && UUID.match?(uuid.to_s)
361
+
362
+ Part.new(name: name, fs_type: fs_type, uuid: uuid)
363
+ end.uniq(&:name)
364
+ end
365
+
366
+ # Deactivates the imported copies, so that their disks can be
367
+ # detached. Device-mapper devices that are still open (a busy unmount)
368
+ # are removed as soon as they are closed. Never raises.
369
+ def deactivate_lvm
370
+ @imported.reverse_each do |name, members|
371
+ status, = safely { @guest.run("vgchange -an #{scope(members, NO_BACKUP)} #{name}") }
372
+ next if status&.zero?
373
+
374
+ @ctx.ui&.warn(:diff_lvm_busy, name: name)
375
+ safely do
376
+ @guest.run("for d in $(dmsetup ls | awk '{print $1}' | grep '^#{name}-'); do " \
377
+ "dmsetup remove --deferred \"$d\"; done; true")
378
+ end
379
+ end
380
+ @imported.clear
381
+ end
382
+
383
+ # Mounts a snapshot file system read-only below the temporary
384
+ # directory, without setuid, device nodes or execution: it is only
385
+ # read. vfat gets the owner and mask options of the live mount it is
386
+ # compared with (`like`), so modes and owners match.
387
+ # @return [String] the mount point
388
+ def mount(part, name, like: nil)
389
+ raise Error.new(:diff_mount_failed, part: part.name.to_s, detail: "unsupported") unless mountable?(part)
390
+
391
+ dir = "#{@tmp}/#{name}"
392
+ opts = %w[ro nosuid nodev noexec]
393
+ opts << "nouuid" if part.fs_type == "xfs"
394
+ opts.concat(vfat_options(like)) if part.fs_type == "vfat"
395
+ status, _out, err = @guest.run("mkdir -p #{dir} && mount -t #{part.fs_type} -o #{opts.join(",")} " \
396
+ "/dev/#{part.name} #{dir}")
397
+ raise Error.new(:diff_mount_failed, part: part.name, detail: detail(err)) unless status.zero?
398
+
399
+ @mounts << dir
400
+ dir
401
+ end
402
+
403
+ def mountable?(part)
404
+ MOUNTABLE.include?(part.fs_type) && NAME.match?(part.name.to_s)
405
+ end
406
+
407
+ def vfat_options(fs)
408
+ (fs&.options).to_s.split(",").select { |option| VFAT_OPTION.match?(option) }
409
+ end
410
+
411
+ # Lists one file system below root (-xdev) and downloads the listing.
412
+ # @param starts [Array<String>, nil] paths relative to root to list
413
+ # (from --path, checked by the caller); nil lists everything
414
+ # @return [Array(String, Integer)] local file, number of paths find
415
+ # could not read
416
+ def listing(root, local_dir, starts: nil)
417
+ remote, local = names(local_dir, "list")
418
+ # C locale: find's own messages (write error) are in English, and
419
+ # the times always have a dot.
420
+ find = "{ LC_ALL=C find \"$@\" -xdev -printf '#{FileDiff::PRINTF}' 2> #{remote}.err || rc=$?; " \
421
+ "printf '%s\\0' #{LISTING_END}; } > #{remote}"
422
+ select = if starts.nil?
423
+ ["set -- .", find]
424
+ else
425
+ # Only what exists: a --path may be missing on one side.
426
+ # The kernel follows a symlink in the directories above
427
+ # a start, and in a snapshot an absolute one points into
428
+ # the running system. So when a start's directory is not
429
+ # where its path says (realpath), the whole tree is
430
+ # listed and the filter narrows it, as without --path:
431
+ # nothing below a symlink matches. A start that is a
432
+ # symlink itself is listed as the link (find -P).
433
+ quoted = starts.map { |s| Shellwords.escape("./#{s}") }.join(" ")
434
+ ["set --", "p=$(pwd -P) whole=",
435
+ "for s in #{quoted}; do",
436
+ " d=${s%/*}",
437
+ " if [ \"$d\" != . ] && { [ -e \"$d\" ] || [ -h \"$d\" ]; } &&",
438
+ " [ \"$(realpath -e -- \"$d\" 2>/dev/null)\" != \"${p%/}/${d#./}\" ]; then whole=1; fi",
439
+ " { [ -e \"$s\" ] || [ -h \"$s\" ]; } && set -- \"$@\" \"$s\"",
440
+ "done",
441
+ "if [ -n \"$whole\" ]; then set -- .; fi",
442
+ "if [ $# -gt 0 ]; then #{find}; " \
443
+ "else printf '%s\\0' #{LISTING_END} > #{remote}; : > #{remote}.err; fi"]
444
+ end
445
+ status, out, err = @guest.run([
446
+ "cd #{Shellwords.escape(root)} || exit 5", "rc=0", *select,
447
+ # find's own message, not a path in a read error: those are
448
+ # quoted ("find: './write error': Permission denied").
449
+ "echo errors:$(wc -l < #{remote}.err) status:$rc writes:$(grep -c '^find: write error' #{remote}.err); " \
450
+ "chmod 0644 #{remote}"
451
+ ].join("\n"))
452
+ raise Error.new(:diff_guest_failed, step: "find", detail: detail(err)) unless status.zero?
453
+
454
+ errors = out.to_s[/errors:\s*(\d+)/, 1].to_i
455
+ # find fails when a path cannot be read, and names it on stderr. A
456
+ # failure without a name is one of the listing itself, e.g. when the
457
+ # guest's /tmp is full: the listing may be cut off at any point.
458
+ raise Error.new(:diff_listing_invalid) if out.to_s[/status:(\d+)/, 1].to_i.positive? && errors.zero?
459
+ # Also when find named unreadable paths as well: it could not write.
460
+ raise Error.new(:diff_listing_invalid) if out.to_s[/writes:(\d+)/, 1].to_i.positive?
461
+
462
+ @guest.download(remote, local)
463
+ strip_listing_end!(local)
464
+ [local, errors]
465
+ end
466
+
467
+ # SHA-256 of files below root (see REGULAR). @param rels [Array<String>]
468
+ # paths relative to root @return [Hash{String => String}] rel => hex
469
+ # digest; files that could not be read or are no longer regular files
470
+ # are missing
471
+ def hashes(root, rels, local_dir)
472
+ return {} if rels.empty?
473
+
474
+ remote, local = names(local_dir, "sums")
475
+ File.binwrite("#{local}.in", rels.map { |rel| "#{FileDiff.join(FileDiff.normalize(root), rel)}\0" }.join)
476
+ @guest.upload("#{local}.in", "#{remote}.in")
477
+ # sha256sum -z (coreutils 8.30 and later) ends each line with NUL and
478
+ # does not escape names; older ones escape a name with a backslash
479
+ # or newline and start its line with a backslash.
480
+ status, out, err = @guest.run(
481
+ "if sha256sum -z </dev/null >/dev/null 2>&1; then z=-z; else z=; fi; echo \"format:${z:-lines}\"; " \
482
+ "xargs -0 -r sh -c '#{REGULAR}' sh < #{remote}.in | xargs -0 -r sha256sum $z -- > #{remote} 2>/dev/null; " \
483
+ "chmod 0644 #{remote}"
484
+ )
485
+ raise Error.new(:diff_guest_failed, step: "sha256sum", detail: detail(err)) unless status.zero?
486
+
487
+ @guest.download(remote, local)
488
+ data = File.binread(local)
489
+ by_path = out.to_s.include?("format:-z") ? parse_sums_z(data) : parse_sums(data)
490
+ prefix = FileDiff.normalize(root)
491
+ rels.each_with_object({}) do |rel, h|
492
+ hex = by_path[FileDiff.join(prefix, rel)]
493
+ h[rel] = hex if hex
494
+ end
495
+ end
496
+
497
+ # Copies files (absolute paths in the guest) as root into the
498
+ # temporary directory, at most limit bytes each and budget bytes in
499
+ # all, packs them into one tar archive and downloads it. The paths
500
+ # travel in a file and reach the shell only as arguments (xargs),
501
+ # never in the command line. Only regular files are copied, no
502
+ # symlinks; a binary file (a NUL byte in its first 8000 bytes, as
503
+ # ContentDiff.binary?) is only marked, not copied. The copies are
504
+ # removed again as soon as the archive is made, also when that fails.
505
+ # @return [Array<String, Symbol, TooLarge, nil>] per path its content,
506
+ # :binary, :budget when it no longer fitted, TooLarge when it grew
507
+ # past limit since it was listed, or nil when it was not copied
508
+ # (gone, not a regular file, or unreadable)
509
+ def contents(paths, local_dir, limit:, budget:)
510
+ return [] if paths.empty?
511
+
512
+ remote, local = names(local_dir, "content")
513
+ # "<index><path>": a path is absolute, so the first slash ends the
514
+ # index, and an xargs batch can never split index from path.
515
+ File.binwrite("#{local}.in", paths.each_with_index.map { |path, i| "#{i}#{path}\0" }.join)
516
+ @guest.upload("#{local}.in", "#{remote}.in")
517
+ status, out, err = @guest.run([
518
+ "command -v tar >/dev/null 2>&1 || { echo missing:tar; exit 3; }",
519
+ "mkdir #{remote}.d && echo 0 > #{remote}.d.used || exit 4",
520
+ "xargs -0 -r sh -c '#{COPY}' #{remote}.d #{Integer(limit)} #{Integer(budget)} < #{remote}.in",
521
+ "tar -C #{remote}.d -cf #{remote}.tar .; rc=$?",
522
+ "rm -rf #{remote}.d #{remote}.d.used",
523
+ "[ $rc -eq 0 ] && chmod 0644 #{remote}.tar"
524
+ ].join("\n"))
525
+ raise Error.new(:diff_guest_tool_missing, tool: "tar") if out.to_s.include?("missing:tar")
526
+ raise Error.new(:diff_guest_failed, step: "copy", detail: detail(err)) unless status.zero?
527
+
528
+ @guest.download("#{remote}.tar", "#{local}.tar")
529
+ by_index = read_tar("#{local}.tar")
530
+ paths.each_index.map do |i|
531
+ copied = by_index[i]
532
+ # It was more than limit when copied, whatever wc said after.
533
+ copied = TooLarge.new([copied.size.to_i, limit + 1].max) if copied.is_a?(TooLarge)
534
+ copied
535
+ end
536
+ end
537
+
538
+ # Unmounts everything this object mounted. Never raises.
539
+ def unmount_all
540
+ @mounts.reverse_each do |dir|
541
+ status, _out, err = safely { @guest.run(unmount(dir)) }
542
+ next if status&.zero?
543
+
544
+ @ctx.ui&.warn(:diff_umount_failed, dir: dir, detail: detail(err))
545
+ end
546
+ @mounts.clear
547
+ end
548
+
549
+ # Removes the temporary directory: the copies of #contents (never a
550
+ # mount point), plain files and empty directories, so nothing below a
551
+ # mount that is still there. Never raises.
552
+ def cleanup
553
+ if @shielded
554
+ safely { @guest.run("rm -f #{UDEV_RULE} && udevadm control --reload") }
555
+ @shielded = false
556
+ end
557
+ return unless @tmp
558
+
559
+ safely { @guest.run("#{remove_tmp(@tmp)}; true") }
560
+ @tmp = nil
561
+ end
562
+
563
+ private
564
+
565
+ # LVM tools and version. @return [Lvm]
566
+ def lvm!
567
+ return @lvm if @lvm
568
+
569
+ checks = LVM_TOOLS.map { |t| "command -v #{t} >/dev/null 2>&1 || { echo missing:#{t}; exit 3; }" }
570
+ status, out, err = @guest.run([*checks, "lvm version"].join("\n"))
571
+ tool = out.to_s[/^missing:(.+)$/, 1]
572
+ raise Error.new(:diff_guest_tool_missing, tool: "#{FileDiff.escape(tool.strip)} (lvm2)") if tool
573
+ raise Error.new(:diff_guest_failed, step: "lvm version", detail: detail(err)) unless status.zero?
574
+
575
+ version = out.to_s[/LVM version:\s*(\d+\.\d+\.\d+)/, 1]
576
+ raise Error.new(:diff_guest_failed, step: "lvm version", detail: detail(out)) unless version
577
+
578
+ @lvm = Lvm.new(devices_option: Gem::Version.new(version) >= LVM_DEVICES_SINCE)
579
+ end
580
+
581
+ # VG of each PV, as LVM sees the copies. PVs without VG are left out.
582
+ # @return [Hash{String => Array<String>}] VG name => PV kernel names
583
+ def volume_groups(pvs)
584
+ out = run!("pvs", "pvs #{scope(pvs)} --noheadings --separator '|' -o pv_name,vg_name #{paths(pvs)}")
585
+ out.lines.each_with_object({}) do |line, groups|
586
+ pv, vg = line.strip.split("|", 2).map(&:to_s)
587
+ pv = pv.delete_prefix("/dev/")
588
+ next if vg.empty? || !pvs.include?(pv)
589
+ raise Error.new(:diff_guest_failed, step: "pvs", detail: FileDiff.escape(vg)) unless VG_NAME.match?(vg)
590
+
591
+ (groups[vg] ||= []) << pv
592
+ end
593
+ end
594
+
595
+ # LVM options that limit a command to these devices: --devices where
596
+ # lvm2 has it (it also bypasses a devices file), and always a filter
597
+ # (a restrictive global_filter in lvm.conf would hide the copies).
598
+ def scope(pvs, config = nil)
599
+ filter = "[ #{pvs.map { |p| "\"a|^/dev/#{p}$|\"" }.join(", ")}, \"r|.*|\" ]"
600
+ devices = lvm!.devices_option ? pvs.map { |p| "--devices /dev/#{p}" } : []
601
+ [*devices, "--config 'devices { filter = #{filter} global_filter = #{filter} } #{config}'"].join(" ")
602
+ end
603
+
604
+ def paths(pvs)
605
+ pvs.map { |p| "/dev/#{p}" }.join(" ")
606
+ end
607
+
608
+ # lsblk of one device and what is on it (partitions, LVs).
609
+ # @return [Array<Array(String, String, String, String, String)>]
610
+ # kernel name, type, file system type, UUID, size
611
+ def rows(device)
612
+ parse_rows(run!("lsblk", "lsblk -rnb -o KNAME,TYPE,FSTYPE,UUID,SIZE /dev/#{device}"))
613
+ end
614
+
615
+ def parse_rows(out)
616
+ out.lines.map { |line| line.chomp.split(/ /, -1).map { |f| unescape(f) } }
617
+ end
618
+
619
+ def unsupported(rows)
620
+ rows.filter_map do |_name, type, fs_type|
621
+ next if %w[rom loop].include?(type)
622
+
623
+ if UNSUPPORTED_FS.include?(fs_type)
624
+ fs_type
625
+ elsif UNSUPPORTED_TYPE.match?(type.to_s)
626
+ type
627
+ end
628
+ end.uniq
629
+ end
630
+
631
+ def run!(step, command)
632
+ status, out, err = @guest.run(command)
633
+ raise Error.new(:diff_guest_failed, step: step, detail: detail(err)) unless status.zero?
634
+
635
+ out.to_s
636
+ end
637
+
638
+ def names(local_dir, kind)
639
+ @files += 1
640
+ ["#{@tmp}/#{kind}-#{@files}", File.join(local_dir, "#{kind}-#{@files}")]
641
+ end
642
+
643
+ # Unmounts, lazily when the file system is busy.
644
+ def unmount(dir)
645
+ "umount #{dir} || umount -l #{dir}"
646
+ end
647
+
648
+ # Checks and removes the end field of a downloaded listing.
649
+ def strip_listing_end!(local)
650
+ tail = "#{LISTING_END}\0".b
651
+ size = File.size(local)
652
+ found = File.open(local, "rb") do |f|
653
+ f.seek([size - tail.bytesize, 0].max)
654
+ f.read.to_s.b
655
+ end
656
+ raise Error.new(:diff_listing_invalid) unless size >= tail.bytesize && found == tail
657
+
658
+ File.truncate(local, size - tail.bytesize)
659
+ end
660
+
661
+ def remove_tmp(dir)
662
+ "rm -rf #{dir}/content-*.d; find #{dir} -maxdepth 1 -type f -delete; rmdir #{dir}/* #{dir} 2>/dev/null"
663
+ end
664
+
665
+ # index => content of the files in a tar archive of #contents, or
666
+ # :binary / :budget for a marker, TooLarge for a file that grew past
667
+ # the limit (the marker holds its size).
668
+ def read_tar(file)
669
+ File.open(file, "rb") do |io|
670
+ Gem::Package::TarReader.new(io).each_with_object({}) do |entry, by_index|
671
+ m = /\A(\d+)(?:\.(binary|budget|large))?\z/.match(File.basename(entry.full_name))
672
+ next unless entry.file? && m
673
+
674
+ data = (entry.read || "").b
675
+ by_index[m[1].to_i] = case m[2]
676
+ when nil then data
677
+ when "large" then TooLarge.new(data[/\d+/].to_i)
678
+ else m[2].to_sym
679
+ end
680
+ end
681
+ end
682
+ end
683
+
684
+ # sha256sum -z: "<hex> <name>\0" per file, names as they are.
685
+ def parse_sums_z(data)
686
+ data.b.split("\0").each_with_object({}) do |entry, h|
687
+ hex = entry[0, 64]
688
+ next unless hex.match?(/\A\h{64}\z/) && entry[64] == " " && [" ", "*"].include?(entry[65])
689
+
690
+ h[entry[66..].to_s] = hex.downcase
691
+ end
692
+ end
693
+
694
+ # GNU sha256sum without -z: "<hex> <name>"; a name with a backslash,
695
+ # newline or carriage return is escaped and the line starts with a
696
+ # backslash.
697
+ def parse_sums(data)
698
+ data.b.split("\n").each_with_object({}) do |line, h|
699
+ escaped = line.start_with?("\\")
700
+ line = line[1..] if escaped
701
+ hex = line[0, 64]
702
+ next unless hex.match?(/\A\h{64}\z/) && line[64] == " " && [" ", "*"].include?(line[65])
703
+
704
+ name = line[66..].to_s
705
+ name = name.gsub(/\\([\\nr])/) { { "\\" => "\\", "n" => "\n", "r" => "\r" }[Regexp.last_match(1)] } if escaped
706
+ h[name.b] = hex.downcase
707
+ end
708
+ end
709
+
710
+ # findmnt and lsblk -r escape unsafe characters as \xHH.
711
+ def unescape(field)
712
+ return nil if field.nil?
713
+
714
+ field.b.gsub(/\\x(\h{2})/) { Regexp.last_match(1).hex.chr }
715
+ end
716
+
717
+ def detail(err)
718
+ FileDiff.escape(err.to_s.strip[0, 300])
719
+ end
720
+
721
+ def safely
722
+ yield
723
+ rescue StandardError => e
724
+ @ctx.logger&.warn("vagrant xapi diff cleanup in the guest failed: #{e.message}")
725
+ nil
726
+ end
727
+ end
728
+ end
729
+ end
730
+ end