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