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,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+
9
+ module VagrantPlugins
10
+ module Xapi
11
+ module Api
12
+ # Streams uploads and downloads to XAPI HTTP handlers (/import,
13
+ # /import_raw_vdi, /export, /export_raw_vdi) with net/http.
14
+ #
15
+ # The session travels as a cookie, never in the URL, and no external
16
+ # process (curl) is involved, so no secret ever shows up in `ps`.
17
+ #
18
+ # XAPI answers these handlers with a 302 to another pool host when the
19
+ # receiving host cannot reach the SR. A PUT cannot follow that after
20
+ # its body is streamed, so callers pass the right host up front
21
+ # (`host:` is a host ref; nil means the master). GETs also follow
22
+ # redirects.
23
+ #
24
+ # Never send `Expect: 100-continue`: XAPI does not answer it with
25
+ # "100 Continue", and Net::HTTP would then wait forever for a body it
26
+ # never sent.
27
+ class HttpTransfer
28
+ MAX_REDIRECTS = 3
29
+
30
+ # IO wrapper that reports how many bytes have been read.
31
+ class ProgressIO
32
+ def initialize(io, total, progress)
33
+ @io = io
34
+ @total = total.to_f
35
+ @progress = progress
36
+ @done = 0
37
+ @last = -1
38
+ end
39
+
40
+ def read(length = nil, outbuf = nil)
41
+ data = @io.read(length, outbuf)
42
+ if data
43
+ @done += data.bytesize
44
+ report
45
+ end
46
+ data
47
+ end
48
+
49
+ def size
50
+ @total.to_i
51
+ end
52
+
53
+ private
54
+
55
+ def report
56
+ return unless @progress && @total.positive?
57
+
58
+ pct = (@done / @total * 100).floor
59
+ return if pct == @last
60
+
61
+ @last = pct
62
+ @progress.call(@done, @total.to_i)
63
+ end
64
+ end
65
+
66
+ def initialize(client)
67
+ @client = client
68
+ end
69
+
70
+ # PUT a local file to an XAPI handler. When task_label is given, a task
71
+ # is created, passed as task_id, and waited for; its Result is returned.
72
+ def upload(path, query, file, host: nil, progress: nil, task_label: nil, cancel: nil)
73
+ with_session_retry do
74
+ upload_once(path, query, file, host: host, progress: progress, task_label: task_label, cancel: cancel)
75
+ end
76
+ end
77
+
78
+ # Sends an empty PUT and returns only the status code, without waiting
79
+ # for the handler to finish. Used to ask whether a handler accepts a
80
+ # parameter (e.g. format=qcow2) before streaming a large file.
81
+ # @return [Integer, nil] nil when there was no answer in time
82
+ def probe(path, query, host: nil, timeout: 15)
83
+ status = probe_once(path, query, host: host, timeout: timeout)
84
+ return status unless status == 401
85
+
86
+ # An expired session, not an answer about the parameter.
87
+ @client.renew_session(@cookie)
88
+ probe_once(path, query, host: host, timeout: timeout)
89
+ end
90
+
91
+ # GET from an XAPI handler into a local file, following redirects to
92
+ # other pool hosts. When task_label is given, a task is created,
93
+ # passed as task_id and waited for once the body is in: the end of the
94
+ # stream alone does not say that the export on the host succeeded.
95
+ def download(path, query, dest, host: nil, progress: nil, cancel: nil, task_label: nil)
96
+ with_session_retry do
97
+ download_once(path, query, dest, host: host, progress: progress, cancel: cancel, task_label: task_label)
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ # XAPI's HTTP handlers answer an expired or evicted session (e.g. the
104
+ # oldest of a user with too many) with 401. Log in again once and
105
+ # start the transfer over, as Client#call does on SESSION_INVALID.
106
+ def with_session_retry
107
+ renewed = false
108
+ begin
109
+ yield
110
+ rescue HttpError => e
111
+ raise unless e.status == 401 && !renewed
112
+
113
+ renewed = true
114
+ @client.renew_session(@cookie)
115
+ retry
116
+ end
117
+ end
118
+
119
+ # The session cookie for a request, remembered so that a 401 renews
120
+ # exactly that session.
121
+ def cookie
122
+ @cookie = @client.session_cookie
123
+ end
124
+
125
+ def upload_once(path, query, file, host:, progress:, task_label:, cancel:)
126
+ waiting = false
127
+ task_ref = task_label ? @client.create_task(task_label) : nil
128
+ query = query.merge("task_id" => task_ref) if task_ref
129
+ size = File.size(file)
130
+ transport = @client.transport_for(host)
131
+
132
+ File.open(file, "rb") do |io|
133
+ req = Net::HTTP::Put.new(build_path(path, query))
134
+ req["Cookie"] = cookie
135
+ req["Content-Length"] = size.to_s
136
+ req["Content-Type"] = "application/octet-stream"
137
+ req["User-Agent"] = Client::ORIGINATOR
138
+ req.body_stream = ProgressIO.new(io, size, progress)
139
+ response = perform(transport, req, path, cancel: cancel, task_ref: task_ref)
140
+ check!(response, path)
141
+ end
142
+
143
+ return nil unless task_ref
144
+
145
+ waiting = true
146
+ @client.wait_task(task_ref, label: task_label, cancel: cancel) # destroys the task
147
+ rescue Exception # rubocop:disable Lint/RescueException
148
+ abandon_task(task_ref) if task_ref && !waiting
149
+ raise
150
+ end
151
+
152
+ def probe_once(path, query, host:, timeout:)
153
+ transport = @client.transport_for(host)
154
+ http = transport.new_connection(read_timeout: timeout)
155
+ begin
156
+ req = Net::HTTP::Put.new(build_path(path, query))
157
+ req["Cookie"] = cookie
158
+ req["User-Agent"] = Client::ORIGINATOR
159
+ req["Connection"] = "close"
160
+ req.body = ""
161
+ # The block runs once the status and headers are in; returning
162
+ # from it skips reading the body, which XAPI may never close.
163
+ http.request(req) { |response| return response.code.to_i } # rubocop:disable Lint/NonLocalExitFromIterator
164
+ ensure
165
+ begin
166
+ http.finish if http.started?
167
+ rescue StandardError
168
+ nil
169
+ end
170
+ end
171
+ rescue TimeoutError, *Transport::TIMEOUT_ERRORS
172
+ # No answer in time: the caller converts instead (and asks again
173
+ # next time). A refused or unreachable host is a real error below.
174
+ nil
175
+ rescue OpenSSL::SSL::SSLError => e
176
+ raise TlsError.new(transport.host, e.message)
177
+ rescue *Transport::NETWORK_ERRORS => e
178
+ raise ConnectionError.new(transport.host, e.message)
179
+ end
180
+
181
+ def download_once(path, query, dest, host:, progress:, cancel:, task_label:)
182
+ waiting = false
183
+ task_ref = task_label ? @client.create_task(task_label) : nil
184
+ query = query.merge("task_id" => task_ref) if task_ref
185
+ transport = @client.transport_for(host)
186
+ target = build_path(path, query)
187
+ MAX_REDIRECTS.times do
188
+ location = get_into(transport, target, path, dest, progress, cancel)
189
+ unless location
190
+ if task_ref
191
+ waiting = true
192
+ @client.wait_task(task_ref, label: task_label, cancel: cancel) # destroys the task
193
+ end
194
+ return dest
195
+ end
196
+
197
+ uri = URI.parse(location)
198
+ transport = redirect_transport(uri)
199
+ target = uri.request_uri
200
+ end
201
+ raise HttpError.new(302, path, "too many redirects")
202
+ rescue Exception # rubocop:disable Lint/RescueException
203
+ abandon_task(task_ref) if task_ref && !waiting
204
+ raise
205
+ end
206
+
207
+ # @return [String, nil] redirect location, or nil when done
208
+ def get_into(transport, target, path, dest, progress, cancel)
209
+ http = transport.new_connection(read_timeout: @client.task_timeout)
210
+ begin
211
+ req = Net::HTTP::Get.new(target)
212
+ req["Cookie"] = cookie
213
+ req["User-Agent"] = Client::ORIGINATOR
214
+ http.request(req) do |response|
215
+ return response["Location"] if response.is_a?(Net::HTTPRedirection) && response["Location"] # rubocop:disable Lint/NonLocalExitFromIterator
216
+
217
+ check!(response, path)
218
+ total = response["Content-Length"].to_i
219
+ done = 0
220
+ File.open(dest, "wb") do |f|
221
+ response.read_body do |chunk|
222
+ raise TaskCancelled, path if cancel&.call
223
+
224
+ f.write(chunk)
225
+ done += chunk.bytesize
226
+ progress&.call(done, total)
227
+ end
228
+ end
229
+ # Net::HTTP ignores an early end of the stream by default.
230
+ if response["Content-Length"] && done != total
231
+ raise ProtocolError, "the download of #{path} ended after #{done} of #{total} bytes"
232
+ end
233
+ end
234
+ nil
235
+ ensure
236
+ begin
237
+ http.finish if http.started?
238
+ rescue StandardError
239
+ nil
240
+ end
241
+ end
242
+ rescue OpenSSL::SSL::SSLError => e
243
+ raise TlsError.new(transport.host, e.message)
244
+ rescue *Transport::NETWORK_ERRORS => e
245
+ raise ConnectionError.new(transport.host, e.message)
246
+ end
247
+
248
+ # A redirect points at another pool host by address; find its ref so
249
+ # its certificate can be pinned. hostname, not host: an IPv6 address
250
+ # comes without its brackets, as host.get_address returns it.
251
+ def redirect_transport(uri)
252
+ host_ref = @client.call("host.get_all").find do |ref|
253
+ @client.call("host.get_address", ref) == uri.hostname
254
+ end
255
+ raise HttpError.new(302, uri.path, "redirect to unknown host #{uri.host}") unless host_ref
256
+
257
+ @client.transport_for(host_ref)
258
+ end
259
+
260
+ def perform(transport, req, path, cancel:, task_ref:)
261
+ http = transport.new_connection(read_timeout: @client.task_timeout)
262
+ watcher = start_cancel_watcher(http, cancel, task_ref)
263
+ begin
264
+ http.request(req)
265
+ ensure
266
+ watcher&.kill
267
+ begin
268
+ http.finish if http.started?
269
+ rescue StandardError
270
+ nil
271
+ end
272
+ end
273
+ rescue OpenSSL::SSL::SSLError => e
274
+ raise TaskCancelled, path if cancel&.call
275
+
276
+ raise TlsError.new(transport.host, e.message)
277
+ rescue *Transport::NETWORK_ERRORS => e
278
+ raise TaskCancelled, path if cancel&.call
279
+
280
+ raise ConnectionError.new(transport.host, e.message)
281
+ end
282
+
283
+ # On Ctrl-C, cancel the task and close the socket so the PUT stops.
284
+ def start_cancel_watcher(http, cancel, task_ref)
285
+ return nil unless cancel
286
+
287
+ Thread.new do
288
+ sleep 0.5 until cancel.call
289
+ cancel_task(task_ref)
290
+ begin
291
+ http.finish if http.started?
292
+ rescue StandardError
293
+ nil
294
+ end
295
+ end
296
+ end
297
+
298
+ def cancel_task(task_ref)
299
+ return unless task_ref
300
+
301
+ @client.call("task.cancel", task_ref)
302
+ rescue Error
303
+ nil
304
+ end
305
+
306
+ # A task we created but will not wait for: cancel and remove it.
307
+ def abandon_task(task_ref)
308
+ cancel_task(task_ref)
309
+ @client.call("task.destroy", task_ref)
310
+ rescue Error
311
+ nil
312
+ end
313
+
314
+ def check!(response, path)
315
+ return if response.is_a?(Net::HTTPSuccess)
316
+
317
+ body = begin
318
+ response.body
319
+ rescue StandardError
320
+ nil
321
+ end
322
+ raise HttpError.new(response.code, path, body)
323
+ end
324
+
325
+ def build_path(path, query)
326
+ q = query.compact.map { |k, v| "#{URI.encode_www_form_component(k)}=#{URI.encode_www_form_component(v)}" }
327
+ q.empty? ? path : "#{path}?#{q.join("&")}"
328
+ end
329
+ end
330
+ end
331
+ end
332
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module VagrantPlugins
6
+ module Xapi
7
+ module Api
8
+ # Keeps track of secrets (passwords, session ids) and removes them from
9
+ # any string before it is logged or shown. Process-wide, thread-safe.
10
+ module Redactor
11
+ MASK = "[REDACTED]"
12
+ @secrets = Set.new
13
+ @mutex = Mutex.new
14
+
15
+ class << self
16
+ def register(secret)
17
+ return if secret.nil?
18
+
19
+ s = secret.to_s
20
+ return if s.empty?
21
+
22
+ @mutex.synchronize { @secrets << s }
23
+ end
24
+
25
+ def unregister(secret)
26
+ @mutex.synchronize { @secrets.delete(secret.to_s) }
27
+ end
28
+
29
+ def scrub(value)
30
+ case value
31
+ when String
32
+ out = value.dup
33
+ secrets.each { |s| out = out.gsub(s, MASK) }
34
+ # session_id never belongs in a URL or a log line, nor do
35
+ # credentials in a URL (e.g. an xva_url).
36
+ out = out.gsub(/(session_id=)[^&\s"';]+/, "\\1#{MASK}")
37
+ out.gsub(%r{(\w+://)[^/\s:@]+:[^/\s@]+@}, "\\1#{MASK}@")
38
+ when Array
39
+ value.map { |v| scrub(v) }
40
+ when Hash
41
+ value.each_with_object({}) { |(k, v), h| h[k] = scrub(v) }
42
+ else
43
+ value
44
+ end
45
+ end
46
+
47
+ def secrets
48
+ @mutex.synchronize { @secrets.to_a.sort_by { |s| -s.length } }
49
+ end
50
+
51
+ # For tests.
52
+ def reset!
53
+ @mutex.synchronize { @secrets.clear }
54
+ end
55
+ end
56
+ end
57
+
58
+ # Minimal logger wrapper: everything passes through the Redactor first.
59
+ # Wraps any object with debug/info/warn/error (e.g. a Log4r::Logger);
60
+ # defaults to a no-op logger so the client works without Vagrant.
61
+ class SafeLogger
62
+ def initialize(inner = nil)
63
+ @inner = inner
64
+ end
65
+
66
+ %i[debug info warn error].each do |level|
67
+ define_method(level) do |msg = nil, &blk|
68
+ return unless @inner
69
+
70
+ msg = blk.call if msg.nil? && blk
71
+ @inner.public_send(level, Redactor.scrub(msg.to_s))
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "client"
6
+
7
+ module VagrantPlugins
8
+ module Xapi
9
+ module Api
10
+ # One logged-in Client per process per pool and user, shared between
11
+ # threads (Vagrant runs machines in parallel). All sessions are logged
12
+ # out when the process exits.
13
+ module SessionRegistry
14
+ @clients = {}
15
+ @mutex = Mutex.new
16
+ @at_exit_installed = false
17
+
18
+ class << self
19
+ # @param opts [Hash] Client.new options
20
+ def fetch(**opts)
21
+ # A digest of the password, so machines with other credentials for
22
+ # the same host and user do not share a session.
23
+ key = [opts[:host], opts[:port], opts[:username], Digest::SHA256.hexdigest(opts[:password].to_s),
24
+ opts[:tls].to_a.sort.to_s, opts[:api_timeout], opts[:task_timeout]].join("|")
25
+ client = @mutex.synchronize do
26
+ install_at_exit
27
+ @clients[key] ||= Client.new(**opts)
28
+ end
29
+ client.login
30
+ client
31
+ rescue Error
32
+ # Do not cache a client whose login failed (wrong password etc.).
33
+ @mutex.synchronize { @clients.delete(key) if @clients[key] && !@clients[key].logged_in? }
34
+ raise
35
+ end
36
+
37
+ def logout_all
38
+ clients = @mutex.synchronize { @clients.values.tap { @clients.clear } }
39
+ clients.each(&:logout)
40
+ end
41
+
42
+ # For tests.
43
+ def reset!
44
+ @mutex.synchronize { @clients.clear }
45
+ end
46
+
47
+ private
48
+
49
+ def install_at_exit
50
+ return if @at_exit_installed
51
+
52
+ @at_exit_installed = true
53
+ at_exit { logout_all }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module VagrantPlugins
6
+ module Xapi
7
+ module Api
8
+ # Waits for an XAPI task using event.from, reports progress, supports
9
+ # cancellation (task.cancel) and destroys the task afterwards.
10
+ class Task
11
+ EVENT_WAIT = 5.0 # seconds per event.from call
12
+ # How long the pool may be unreachable while a task runs. Giving up
13
+ # destroys the task record, but XAPI goes on with the work (e.g. a
14
+ # VM.copy whose result nobody would then mark or clean up), so a
15
+ # dropped connection or a read timeout is retried for a while first.
16
+ CONNECTION_GRACE = 60.0
17
+ RETRY_PAUSE = 2.0
18
+
19
+ # Result of a finished task. XAPI returns results as an XML-RPC-ish
20
+ # string, e.g. "<value>OpaqueRef:...</value>".
21
+ class Result
22
+ attr_reader :raw
23
+
24
+ def initialize(raw)
25
+ @raw = raw.to_s
26
+ end
27
+
28
+ # All OpaqueRefs in the result (VM.import returns several).
29
+ def refs
30
+ @raw.scan(/OpaqueRef:[0-9A-Za-z-]+/)
31
+ end
32
+
33
+ def ref
34
+ refs.first
35
+ end
36
+
37
+ # The first <value> content, or the raw string if it is not wrapped.
38
+ def value
39
+ m = @raw.match(%r{<value>(?:<string>)?([^<]*)(?:</string>)?</value>})
40
+ m ? m[1] : @raw
41
+ end
42
+ end
43
+
44
+ attr_reader :ref, :label
45
+
46
+ def initialize(client, ref, label:, logger: nil)
47
+ @client = client
48
+ @ref = ref
49
+ @label = label
50
+ @logger = logger || SafeLogger.new
51
+ end
52
+
53
+ # @param progress [#call] called with a Float 0.0..1.0
54
+ # @param cancel [#call] returns true when the user asked to cancel
55
+ def wait(timeout:, progress: nil, cancel: nil)
56
+ deadline = monotonic + timeout
57
+ token = ""
58
+ cancelled = false
59
+ use_events = true
60
+ last_progress = nil
61
+ @lost_since = nil
62
+
63
+ loop do
64
+ if cancel&.call && !cancelled
65
+ cancelled = true
66
+ @logger.info("cancelling task #{@label}")
67
+ begin
68
+ @client.call("task.cancel", @ref)
69
+ rescue Error => e
70
+ @logger.debug("task.cancel: #{e.message}")
71
+ end
72
+ end
73
+
74
+ record = through_connection_loss(deadline) { @client.call("task.get_record", @ref) }
75
+ next if record.nil?
76
+
77
+ status = record["status"].to_s.downcase
78
+ pct = record["progress"].to_f
79
+ if progress && pct != last_progress
80
+ last_progress = pct
81
+ progress.call(pct)
82
+ end
83
+
84
+ case status
85
+ when "success"
86
+ return Result.new(record["result"])
87
+ when "failure"
88
+ info = Array(record["error_info"])
89
+ raise TaskCancelled, @label if cancelled && info.first.to_s =~ /CANCELLED/
90
+
91
+ raise XapiError.new(info.first || "TASK_FAILED", info.drop(1), method_name: @label)
92
+ when "cancelled", "cancelling"
93
+ raise TaskCancelled, @label if status == "cancelled"
94
+ end
95
+
96
+ if monotonic > deadline
97
+ begin
98
+ @client.call("task.cancel", @ref)
99
+ rescue Error
100
+ nil
101
+ end
102
+ raise TaskTimeout.new(@label, timeout)
103
+ end
104
+
105
+ if use_events
106
+ begin
107
+ res = through_connection_loss(deadline) do
108
+ @client.call_with_timeout("event.from", EVENT_WAIT + @client.api_timeout,
109
+ ["task/#{@ref}"], token, EVENT_WAIT)
110
+ end
111
+ token = res["token"] if res.is_a?(Hash) && res["token"]
112
+ rescue XapiError => e
113
+ # EVENTS_LOST or RBAC: fall back to polling.
114
+ @logger.debug("event.from failed (#{e.code}); polling task instead")
115
+ use_events = false
116
+ end
117
+ else
118
+ sleep 1
119
+ end
120
+ end
121
+ ensure
122
+ destroy
123
+ end
124
+
125
+ private
126
+
127
+ # The block's result, or nil after a connection error within the
128
+ # grace period (the caller then polls again). Raises the error once
129
+ # the pool has been unreachable for CONNECTION_GRACE seconds or the
130
+ # task's deadline has passed.
131
+ def through_connection_loss(deadline)
132
+ result = yield
133
+ @lost_since = nil
134
+ result
135
+ rescue ConnectionError => e
136
+ @lost_since ||= monotonic
137
+ raise if monotonic - @lost_since >= CONNECTION_GRACE || monotonic > deadline
138
+
139
+ @logger.info("waiting for task #{@label}: #{e.message}; trying again")
140
+ sleep RETRY_PAUSE
141
+ nil
142
+ end
143
+
144
+ def destroy
145
+ @client.call("task.destroy", @ref)
146
+ rescue Error
147
+ nil
148
+ end
149
+
150
+ def monotonic
151
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end