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,393 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ require_relative "errors"
7
+ require_relative "redactor"
8
+ require_relative "transport"
9
+ require_relative "task"
10
+ require_relative "http_transfer"
11
+ require_relative "../version"
12
+
13
+ module VagrantPlugins
14
+ module Xapi
15
+ module Api
16
+ # XAPI client over JSON-RPC 2.0 (https://<host>/jsonrpc) using only the
17
+ # Ruby standard library.
18
+ #
19
+ # client = Client.new(host: "xcp1", username: "root", password: "...")
20
+ # client.login
21
+ # vm = client.call("VM.get_by_uuid", uuid) # session is prepended
22
+ # client.VM.get_record(vm) # same, via proxy
23
+ # client.run_task("VM.clone", vm, "name") # Async.* + wait
24
+ #
25
+ # One Client = one session. It is safe to share between threads.
26
+ class Client
27
+ API_VERSION = "1.0"
28
+ ORIGINATOR = "vagrant-xapi/#{VERSION}"
29
+ MAX_REDIRECTS = 2
30
+
31
+ attr_reader :host, :port, :username, :features, :session_ref, :transport
32
+
33
+ def initialize(host:, username:, password:, port: 443, tls: {}, api_timeout: 60,
34
+ task_timeout: 3600, logger: nil)
35
+ @host = host
36
+ @port = port
37
+ @username = username
38
+ @password = password
39
+ @tls = tls
40
+ @api_timeout = api_timeout
41
+ @task_timeout = task_timeout
42
+ @logger = SafeLogger.new(logger)
43
+ @transport = Transport.new(host: host, port: port, tls: tls, timeout: api_timeout)
44
+ @id = 0
45
+ @id_mutex = Mutex.new
46
+ @login_mutex = Mutex.new
47
+ @session_ref = nil
48
+ @features = {}
49
+ @host_transports = {}
50
+ @host_transports_mutex = Mutex.new
51
+ @master_ref = nil
52
+ Redactor.register(password)
53
+ end
54
+
55
+ attr_reader :task_timeout, :api_timeout
56
+
57
+ def logged_in?
58
+ !@session_ref.nil?
59
+ end
60
+
61
+ # Logs in, following HOST_IS_SLAVE to the pool master.
62
+ def login
63
+ @login_mutex.synchronize { login_unlocked unless @session_ref }
64
+ self
65
+ end
66
+
67
+ def logout
68
+ ref = nil
69
+ @login_mutex.synchronize do
70
+ ref = @session_ref
71
+ @session_ref = nil
72
+ end
73
+ return unless ref
74
+
75
+ begin
76
+ rpc("session.logout", [ref])
77
+ rescue Error => e
78
+ @logger.debug("logout failed: #{e.message}")
79
+ ensure
80
+ Redactor.unregister(ref)
81
+ @transport.shutdown
82
+ end
83
+ end
84
+
85
+ # Calls a XAPI method with the session prepended to params.
86
+ # Retries once after re-login on SESSION_INVALID.
87
+ def call(method, *params)
88
+ call_with_session(method, params)
89
+ end
90
+
91
+ # Calls a method with a longer read timeout (event.from).
92
+ def call_with_timeout(method, read_timeout, *params)
93
+ call_with_session(method, params, read_timeout: read_timeout)
94
+ end
95
+
96
+ # Starts Async.<method> and waits for the task.
97
+ # @return [Task::Result]
98
+ def run_task(method, *params, label: nil, timeout: nil, progress: nil, cancel: nil)
99
+ task_ref = call("Async.#{method}", *params)
100
+ wait_task(task_ref, label: label || method, timeout: timeout, progress: progress, cancel: cancel)
101
+ end
102
+
103
+ def wait_task(task_ref, label:, timeout: nil, progress: nil, cancel: nil)
104
+ Task.new(self, task_ref, label: label, logger: @logger)
105
+ .wait(timeout: timeout || @task_timeout, progress: progress, cancel: cancel)
106
+ end
107
+
108
+ # Creates a task object for HTTP handlers that accept task_id.
109
+ def create_task(label)
110
+ call("task.create", label, ORIGINATOR)
111
+ end
112
+
113
+ def http
114
+ HttpTransfer.new(self)
115
+ end
116
+
117
+ # Proxy: client.VM.get_record(ref) == client.call("VM.get_record", ref)
118
+ class ClassProxy
119
+ def initialize(client, klass)
120
+ @client = client
121
+ @klass = klass
122
+ end
123
+
124
+ def method_missing(name, *args)
125
+ @client.call("#{@klass}.#{name}", *args)
126
+ end
127
+
128
+ def respond_to_missing?(*)
129
+ true
130
+ end
131
+ end
132
+
133
+ # Only the capitalized classes: lowercase ones (host, pool, ...) would
134
+ # clash with the client's own methods. Use #call for those.
135
+ XAPI_CLASSES = %w[VM VBD VDI VIF SR PBD PIF VM_guest_metrics VM_metrics VTPM].freeze
136
+
137
+ XAPI_CLASSES.each do |klass|
138
+ define_method(klass) { ClassProxy.new(self, klass) }
139
+ end
140
+
141
+ # Optional XAPI features, detected on first login.
142
+ def feature?(name)
143
+ @features[name.to_sym] ? true : false
144
+ end
145
+
146
+ def set_feature(name, value)
147
+ @features = @features.merge(name.to_sym => value)
148
+ end
149
+
150
+ def session_cookie
151
+ "session_id=#{current_session}"
152
+ end
153
+
154
+ # The HTTP handlers answer an expired or evicted session with 401:
155
+ # log in again (unless another thread already did), as #call does on
156
+ # SESSION_INVALID. cookie is the one that was refused.
157
+ def renew_session(cookie)
158
+ relogin(cookie.to_s.delete_prefix("session_id="), nil)
159
+ nil
160
+ end
161
+
162
+ # The pool master's host ref.
163
+ def master_ref
164
+ @master_ref ||= call("pool.get_master", call("pool.get_all").first)
165
+ end
166
+
167
+ # Transport to a specific pool host, for HTTP transfers and the
168
+ # console. The master uses the main connection. For other members the
169
+ # certificate is fetched through the (already verified) master with
170
+ # host.get_server_certificate and pinned, because a configured pin or
171
+ # a hostname check would not match them.
172
+ def transport_for(host_ref)
173
+ return @transport if host_ref.nil? || host_ref == master_ref
174
+
175
+ address = call("host.get_address", host_ref)
176
+ @host_transports_mutex.synchronize do
177
+ @host_transports[address] ||= Transport.new(host: address, port: @port, tls: member_tls(host_ref),
178
+ timeout: @api_timeout)
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ def member_tls(host_ref)
185
+ pem = call("host.get_server_certificate", host_ref)
186
+ { fingerprint: OpenSSL::Digest::SHA256.hexdigest(OpenSSL::X509::Certificate.new(pem).to_der) }
187
+ rescue XapiError, OpenSSL::X509::CertificateError => e
188
+ @logger.debug("no server certificate for member host (#{e.message}); using the configured TLS settings")
189
+ @tls
190
+ end
191
+
192
+ def current_session
193
+ login unless @session_ref
194
+ @session_ref || login_and_get
195
+ end
196
+
197
+ def login_and_get
198
+ @login_mutex.synchronize do
199
+ login_unlocked unless @session_ref
200
+ @session_ref
201
+ end
202
+ end
203
+
204
+ def call_with_session(method, params, read_timeout: nil)
205
+ ref = current_session
206
+ attempt = 0
207
+ begin
208
+ rpc(method, [ref, *params], read_timeout: read_timeout)
209
+ rescue XapiError => e
210
+ raise unless e.code == "SESSION_INVALID" && attempt.zero?
211
+
212
+ attempt += 1
213
+ @logger.info("session expired, logging in again")
214
+ ref = relogin(ref, e)
215
+ retry
216
+ end
217
+ end
218
+
219
+ def next_id
220
+ @id_mutex.synchronize { @id += 1 }
221
+ end
222
+
223
+ # Logs in into a local variable first; the session is only swapped in
224
+ # once it is complete, so other threads never see a half state.
225
+ def login_unlocked
226
+ redirects = 0
227
+ member = nil
228
+ begin
229
+ ref = rpc("session.login_with_password", [@username, @password, API_VERSION, ORIGINATOR])
230
+ rescue TlsError => e
231
+ raise unless member
232
+
233
+ # The master's address never appears in the configuration, so
234
+ # say where it came from.
235
+ raise TlsError.new(e.host, "#{e.reason}. #{redirect_hint(member, e.host)}")
236
+ rescue XapiError => e
237
+ raise unless e.code == "HOST_IS_SLAVE" && redirects < MAX_REDIRECTS && e.params.first
238
+
239
+ master = e.params.first
240
+ # One pin matches only the member's own certificate, never the
241
+ # master's. With several (the pool's hosts) the master's
242
+ # certificate is checked against them.
243
+ if Transport.normalize_fingerprints(@tls && @tls[:fingerprint]).size == 1
244
+ raise PoolMemberError.new(@host, master)
245
+ end
246
+
247
+ redirects += 1
248
+ member ||= @host
249
+ @logger.info("#{@host} is a pool member; reconnecting to master #{master}")
250
+ @transport.shutdown
251
+ @host = master
252
+ @transport = Transport.new(host: master, port: @port, tls: @tls, timeout: @api_timeout)
253
+ retry
254
+ end
255
+ Redactor.register(ref)
256
+ features = detect_features(ref)
257
+ # Answers learned during the session (ImageUpload's qcow2 probe per
258
+ # host) survive a relogin.
259
+ @features = features.merge(@features.select { |key, _| key.to_s.start_with?("qcow2_import@") })
260
+ @master_ref = features[:master_ref]
261
+ @session_ref = ref
262
+ end
263
+
264
+ # For a TLS failure on the master after HOST_IS_SLAVE, by TLS mode.
265
+ # `vagrant xapi hosts` cannot help yet: it would take the same way.
266
+ def redirect_hint(member, master)
267
+ said = "#{member} is a pool member and sent the login on to its master #{master}"
268
+ if Transport.normalize_fingerprints(@tls && @tls[:fingerprint]).empty?
269
+ "#{said}; the master's certificate must pass the same CA check, for the address #{master}"
270
+ else
271
+ "#{said}, whose certificate is not among the tls_fingerprint pins. Add the master's fingerprint " \
272
+ "(openssl s_client -connect #{master}:#{@port} </dev/null | openssl x509 -noout -fingerprint " \
273
+ "-sha256); after that `vagrant xapi hosts` lists the fingerprints of all hosts"
274
+ end
275
+ end
276
+
277
+ # Logs in again unless another thread already did. A failing login
278
+ # raises its own error (e.g. a rotated password), not SESSION_INVALID.
279
+ # @return [String] the session to use
280
+ def relogin(failed_ref, _error)
281
+ @login_mutex.synchronize do
282
+ return @session_ref if @session_ref && @session_ref != failed_ref
283
+
284
+ @session_ref = nil
285
+ Redactor.unregister(failed_ref) if failed_ref
286
+ login_unlocked
287
+ @session_ref
288
+ end
289
+ end
290
+
291
+ def detect_features(ref)
292
+ features = {}
293
+ begin
294
+ pool = rpc("pool.get_all", [ref]).first
295
+ master = rpc("pool.get_master", [ref, pool])
296
+ features[:master_ref] = master
297
+ major = rpc("host.get_API_version_major", [ref, master]).to_i
298
+ minor = rpc("host.get_API_version_minor", [ref, master]).to_i
299
+ features[:api_version] = [major, minor]
300
+ sw = rpc("host.get_software_version", [ref, master])
301
+ features[:product_brand] = sw["product_brand"] || sw["platform_name"]
302
+ features[:product_version] = sw["product_version"] || sw["platform_version"]
303
+ rescue XapiError => e
304
+ @logger.debug("version detection failed: #{e.message}")
305
+ end
306
+ features[:vtpm] = method_available?(ref, "VTPM.get_all")
307
+ features
308
+ end
309
+
310
+ def method_available?(ref, method)
311
+ rpc(method, [ref])
312
+ true
313
+ rescue XapiError => e
314
+ return false if %w[MESSAGE_METHOD_UNKNOWN UNKNOWN_XENAPI_MESSAGE].include?(e.code)
315
+
316
+ # Other errors (e.g. RBAC) still mean the class exists.
317
+ true
318
+ end
319
+
320
+ # Raw JSON-RPC call; params as given.
321
+ def rpc(method, params, read_timeout: nil)
322
+ id = next_id
323
+ body = JSON.generate(jsonrpc: "2.0", method: method, params: params, id: id)
324
+ @logger.debug { "xapi -> #{method} #{log_params(method, params).inspect}" }
325
+
326
+ response = @transport.with_connection(read_timeout: read_timeout) do |http|
327
+ req = Net::HTTP::Post.new("/jsonrpc")
328
+ req["Content-Type"] = "application/json"
329
+ req["User-Agent"] = ORIGINATOR
330
+ req.body = body
331
+ http.request(req)
332
+ end
333
+
334
+ unless response.is_a?(Net::HTTPSuccess)
335
+ raise ProtocolError, "HTTP #{response.code} from #{@host} for #{method}"
336
+ end
337
+
338
+ parse_response(method, response.body)
339
+ end
340
+
341
+ def parse_response(method, body)
342
+ data = begin
343
+ JSON.parse(body)
344
+ rescue JSON::ParserError => e
345
+ raise ProtocolError, "invalid JSON from #{@host} for #{method}: #{e.message}"
346
+ end
347
+
348
+ if data.is_a?(Hash) && data["error"]
349
+ err = data["error"]
350
+ code, params = extract_error(err)
351
+ @logger.debug("xapi <- #{method} error #{code} #{params.inspect}")
352
+ raise XapiError.new(code, params, method_name: method)
353
+ end
354
+ raise ProtocolError, "no result from #{@host} for #{method}" unless data.is_a?(Hash) && data.key?("result")
355
+
356
+ result = data["result"]
357
+ # Some XAPI versions wrap like XML-RPC: {"Status":"Success","Value":...}
358
+ if result.is_a?(Hash) && result.key?("Status") && result.size <= 2
359
+ if result["Status"] == "Success"
360
+ result = result["Value"]
361
+ else
362
+ info = Array(result["ErrorDescription"])
363
+ raise XapiError.new(info.first || "UNKNOWN", info.drop(1), method_name: method)
364
+ end
365
+ end
366
+ @logger.debug("xapi <- #{method} ok")
367
+ result
368
+ end
369
+
370
+ def extract_error(err)
371
+ if err.is_a?(Hash)
372
+ data = err["data"]
373
+ if data.is_a?(Array) && data.first.is_a?(String) && err["message"].to_s.empty?
374
+ [data.first, data.drop(1)]
375
+ else
376
+ [err["message"] || err["code"] || "UNKNOWN", Array(data)]
377
+ end
378
+ elsif err.is_a?(Array)
379
+ [err.first, err.drop(1)]
380
+ else
381
+ [err.to_s, []]
382
+ end
383
+ end
384
+
385
+ def log_params(method, params)
386
+ return ["[user]", Redactor::MASK, *params.drop(2)] if method.start_with?("session.login")
387
+
388
+ params.each_with_index.map { |p, i| i.zero? && p.to_s.start_with?("OpaqueRef:") ? "<session>" : p }
389
+ end
390
+ end
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module VagrantPlugins
4
+ module Xapi
5
+ module Api
6
+ # Base class for everything the XAPI client raises. The client layer does
7
+ # not know Vagrant; the integration layer translates these into
8
+ # Vagrant::Errors::VagrantError subclasses (see VagrantPlugins::Xapi::Errors).
9
+ class Error < StandardError; end
10
+
11
+ # An error returned by XAPI itself, e.g. ["SR_FULL", "123", "456"].
12
+ class XapiError < Error
13
+ attr_reader :code, :params, :method_name
14
+
15
+ def initialize(code, params = [], method_name: nil)
16
+ @code = code.to_s
17
+ @params = Array(params).map(&:to_s)
18
+ @method_name = method_name
19
+ super("#{@code}#{@params.empty? ? "" : " #{@params.inspect}"}" \
20
+ "#{method_name ? " (#{method_name})" : ""}")
21
+ end
22
+ end
23
+
24
+ # The host could not be reached at the TCP/HTTP level.
25
+ class ConnectionError < Error
26
+ attr_reader :host, :reason
27
+
28
+ def initialize(host, reason)
29
+ @host = host
30
+ @reason = reason
31
+ super("cannot connect to #{host}: #{reason}")
32
+ end
33
+ end
34
+
35
+ # The host did not answer in time (connect or read timeout).
36
+ class TimeoutError < ConnectionError; end
37
+
38
+ # TLS verification failed (certificate, CA or fingerprint pin).
39
+ class TlsError < Error
40
+ attr_reader :host, :reason
41
+
42
+ def initialize(host, reason)
43
+ @host = host
44
+ @reason = reason
45
+ super("TLS verification of #{host} failed: #{reason}")
46
+ end
47
+ end
48
+
49
+ # Login on a pool member while a TLS pin is configured: the pin cannot
50
+ # match the master's certificate, so the redirect is refused.
51
+ class PoolMemberError < Error
52
+ attr_reader :host, :master
53
+
54
+ def initialize(host, master)
55
+ @host = host
56
+ @master = master
57
+ super("#{host} is a pool member; the pool master is #{master}")
58
+ end
59
+ end
60
+
61
+ # The response was not valid JSON-RPC.
62
+ class ProtocolError < Error; end
63
+
64
+ # HTTP transfer (/import, /export, ...) failed.
65
+ class HttpError < Error
66
+ attr_reader :status, :path
67
+
68
+ def initialize(status, path, body = nil)
69
+ @status = status.to_i
70
+ @path = path
71
+ detail = body.to_s.strip[0, 300]
72
+ super("HTTP #{status} on #{path}#{detail.empty? ? "" : ": #{detail}"}")
73
+ end
74
+ end
75
+
76
+ # A task did not finish within the configured timeout.
77
+ class TaskTimeout < Error
78
+ attr_reader :label, :timeout
79
+
80
+ def initialize(label, timeout)
81
+ @label = label
82
+ @timeout = timeout
83
+ super("task '#{label}' did not finish within #{timeout}s")
84
+ end
85
+ end
86
+
87
+ # A task was cancelled (by Ctrl-C or by someone else).
88
+ class TaskCancelled < Error
89
+ attr_reader :label
90
+
91
+ def initialize(label)
92
+ @label = label
93
+ super("task '#{label}' was cancelled")
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end