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,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "log4r"
4
+ require "vagrant"
5
+
6
+ require_relative "action"
7
+ require_relative "errors"
8
+ require_relative "extensions"
9
+ require_relative "glue"
10
+ require_relative "services/power"
11
+ require_relative "services/lookup"
12
+
13
+ module VagrantPlugins
14
+ module Xapi
15
+ class Provider < Vagrant.plugin("2", :provider)
16
+ SAFE_HOST = /\A[A-Za-z0-9.:\-\[\]]+\z/.freeze
17
+ SAFE_USER = /\A[A-Za-z0-9._\-]+\z/.freeze
18
+
19
+ # Default synced folder types, in this order, when the Vagrantfile does
20
+ # not choose (config.vm.allowed_synced_folder_types).
21
+ SYNCED_FOLDER_TYPES = %i[rsync nfs smb].freeze
22
+
23
+ def initialize(machine)
24
+ super
25
+ @machine = machine
26
+ @logger = Log4r::Logger.new("vagrantplugins::xapi_provider")
27
+ prefer_rsync
28
+ end
29
+
30
+ def action(name)
31
+ method = "action_#{name}"
32
+ return Action.public_send(method) if Action.respond_to?(method)
33
+
34
+ nil
35
+ end
36
+
37
+ # The machine id is the VM UUID. A VM that cannot be found is
38
+ # :not_created, which makes Vagrant clear the data_dir. An id that is
39
+ # not a UUID goes to the extensions first (Extensions.on_foreign_id).
40
+ def state
41
+ return not_created if @machine.id.nil?
42
+
43
+ ctx = Glue.context(@machine)
44
+ unless Services::Lookup.uuid?(@machine.id)
45
+ Errors.wrap { Extensions.resolve_foreign_id(@machine, ctx) }
46
+ return not_created if @machine.id.nil?
47
+ end
48
+ info = Errors.wrap do
49
+ ref = Services::Lookup.new(ctx).vm_by_uuid(@machine.id)
50
+ ref ? Services::Power.new(ctx).state(ref) : nil
51
+ end
52
+ return not_created unless info
53
+
54
+ id = info[:state]
55
+ short = I18n.t("vagrant_xapi.states.short_#{id}", default: id.to_s)
56
+ long = I18n.t("vagrant_xapi.states.long_#{id}", default: short)
57
+ unless info[:operations].empty?
58
+ long = "#{long}\n\n#{I18n.t("vagrant_xapi.states.operations", operations: info[:operations].join(", "))}"
59
+ end
60
+ Vagrant::MachineState.new(id, short, long)
61
+ end
62
+
63
+ # nil until the guest has an IP.
64
+ def ssh_info
65
+ return nil if @machine.id.nil?
66
+
67
+ ctx = Glue.context(@machine)
68
+ Errors.wrap do
69
+ ref = Services::Lookup.new(ctx).vm_by_uuid(@machine.id)
70
+ return nil unless ref
71
+ return nil unless ctx.client.call("VM.get_power_state", ref) == "Running"
72
+
73
+ found = Glue.ip_detector(@machine, ctx).detect(ref)
74
+ return nil unless found
75
+
76
+ info = { host: found.first, port: @machine.config.ssh.guest_port || 22 }
77
+ proxy = proxy_command(ctx, ref)
78
+ info[:proxy_command] = proxy if proxy
79
+ info
80
+ end
81
+ end
82
+
83
+ # Nothing to install locally: the plugin talks to XAPI over HTTPS.
84
+ def self.usable?(_raise_error = false)
85
+ true
86
+ end
87
+
88
+ def self.installed?
89
+ true
90
+ end
91
+
92
+ def machine_id_changed; end
93
+
94
+ def to_s
95
+ id = @machine.id ? @machine.id.to_s : "new VM"
96
+ "XAPI (#{id})"
97
+ end
98
+
99
+ private
100
+
101
+ # The VM runs on a remote hypervisor. Without a choice in the
102
+ # Vagrantfile, Vagrant takes the usable type with the highest priority,
103
+ # which on macOS and Windows is SMB: it asks for credentials on every
104
+ # `up` (failing without a terminal) and needs the guest to reach this
105
+ # machine. rsync needs neither, so it comes first. Only the default
106
+ # changes: a folder with an explicit type keeps it.
107
+ def prefer_rsync
108
+ vm = @machine.config&.vm
109
+ vm.allowed_synced_folder_types ||= SYNCED_FOLDER_TYPES.dup if vm
110
+ end
111
+
112
+ def not_created
113
+ Vagrant::MachineState.new(:not_created,
114
+ I18n.t("vagrant_xapi.states.short_not_created"),
115
+ I18n.t("vagrant_xapi.states.long_not_created"))
116
+ end
117
+
118
+ # ProxyCommand through dom0 of the host the VM runs on, with normal
119
+ # host key checking (the old plugin turned it off).
120
+ def proxy_command(ctx, ref)
121
+ cfg = @machine.provider_config
122
+ return nil unless cfg.ssh_via_host
123
+
124
+ host = ctx.client.call("VM.get_resident_on", ref)
125
+ address = ctx.client.call("host.get_address", host)
126
+ user = cfg.host_ssh_user.to_s
127
+ unless SAFE_HOST.match?(address.to_s) && SAFE_USER.match?(user)
128
+ raise Errors::ServiceFailed.new(:unsafe_proxy, address: address.to_s, user: user)
129
+ end
130
+
131
+ "ssh -W %h:%p -l #{user} -o BatchMode=yes #{address}"
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module VagrantPlugins
6
+ module Xapi
7
+ module Services
8
+ # Hands an existing VM to a Vagrant machine: the VM gets the machine's
9
+ # marking, and its own disks are marked as the VM's, so that
10
+ # `vagrant destroy` removes them.
11
+ class Adoption
12
+ def initialize(ctx)
13
+ @client = ctx.client
14
+ end
15
+
16
+ # Replaces any older marking of the VM with this one and tags it. Its
17
+ # vTPM no longer counts as made by the plugin, so turning `vtpm` off
18
+ # does not remove it.
19
+ def mark_vm(vm, marking)
20
+ begin
21
+ @client.call("VM.remove_from_other_config", vm, Marking::VTPM)
22
+ rescue Api::XapiError
23
+ nil
24
+ end
25
+ marking.each do |key, value|
26
+ begin
27
+ @client.call("VM.remove_from_other_config", vm, key)
28
+ rescue Api::XapiError
29
+ nil
30
+ end
31
+ @client.call("VM.add_to_other_config", vm, key, value)
32
+ end
33
+ tags = @client.call("VM.get_record", vm)["tags"] || []
34
+ @client.call("VM.add_tags", vm, Marking::TAG) unless tags.include?(Marking::TAG)
35
+ end
36
+
37
+ # Marks the VM's exclusive disks (Disk VBDs whose VDI is not sharable
38
+ # and attached to no other VM) as its own. A disk that is not
39
+ # exclusive (any more) loses the VM's marking, so `vagrant destroy`
40
+ # never deletes it.
41
+ def mark_disks(vm)
42
+ vm_uuid = @client.call("VM.get_uuid", vm)
43
+ @client.call("VM.get_VBDs", vm).each do |vbd|
44
+ rec = @client.call("VBD.get_record", vbd)
45
+ vdi = rec["VDI"].to_s
46
+ next if rec["type"] != "Disk" || vdi.empty? || vdi == "OpaqueRef:NULL"
47
+
48
+ if exclusive_disk?(vdi, vm)
49
+ remove_owner(vdi)
50
+ @client.call("VDI.add_to_other_config", vdi, Marking::OWNER_VM, vm_uuid)
51
+ elsif @client.call("VDI.get_other_config", vdi)[Marking::OWNER_VM] == vm_uuid
52
+ remove_owner(vdi)
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def exclusive_disk?(vdi, vm)
60
+ return false if @client.call("VDI.get_sharable", vdi)
61
+
62
+ @client.call("VDI.get_VBDs", vdi).all? { |vbd| @client.call("VBD.get_VM", vbd) == vm }
63
+ end
64
+
65
+ def remove_owner(vdi)
66
+ @client.call("VDI.remove_from_other_config", vdi, Marking::OWNER_VM)
67
+ rescue Api::XapiError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "etc"
5
+
6
+ require_relative "../api/errors"
7
+ require_relative "../version"
8
+
9
+ module VagrantPlugins
10
+ module Xapi
11
+ module Services
12
+ # A service-level error. `key` is a message key under
13
+ # vagrant_xapi.errors in locales/en.yml; the integration layer turns it
14
+ # into a Vagrant error with readable text.
15
+ class Error < StandardError
16
+ attr_reader :key, :params
17
+
18
+ def initialize(key, **params)
19
+ @key = key.to_sym
20
+ @params = params
21
+ super("#{key} #{params.inspect}")
22
+ end
23
+ end
24
+
25
+ # Everything a service needs to talk to the pool and the user.
26
+ #
27
+ # ui responds to:
28
+ # info(key, **params), detail(key, **params), warn(key, **params),
29
+ # progress(key, done, total, **params), clear_progress
30
+ # cancel: a callable returning true once the user pressed Ctrl-C.
31
+ Context = Struct.new(:client, :ui, :cancel, :logger, keyword_init: true) do
32
+ def cancelled?
33
+ cancel ? cancel.call : false
34
+ end
35
+
36
+ def progress_proc(key, **params)
37
+ return nil unless ui
38
+
39
+ ->(done, total = 1.0) { ui.progress(key, done, total, **params) }
40
+ end
41
+
42
+ # For XAPI task progress (0.0..1.0).
43
+ def task_progress(key, **params)
44
+ return nil unless ui
45
+
46
+ ->(fraction) { ui.progress(key, (fraction * 100).round, 100, **params) }
47
+ end
48
+
49
+ def run_task(method, *args, label: nil, progress_key: nil, timeout: nil, **params)
50
+ result = client.run_task(method, *args,
51
+ label: label || method,
52
+ timeout: timeout,
53
+ progress: progress_key ? task_progress(progress_key, **params) : nil,
54
+ cancel: cancel)
55
+ ui&.clear_progress if progress_key
56
+ result
57
+ rescue StandardError
58
+ ui&.clear_progress if progress_key
59
+ raise
60
+ end
61
+ end
62
+
63
+ # Small XAPI helpers shared by the services. Expects @client.
64
+ module Helpers
65
+ NULL_REF = "OpaqueRef:NULL"
66
+
67
+ # Also usable without including Helpers (commands).
68
+ def self.null_ref?(ref)
69
+ ref.nil? || ref.to_s.empty? || ref.to_s == NULL_REF
70
+ end
71
+
72
+ def null_ref?(ref)
73
+ Helpers.null_ref?(ref)
74
+ end
75
+
76
+ # Sets one other_config key (XAPI has no "set key", only add/remove).
77
+ def set_oc(klass, ref, key, value)
78
+ remove_oc(klass, ref, key)
79
+ @client.call("#{klass}.add_to_other_config", ref, key, value.to_s)
80
+ end
81
+
82
+ def remove_oc(klass, ref, key)
83
+ @client.call("#{klass}.remove_from_other_config", ref, key)
84
+ rescue Api::XapiError
85
+ nil
86
+ end
87
+
88
+ # [[vbd_ref, vbd_record], ...] for a VM
89
+ def vbds_of(vm)
90
+ @client.call("VM.get_VBDs", vm).map { |vbd| [vbd, @client.call("VBD.get_record", vbd)] }
91
+ end
92
+
93
+ # VDI refs of the VM's Disk VBDs
94
+ def disk_vdis(vm)
95
+ vbds_of(vm).filter_map do |_vbd, rec|
96
+ next if rec["type"] != "Disk" || null_ref?(rec["VDI"])
97
+
98
+ rec["VDI"]
99
+ end
100
+ end
101
+
102
+ def ignore_missing
103
+ yield
104
+ rescue Api::XapiError => e
105
+ raise unless %w[HANDLE_INVALID UUID_INVALID].include?(e.code)
106
+
107
+ nil
108
+ end
109
+
110
+ # A new, empty VDI; every VDI the plugin creates comes from here.
111
+ def create_vdi(sr, name, size, description:, read_only: false, type: "user", other_config: {}, tags: [])
112
+ @client.call("VDI.create", {
113
+ "name_label" => name, "name_description" => description, "SR" => sr,
114
+ "virtual_size" => size.to_i, "type" => type, "sharable" => false, "read_only" => read_only,
115
+ "other_config" => other_config, "xenstore_data" => {}, "sm_config" => {}, "tags" => tags
116
+ })
117
+ end
118
+
119
+ # Device slots in which a guest sees a CD drive. Xen emulates four IDE
120
+ # devices for an HVM guest, in slots 0-3. XAPI accepts a CD drive in a
121
+ # later slot, but the guest never sees it: its PV drivers leave CD
122
+ # drives to the emulation.
123
+ CD_SLOTS = %w[0 1 2 3].freeze
124
+
125
+ # A VBD on the VM's first free device; a CD only in a CD slot.
126
+ def create_vbd(vm, vdi, mode:, type:, other_config: {})
127
+ allowed = @client.call("VM.get_allowed_VBD_devices", vm).map(&:to_s)
128
+ if type == "CD"
129
+ allowed &= CD_SLOTS
130
+ raise Error.new(:no_free_cd_slot, in_use: cd_slots_in_use(vm)) if allowed.empty?
131
+ end
132
+ raise Error.new(:no_free_device) if allowed.empty?
133
+
134
+ @client.call("VBD.create", {
135
+ "VM" => vm, "VDI" => vdi, "userdevice" => allowed.first, "bootable" => false,
136
+ "mode" => mode, "type" => type, "unpluggable" => true, "empty" => false,
137
+ "other_config" => other_config, "qos_algorithm_type" => "", "qos_algorithm_params" => {}
138
+ })
139
+ end
140
+
141
+ # "0: Disk, 3: CD (empty)": what takes the CD slots, for messages.
142
+ def cd_slots_in_use(vm)
143
+ vbds_of(vm).map { |_vbd, rec| [rec["userdevice"].to_s, rec] }
144
+ .select { |slot, _rec| CD_SLOTS.include?(slot) }
145
+ .sort_by { |slot, _rec| slot.to_i }
146
+ .map { |slot, rec| "#{slot}: #{rec["type"]}#{" (empty)" if null_ref?(rec["VDI"])}" }
147
+ .join(", ")
148
+ end
149
+
150
+ # A string literal for the expressions of get_all_records_where. Block
151
+ # form: in a replacement string "\\" would mean one backslash.
152
+ def self.where_value(value)
153
+ value.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\\"' }
154
+ end
155
+ end
156
+
157
+ # Markings put on every object the plugin creates. Cleanup only ever
158
+ # touches objects that carry them.
159
+ module Marking
160
+ PREFIX = "vagrant-xapi."
161
+ TAG = "vagrant"
162
+
163
+ # VM keys
164
+ MACHINE = "#{PREFIX}machine"
165
+ PROJECT = "#{PREFIX}project"
166
+ USER = "#{PREFIX}user"
167
+ CLIENT = "#{PREFIX}client"
168
+ CLIENT_ID = "#{PREFIX}client_id"
169
+ VERSION_KEY = "#{PREFIX}version"
170
+ TEMPLATE_UUID = "#{PREFIX}template"
171
+ # The plugin created the VM's vTPM (provider option `vtpm`), so it may
172
+ # remove it again when the option is turned off.
173
+ VTPM = "#{PREFIX}vtpm"
174
+
175
+ # Template keys
176
+ BOX = "#{PREFIX}box"
177
+ BOX_VERSION = "#{PREFIX}box_version"
178
+ BOX_PROVIDER = "#{PREFIX}box_provider"
179
+ BOX_DIGEST = "#{PREFIX}box_digest"
180
+ SR = "#{PREFIX}sr"
181
+ TEMPLATE_KEYS = [BOX, BOX_VERSION, BOX_PROVIDER, BOX_DIGEST, SR].freeze
182
+
183
+ # VDI keys
184
+ OWNER_VM = "#{PREFIX}vm"
185
+ DISK_NAME = "#{PREFIX}disk"
186
+ CLOUD_INIT = "#{PREFIX}cloud_init"
187
+ CONTENT_SHA256 = "#{PREFIX}sha256"
188
+ OF_TEMPLATE = "#{PREFIX}of_template"
189
+
190
+ # VBD key: CD drive for an ISO from an ISO SR (provider option `isos`)
191
+ ISO = "#{PREFIX}iso"
192
+
193
+ # VDI and VBD key: throwaway snapshot disk of `vagrant xapi diff`
194
+ # (value: the id of the run)
195
+ DIFF = "#{PREFIX}diff"
196
+
197
+ # Snapshot key
198
+ SNAPSHOT_OF = "#{PREFIX}snapshot_of"
199
+
200
+ # Network keys
201
+ NETWORK = "#{PREFIX}network"
202
+
203
+ module_function
204
+
205
+ def user
206
+ Etc.getlogin || ENV["USER"] || ENV["USERNAME"] || "unknown"
207
+ rescue StandardError
208
+ ENV["USER"] || "unknown"
209
+ end
210
+
211
+ def client_host
212
+ Socket.gethostname
213
+ rescue StandardError
214
+ "unknown"
215
+ end
216
+
217
+ # @param machine_name [String] Vagrant machine name
218
+ # @param project [String] path of the Vagrantfile directory
219
+ def vm_marking(machine_name:, project:, client_id: nil)
220
+ marking = {
221
+ MACHINE => machine_name.to_s,
222
+ PROJECT => project.to_s,
223
+ USER => user,
224
+ CLIENT => client_host,
225
+ VERSION_KEY => Xapi::VERSION
226
+ }
227
+ marking[CLIENT_ID] = client_id.to_s if client_id
228
+ marking
229
+ end
230
+
231
+ def marked?(other_config)
232
+ oc = other_config || {}
233
+ oc.key?(MACHINE) && oc.key?(PROJECT)
234
+ end
235
+
236
+ def ours?(other_config, key, value)
237
+ (other_config || {})[key].to_s == value.to_s && !value.to_s.empty?
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "pathname"
7
+ require "securerandom"
8
+
9
+ require_relative "base"
10
+ require_relative "disk_image"
11
+
12
+ module VagrantPlugins
13
+ module Xapi
14
+ module Services
15
+ # Describes what to import for a box: an XVA, an XVA URL, or one or more
16
+ # disk images. Understands three box providers: xapi, xenserver (boxes of
17
+ # the old vagrant-xenserver plugin) and libvirt.
18
+ class BoxSource
19
+ SUPPORTED_PROVIDERS = %w[xapi xenserver libvirt].freeze
20
+ DISK_FORMATS = %w[vhd qcow2 raw img].freeze
21
+
22
+ Disk = Struct.new(:path, :format, :virtual_size, keyword_init: true)
23
+
24
+ attr_reader :name, :version, :provider, :kind, :xva_path, :xva_url, :disks, :directory
25
+
26
+ # @param directory [Pathname] unpacked box directory
27
+ # @param xva_url [String, nil] from provider config (xapi or xenserver)
28
+ def initialize(name:, version:, provider:, directory:, xva_url: nil)
29
+ @name = name.to_s
30
+ @version = version.to_s
31
+ @provider = provider.to_s
32
+ @directory = Pathname.new(directory.to_s)
33
+ @xva_url_config = xva_url
34
+ @disks = []
35
+ raise Error.new(:box_provider_unsupported, provider: @provider) unless SUPPORTED_PROVIDERS.include?(@provider)
36
+
37
+ detect
38
+ end
39
+
40
+ def metadata
41
+ @metadata ||= begin
42
+ path = @directory.join("metadata.json")
43
+ path.file? ? JSON.parse(path.read) : {}
44
+ rescue JSON::ParserError
45
+ raise Error.new(:box_metadata_invalid, path: path.to_s)
46
+ end
47
+ end
48
+
49
+ def architecture
50
+ metadata["architecture"]
51
+ end
52
+
53
+ # Files that make up the box content, in import order.
54
+ def content_files
55
+ case @kind
56
+ when :xva then [@xva_path]
57
+ when :disks then @disks.map(&:path)
58
+ else []
59
+ end
60
+ end
61
+
62
+ # SHA-256 identifying the box content (cached on disk).
63
+ def digest(cache)
64
+ return Digest::SHA256.hexdigest("xva_url:#{@xva_url}") if @kind == :xva_url
65
+
66
+ files = content_files
67
+ return cache.digest(files.first) if files.size == 1
68
+
69
+ Digest::SHA256.hexdigest(files.map { |f| cache.digest(f) }.join(":"))
70
+ end
71
+
72
+ def description
73
+ case @kind
74
+ when :xva then "XVA #{File.basename(@xva_path)}"
75
+ when :xva_url then "XVA from #{@xva_url}"
76
+ else @disks.map { |d| "#{File.basename(d.path)} (#{d.format})" }.join(", ")
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def detect
83
+ case @provider
84
+ when "xapi" then detect_xapi
85
+ when "xenserver" then detect_xenserver
86
+ when "libvirt" then detect_libvirt
87
+ end
88
+ end
89
+
90
+ def detect_xapi
91
+ format = metadata["format"].to_s
92
+ if format.empty?
93
+ # Tolerate boxes without format: look for a box.* file.
94
+ found = %w[xva vhd qcow2 raw img].find { |f| @directory.join("box.#{f}").file? }
95
+ return use_xva_url_or_fail if found.nil?
96
+
97
+ format = found
98
+ end
99
+ case format
100
+ when "xva"
101
+ use_xva(@directory.join("box.xva"))
102
+ when "vhd", "qcow2", "raw", "img"
103
+ use_disk(@directory.join("box.#{format}"))
104
+ else
105
+ raise Error.new(:box_format_unsupported, image_format: format)
106
+ end
107
+ end
108
+
109
+ def detect_xenserver
110
+ if @directory.join("box.xva").file?
111
+ use_xva(@directory.join("box.xva"))
112
+ elsif @directory.join("box.vhd").file?
113
+ use_disk(@directory.join("box.vhd"))
114
+ else
115
+ use_xva_url_or_fail
116
+ end
117
+ end
118
+
119
+ def detect_libvirt
120
+ if metadata["disks"].is_a?(Array) && !metadata["disks"].empty?
121
+ metadata["disks"].each_with_index do |d, i|
122
+ path = d.is_a?(Hash) && d["path"] ? d["path"] : (i.zero? ? "box.img" : "box_#{i}.img")
123
+ use_disk(@directory.join(path.to_s))
124
+ end
125
+ else
126
+ use_disk(@directory.join("box.img"))
127
+ end
128
+ end
129
+
130
+ def use_xva(path)
131
+ @kind = :xva
132
+ @xva_path = inside_box!(path)
133
+ end
134
+
135
+ def use_xva_url_or_fail
136
+ url = @xva_url_config.to_s
137
+ raise Error.new(:box_empty, directory: @directory.to_s) if url.empty?
138
+
139
+ @kind = :xva_url
140
+ @xva_url = url
141
+ end
142
+
143
+ def use_disk(path)
144
+ real = inside_box!(path)
145
+ info = DiskImage.inspect_file(real)
146
+ @kind = :disks
147
+ @disks << Disk.new(path: real, format: info.format, virtual_size: info.virtual_size)
148
+ end
149
+
150
+ # Box content is not trusted: every file it names must be a regular
151
+ # file inside the box directory, also after following symlinks, so a
152
+ # box cannot make the plugin upload files from the local machine.
153
+ # @return [String] the resolved path
154
+ def inside_box!(path)
155
+ raise Error.new(:box_file_missing, path: path.to_s) unless File.file?(path.to_s)
156
+
157
+ real = File.realpath(path.to_s)
158
+ root = File.realpath(@directory.to_s)
159
+ unless real.start_with?(root + File::SEPARATOR) && File.file?(real)
160
+ raise Error.new(:box_path_outside, path: path.to_s, directory: @directory.to_s)
161
+ end
162
+
163
+ real
164
+ end
165
+ end
166
+
167
+ # SHA-256 digests of box files, cached on path, size and mtime in
168
+ # ~/.vagrant.d/data/vagrant-xapi/digests.json.
169
+ class DigestCache
170
+ # One mutex for all instances: parallel machines each build their own
171
+ # DigestCache for the same file.
172
+ MUTEX = Mutex.new
173
+
174
+ def initialize(path)
175
+ @path = Pathname.new(path.to_s)
176
+ @mutex = MUTEX
177
+ end
178
+
179
+ def digest(file)
180
+ stat = File.stat(file)
181
+ key = File.expand_path(file)
182
+ @mutex.synchronize do
183
+ entry = load[key]
184
+ if entry && entry["size"] == stat.size && entry["mtime"] == stat.mtime.to_i
185
+ return entry["sha256"]
186
+ end
187
+ end
188
+
189
+ sha = Digest::SHA256.file(file).hexdigest
190
+ @mutex.synchronize do
191
+ data = load
192
+ data[key] = { "size" => stat.size, "mtime" => stat.mtime.to_i, "sha256" => sha }
193
+ save(data)
194
+ end
195
+ sha
196
+ end
197
+
198
+ private
199
+
200
+ def load
201
+ return {} unless @path.file?
202
+
203
+ JSON.parse(@path.read)
204
+ rescue JSON::ParserError
205
+ {}
206
+ end
207
+
208
+ def save(data)
209
+ FileUtils.mkdir_p(@path.dirname)
210
+ # Unique per writer, so processes never rename each other's file.
211
+ tmp = @path.sub_ext(".#{Process.pid}.#{SecureRandom.hex(4)}.tmp")
212
+ File.write(tmp, JSON.pretty_generate(data))
213
+ File.rename(tmp, @path)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end