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