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,263 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "base"
6
+ require_relative "lock"
7
+ require_relative "lookup"
8
+ require_relative "box_source"
9
+ require_relative "image_upload"
10
+
11
+ module VagrantPlugins
12
+ module Xapi
13
+ module Services
14
+ # Imports each box exactly once per SR as an XAPI template and finds it
15
+ # again on later runs. Import happens under a pool-wide lock, so two
16
+ # processes (or users) never import the same box twice. A failed or
17
+ # interrupted import leaves nothing behind.
18
+ class TemplateCache
19
+ include Helpers
20
+
21
+ # @param converter [#call, nil] converter.call(src, dst) converts a
22
+ # qcow2 image to VHD (qemu-img); raises Services::Error if missing.
23
+ # @param salt [String, nil] extra input for the cache key: a new value
24
+ # makes the same box import again as a new template (for tests)
25
+ def initialize(ctx, digest_cache:, converter: nil, lock_wait: 7200, task_timeout: 3600, lock_poll: 2,
26
+ salt: nil)
27
+ @ctx = ctx
28
+ @client = ctx.client
29
+ @salt = salt.to_s.empty? ? nil : salt.to_s
30
+ @lookup = Lookup.new(ctx)
31
+ @digest_cache = digest_cache
32
+ @converter = converter
33
+ @lock_wait = lock_wait
34
+ @task_timeout = task_timeout
35
+ @lock_poll = lock_poll
36
+ end
37
+
38
+ def self.template_name(source)
39
+ "vagrant-xapi: #{source.name} #{source.version}".strip
40
+ end
41
+
42
+ # @return [String] template VM ref
43
+ # The template is shared by everyone who uses the box, so nothing of
44
+ # one user's configuration (e.g. firmware) is baked into it; firmware
45
+ # is set per VM.
46
+ def ensure_template(source, sr_ref)
47
+ sr_uuid = @client.call("SR.get_uuid", sr_ref)
48
+ @ctx.ui&.detail(:computing_digest) unless source.kind == :xva_url
49
+ digest = source.digest(@digest_cache)
50
+ digest = Digest::SHA256.hexdigest("#{@salt}:#{digest}") if @salt
51
+
52
+ found = find(source, digest, sr_uuid)
53
+ return found if found
54
+
55
+ # The holder keeps the lock alive with heartbeats, so the TTL only
56
+ # decides how fast the lock of a crashed process is taken over.
57
+ lock = Lock.new(@ctx, "template.#{digest}.#{sr_uuid}", ttl: 900, wait: @lock_wait,
58
+ poll: @lock_poll)
59
+ lock.synchronize do
60
+ found = find(source, digest, sr_uuid)
61
+ return found if found
62
+
63
+ @ctx.ui&.info(:importing_box, box: source.name, version: source.version,
64
+ content: source.description)
65
+ import(source, sr_ref, sr_uuid, digest)
66
+ end
67
+ end
68
+
69
+ # @return [String, nil]
70
+ def find(source, digest, sr_uuid)
71
+ candidates = @client.call("VM.get_by_name_label", self.class.template_name(source))
72
+ hit = candidates.find { |ref| matches?(ref, digest, sr_uuid) }
73
+ return hit if hit
74
+
75
+ # The box may have been renamed; search by digest.
76
+ all_templates.each do |ref, rec|
77
+ oc = rec["other_config"] || {}
78
+ return ref if oc[Marking::BOX_DIGEST] == digest && oc[Marking::SR] == sr_uuid
79
+ end
80
+ nil
81
+ end
82
+
83
+ # Templates carrying our marking: { ref => record }
84
+ def all_templates
85
+ recs = begin
86
+ @client.call("VM.get_all_records_where", 'field "is_a_template"="true"')
87
+ rescue Api::XapiError
88
+ @client.call("VM.get_all_records").select { |_r, v| v["is_a_template"] }
89
+ end
90
+ recs.select { |_r, v| v["is_a_template"] && !v["is_a_snapshot"] && (v["other_config"] || {})[Marking::BOX_DIGEST] }
91
+ end
92
+
93
+ private
94
+
95
+ def matches?(ref, digest, sr_uuid)
96
+ rec = @client.call("VM.get_record", ref)
97
+ oc = rec["other_config"] || {}
98
+ rec["is_a_template"] && oc[Marking::BOX_DIGEST] == digest && oc[Marking::SR] == sr_uuid
99
+ end
100
+
101
+ def import(source, sr_ref, sr_uuid, digest)
102
+ created = { vms: [], vdis: [] }
103
+ begin
104
+ vm = case source.kind
105
+ when :xva then import_xva(source, sr_ref, created)
106
+ when :xva_url then import_xva_url(source, sr_ref, created)
107
+ else import_disks(source, sr_ref, created)
108
+ end
109
+ raise Api::TaskCancelled, "import" if @ctx.cancelled?
110
+
111
+ finish_template(vm, source, sr_uuid, digest)
112
+ vm
113
+ rescue Exception # rubocop:disable Lint/RescueException
114
+ cleanup(created)
115
+ raise
116
+ end
117
+ end
118
+
119
+ def import_xva(source, sr_ref, created)
120
+ result = @client.http.upload(
121
+ "/import", { "sr_id" => sr_ref }, source.xva_path,
122
+ host: @lookup.transfer_host_for_sr(sr_ref),
123
+ progress: @ctx.progress_proc(:uploading, file: File.basename(source.xva_path)),
124
+ task_label: "vagrant-xapi import #{source.name}",
125
+ cancel: @ctx.cancel
126
+ )
127
+ @ctx.ui&.clear_progress
128
+ refs = result ? result.refs : []
129
+ created[:vms].concat(refs)
130
+ raise Error.new(:import_no_vm, box: source.name) if refs.empty?
131
+
132
+ refs.first
133
+ end
134
+
135
+ def import_xva_url(source, sr_ref, created)
136
+ @ctx.ui&.warn(:insecure_xva_url, url: source.xva_url) if source.xva_url.start_with?("http://")
137
+ result = @ctx.run_task("VM.import", source.xva_url, sr_ref, false, false,
138
+ label: "import #{source.name}", progress_key: :host_downloading)
139
+ refs = result.refs
140
+ created[:vms].concat(refs)
141
+ raise Error.new(:import_no_vm, box: source.name) if refs.empty?
142
+
143
+ refs.first
144
+ end
145
+
146
+ def import_disks(source, sr_ref, created)
147
+ vdis = source.disks.each_with_index.map do |disk, i|
148
+ vdi = create_vdi(sr_ref, "#{source.name} #{source.version} disk #{i}", disk.virtual_size,
149
+ description: "vagrant-xapi box image")
150
+ created[:vdis] << vdi
151
+ vdi = upload_disk(vdi, disk) # may be a new VDI after a VHD fallback
152
+ created[:vdis] << vdi
153
+ vdi
154
+ end
155
+
156
+ base = @lookup.other_install_media_template
157
+ vm = @client.call("VM.clone", base, self.class.template_name(source))
158
+ created[:vms] << vm
159
+ # "Other install media" would create an empty disk on provision.
160
+ remove_oc("VM", vm, "disks")
161
+ remove_oc("VM", vm, "install-methods")
162
+
163
+ vdis.each_with_index do |vdi, i|
164
+ @client.call("VBD.create", vbd_record(vm, vdi, i))
165
+ end
166
+ @client.call("VM.set_HVM_boot_policy", vm, "BIOS order")
167
+ @client.call("VM.set_HVM_boot_params", vm, { "order" => "cd", "firmware" => "bios" })
168
+ raise_memory(vm)
169
+ vm
170
+ end
171
+
172
+ # "Other install media" starts with 256 MiB: too little for a current
173
+ # distribution, and under UEFI GRUB cannot even load the initramfs
174
+ # ("error: out of memory"). A box with a disk image carries no memory
175
+ # setting, so its template gets this unless it already has more; a
176
+ # Vagrantfile still sets `memory` per VM.
177
+ DISK_IMAGE_MEMORY = 1024 * 1024 * 1024
178
+
179
+ def raise_memory(vm)
180
+ rec = @client.call("VM.get_record", vm)
181
+ return if rec["memory_static_max"].to_i >= DISK_IMAGE_MEMORY
182
+
183
+ static_min = [rec["memory_static_min"].to_i, DISK_IMAGE_MEMORY].min
184
+ @client.call("VM.set_memory_limits", vm, static_min, DISK_IMAGE_MEMORY, DISK_IMAGE_MEMORY,
185
+ DISK_IMAGE_MEMORY)
186
+ end
187
+
188
+ def upload_disk(vdi, disk)
189
+ ImageUpload.new(@ctx, converter: @converter)
190
+ .upload(vdi, disk.path, disk.format, label: "vagrant-xapi import_raw_vdi")
191
+ end
192
+
193
+ def vbd_record(vm, vdi, index)
194
+ {
195
+ "VM" => vm,
196
+ "VDI" => vdi,
197
+ "userdevice" => index.to_s,
198
+ "bootable" => index.zero?,
199
+ "mode" => "RW",
200
+ "type" => "Disk",
201
+ "unpluggable" => false,
202
+ "empty" => false,
203
+ "other_config" => {},
204
+ "qos_algorithm_type" => "",
205
+ "qos_algorithm_params" => {}
206
+ }
207
+ end
208
+
209
+ def finish_template(vm, source, sr_uuid, digest)
210
+ @client.call("VM.set_is_a_template", vm, true) unless @client.call("VM.get_is_a_template", vm)
211
+ convert_pv_to_hvm(vm)
212
+ @client.call("VM.set_name_label", vm, self.class.template_name(source))
213
+ @client.call("VM.set_name_description", vm,
214
+ "Box #{source.name} #{source.version} (#{source.provider}), imported by vagrant-xapi")
215
+ template_uuid = @client.call("VM.get_uuid", vm)
216
+ disk_vdis(vm).each do |vdi|
217
+ set_oc("VDI", vdi, Marking::OF_TEMPLATE, template_uuid)
218
+ end
219
+ {
220
+ Marking::BOX => source.name,
221
+ Marking::BOX_VERSION => source.version,
222
+ Marking::BOX_PROVIDER => source.provider,
223
+ Marking::BOX_DIGEST => digest,
224
+ Marking::SR => sr_uuid
225
+ }.each { |k, v| set_oc("VM", vm, k, v) }
226
+ end
227
+
228
+ # PV guests are not supported; boxes of the old plugin that used
229
+ # pygrub are switched to HVM.
230
+ def convert_pv_to_hvm(vm)
231
+ rec = @client.call("VM.get_record", vm)
232
+ return unless rec["HVM_boot_policy"].to_s.empty?
233
+
234
+ @ctx.ui&.warn(:pv_box_converted)
235
+ @client.call("VM.set_PV_bootloader", vm, "")
236
+ @client.call("VM.set_HVM_boot_policy", vm, "BIOS order")
237
+ params = (rec["HVM_boot_params"] || {}).merge("order" => "cd")
238
+ @client.call("VM.set_HVM_boot_params", vm, params)
239
+ end
240
+
241
+ def cleanup(created)
242
+ created[:vms].uniq.each do |vm|
243
+ begin
244
+ disk_vdis(vm).each { |vdi| created[:vdis] << vdi }
245
+ rescue Api::Error
246
+ nil
247
+ end
248
+ begin
249
+ @client.call("VM.destroy", vm)
250
+ rescue Api::Error => e
251
+ @ctx.logger&.warn("cleanup of half-imported VM failed: #{e.message}")
252
+ end
253
+ end
254
+ created[:vdis].uniq.each do |vdi|
255
+ @client.call("VDI.destroy", vdi)
256
+ rescue Api::Error => e
257
+ @ctx.logger&.warn("cleanup of half-imported VDI failed: #{e.message}")
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
263
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "open3"
6
+ require "pathname"
7
+
8
+ require_relative "base"
9
+
10
+ module VagrantPlugins
11
+ module Xapi
12
+ module Services
13
+ # Port forwarding through SSH tunnels from the local machine, like
14
+ # vagrant-libvirt: one background `ssh -N -L host_ip:host:guest_ip:guest`
15
+ # per forwarded port. PIDs are kept in the machine's data_dir.
16
+ #
17
+ # Processes are started with an argv array, never through a shell.
18
+ class Tunnels
19
+ # host is the port to listen on. configured_host is the host port in
20
+ # the Vagrantfile; the two differ when Vagrant auto-corrected a
21
+ # collision at boot (auto_correct: true).
22
+ Forward = Struct.new(:id, :host_ip, :host, :guest_ip, :guest, :configured_host, :auto_correct,
23
+ keyword_init: true) do
24
+ def configured_port
25
+ (configured_host || host).to_i
26
+ end
27
+ end
28
+
29
+ # @param ssh [Hash] :host, :port, :username, :private_key_path (Array),
30
+ # :proxy_command (String, nil)
31
+ def initialize(data_dir, ssh_binary: "ssh", spawner: nil, logger: nil)
32
+ @data_dir = Pathname.new(data_dir.to_s)
33
+ @ssh_binary = ssh_binary
34
+ @spawner = spawner || ->(argv, opts) { Process.spawn(*argv, **opts) }
35
+ @logger = logger
36
+ end
37
+
38
+ def state_file
39
+ @data_dir.join("forwarded_ports.json")
40
+ end
41
+
42
+ def log_file
43
+ @data_dir.join("tunnels.log")
44
+ end
45
+
46
+ # @return [Array<Hash>] running tunnels
47
+ # Tunnels whose PID is alive and still an ssh process (after a reboot
48
+ # a saved PID can belong to something else).
49
+ def running
50
+ read.select { |t| alive?(t["pid"]) && ssh_process?(t["pid"].to_i) }
51
+ end
52
+
53
+ # host port => guest port, for `vagrant port`
54
+ def forwarded_ports
55
+ running.to_h { |t| [t["host"].to_i, t["guest"].to_i] }
56
+ end
57
+
58
+ # What sync does: running tunnels that serve one of the forwards
59
+ # exactly stay up (keep), the other running tunnels are stopped
60
+ # (stop), and forwards without such a tunnel are started (start).
61
+ # Pass the forwards through with_saved_ports first, so a port that
62
+ # was auto-corrected at boot counts as unchanged.
63
+ Plan = Struct.new(:keep, :stop, :start, keyword_init: true) do
64
+ def noop?
65
+ stop.empty? && start.empty?
66
+ end
67
+ end
68
+
69
+ def plan(forwards)
70
+ live = running
71
+ keep = live.select { |t| forwards.any? { |f| serves?(t, f) } }
72
+ start = forwards.reject { |f| keep.any? { |t| serves?(t, f) } }
73
+ Plan.new(keep: keep, stop: live - keep, start: start)
74
+ end
75
+
76
+ # The forwards with the host port Vagrant auto-corrected when they
77
+ # were last started, so a running VM keeps that port. Only for a
78
+ # forward that still has auto_correct and the same host port in the
79
+ # Vagrantfile; any other forward keeps the Vagrantfile's port.
80
+ def with_saved_ports(forwards)
81
+ saved = read
82
+ forwards.map do |fwd|
83
+ next fwd unless fwd.auto_correct
84
+
85
+ last = saved.find { |t| t["id"] == fwd.id.to_s && t["configured_host"].to_i == fwd.configured_port }
86
+ last ? fwd.dup.tap { |f| f.host = last["host"].to_i } : fwd
87
+ end
88
+ end
89
+
90
+ # Stops every tunnel, then starts one per forward.
91
+ def start(forwards, ssh)
92
+ stop
93
+ sync(Plan.new(keep: [], stop: [], start: forwards), ssh)
94
+ end
95
+
96
+ # Carries out a plan: tunnels in plan.keep are not touched. When a
97
+ # new tunnel dies at once, the others stay up and tunnel_failed is
98
+ # raised. Returns the started tunnels.
99
+ def sync(plan, ssh)
100
+ plan.stop.each { |t| kill(t["pid"].to_i) }
101
+ FileUtils.mkdir_p(@data_dir)
102
+ if plan.start.empty?
103
+ write(plan.keep)
104
+ return []
105
+ end
106
+
107
+ started = plan.start.map { |fwd| spawn(fwd, ssh) }
108
+ write(plan.keep + started)
109
+ sleep 1
110
+ dead = started.reject { |t| alive?(t["pid"]) }
111
+ return started if dead.empty?
112
+
113
+ write(plan.keep + (started - dead))
114
+ raise Error.new(:tunnel_failed, ports: dead.map { |t| t["host"] }.join(", "), log: log_file.to_s)
115
+ end
116
+
117
+ def stop
118
+ read.each { |t| kill(t["pid"].to_i) }
119
+ FileUtils.rm_f(state_file)
120
+ end
121
+
122
+ def command(fwd, ssh)
123
+ argv = [
124
+ @ssh_binary, "-N", "-T",
125
+ "-L", "#{bracket(fwd.host_ip)}:#{fwd.host}:#{bracket(fwd.guest_ip)}:#{fwd.guest}",
126
+ "-p", ssh[:port].to_s,
127
+ "-l", ssh[:username].to_s,
128
+ "-o", "ExitOnForwardFailure=yes",
129
+ "-o", "ServerAliveInterval=30",
130
+ "-o", "ServerAliveCountMax=3",
131
+ "-o", "ControlMaster=no",
132
+ "-o", "LogLevel=ERROR",
133
+ "-o", "BatchMode=yes",
134
+ # Same as Vagrant's own default for guests.
135
+ "-o", "StrictHostKeyChecking=no",
136
+ "-o", "UserKnownHostsFile=/dev/null"
137
+ ]
138
+ keys = Array(ssh[:private_key_path])
139
+ argv += ["-o", "IdentitiesOnly=yes"] unless keys.empty?
140
+ keys.each { |k| argv += ["-i", k.to_s] }
141
+ argv += ["-o", "ProxyCommand=#{ssh[:proxy_command]}"] if ssh[:proxy_command]
142
+ argv << ssh[:host].to_s
143
+ argv
144
+ end
145
+
146
+ private
147
+
148
+ def serves?(tunnel, fwd)
149
+ tunnel["id"] == fwd.id.to_s && tunnel["host_ip"] == fwd.host_ip && tunnel["host"].to_i == fwd.host &&
150
+ tunnel["guest_ip"] == fwd.guest_ip && tunnel["guest"].to_i == fwd.guest
151
+ end
152
+
153
+ def spawn(fwd, ssh)
154
+ pid = @spawner.call(command(fwd, ssh), in: File::NULL, out: File::NULL,
155
+ err: [log_file.to_s, "a"], pgroup: true)
156
+ Process.detach(pid) if pid.is_a?(Integer) && pid.positive?
157
+ {
158
+ "pid" => pid, "id" => fwd.id.to_s, "host_ip" => fwd.host_ip, "host" => fwd.host,
159
+ "configured_host" => fwd.configured_port, "guest_ip" => fwd.guest_ip, "guest" => fwd.guest
160
+ }
161
+ end
162
+
163
+ def kill(pid)
164
+ return unless pid.positive? && alive?(pid) && ssh_process?(pid)
165
+
166
+ Process.kill("TERM", pid)
167
+ rescue Errno::ESRCH, Errno::EPERM
168
+ nil
169
+ end
170
+
171
+ # ssh needs IPv6 addresses in brackets inside a -L spec.
172
+ def bracket(address)
173
+ s = address.to_s
174
+ s.include?(":") && !s.start_with?("[") ? "[#{s}]" : s
175
+ end
176
+
177
+ def read
178
+ return [] unless state_file.file?
179
+
180
+ JSON.parse(state_file.read)
181
+ rescue JSON::ParserError
182
+ []
183
+ end
184
+
185
+ def write(list)
186
+ File.write(state_file, JSON.pretty_generate(list))
187
+ end
188
+
189
+ def alive?(pid)
190
+ pid = pid.to_i
191
+ return false unless pid.positive?
192
+
193
+ Process.kill(0, pid)
194
+ true
195
+ rescue Errno::ESRCH
196
+ false
197
+ rescue Errno::EPERM
198
+ true
199
+ end
200
+
201
+ # Guard against PID reuse: on Linux check that it is still ssh.
202
+ def ssh_process?(pid)
203
+ cmdline = "/proc/#{pid}/cmdline"
204
+ return File.read(cmdline).split("\0").first.to_s.end_with?("ssh") if File.exist?(cmdline)
205
+
206
+ # macOS and BSD: ask ps (argv array, no shell).
207
+ out, = Open3.capture2("ps", "-o", "comm=", "-p", pid.to_s)
208
+ return true if out.strip.empty? # cannot tell; keep it
209
+
210
+ out.strip.end_with?("ssh")
211
+ rescue StandardError
212
+ true
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "lookup"
5
+
6
+ module VagrantPlugins
7
+ module Xapi
8
+ module Services
9
+ # What the VM should look like. nil means "keep what the template has".
10
+ VmSpec = Struct.new(
11
+ :name_label, :description, :cpus, :cores_per_socket, :memory, :memory_dynamic_min,
12
+ :firmware, :secure_boot, :vtpm, :affinity_host, :tags, :platform, :other_config,
13
+ :marking, keyword_init: true
14
+ )
15
+
16
+ # Creates VMs from templates and (re)applies CPU, memory, firmware and
17
+ # the other settings from the provider config.
18
+ class VmBuilder
19
+ include Helpers
20
+
21
+ MIB = 1024 * 1024
22
+
23
+ def initialize(ctx)
24
+ @ctx = ctx
25
+ @client = ctx.client
26
+ @lookup = Lookup.new(ctx)
27
+ end
28
+
29
+ # @param clone_mode [Symbol] :fast (VM.clone) or :full (VM.copy to sr)
30
+ # @return [String] VM ref
31
+ def create(template, spec, clone_mode:, sr_ref:)
32
+ vm = nil
33
+ @ctx.ui&.info(:creating_vm, name: spec.name_label)
34
+ result = if clone_mode.to_sym == :full
35
+ @ctx.run_task("VM.copy", template, spec.name_label, sr_ref,
36
+ label: "copy template", progress_key: :copying)
37
+ else
38
+ @ctx.run_task("VM.clone", template, spec.name_label, label: "clone template")
39
+ end
40
+ vm = result.ref
41
+ raise Error.new(:clone_failed) if vm.nil?
42
+
43
+ @ctx.run_task("VM.provision", vm, label: "provision")
44
+ @client.call("VM.set_is_a_template", vm, false) if @client.call("VM.get_is_a_template", vm)
45
+ mark(vm, template, spec)
46
+ apply(vm, spec)
47
+ vm
48
+ rescue Exception # rubocop:disable Lint/RescueException
49
+ destroy_partial(vm) if vm
50
+ raise
51
+ end
52
+
53
+ # Applies the VmSpec to a halted VM (also used by reload).
54
+ def apply(vm, spec)
55
+ rec = @client.call("VM.get_record", vm)
56
+ check_cores_per_socket!(rec, spec)
57
+ clear_cores_per_socket(vm, rec, spec)
58
+ apply_cpus(vm, rec, spec)
59
+ apply_memory(vm, rec, spec)
60
+ apply_firmware(vm, rec, spec)
61
+ apply_platform(vm, rec, spec)
62
+ apply_vtpm(vm, rec, spec)
63
+ apply_affinity(vm, spec)
64
+ apply_tags(vm, rec, spec)
65
+ (spec.other_config || {}).each { |k, v| set_oc("VM", vm, k.to_s, v) }
66
+ @client.call("VM.set_name_description", vm, spec.description) if spec.description
67
+ end
68
+
69
+ private
70
+
71
+ def mark(vm, template, spec)
72
+ oc = @client.call("VM.get_other_config", vm)
73
+ Marking::TEMPLATE_KEYS.each { |k| remove_oc("VM", vm, k) if oc.key?(k) }
74
+ (spec.marking || {}).each { |k, v| set_oc("VM", vm, k, v) }
75
+ set_oc("VM", vm, Marking::TEMPLATE_UUID, @client.call("VM.get_uuid", template))
76
+ @client.call("VM.add_tags", vm, Marking::TAG)
77
+
78
+ vm_uuid = @client.call("VM.get_uuid", vm)
79
+ disk_vdis(vm).each_with_index do |vdi, i|
80
+ remove_oc("VDI", vdi, Marking::OF_TEMPLATE)
81
+ set_oc("VDI", vdi, Marking::OWNER_VM, vm_uuid)
82
+ @client.call("VDI.set_name_label", vdi, "#{spec.name_label} disk #{i}")
83
+ end
84
+ end
85
+
86
+ def apply_cpus(vm, rec, spec)
87
+ return if spec.cpus.nil?
88
+
89
+ want = spec.cpus.to_i
90
+ max = rec["VCPUs_max"].to_i
91
+ if want > max
92
+ @client.call("VM.set_VCPUs_max", vm, want)
93
+ @client.call("VM.set_VCPUs_at_startup", vm, want)
94
+ elsif want < max || rec["VCPUs_at_startup"].to_i != want
95
+ @client.call("VM.set_VCPUs_at_startup", vm, want)
96
+ @client.call("VM.set_VCPUs_max", vm, want)
97
+ end
98
+ end
99
+
100
+ # Without cpus, cores_per_socket must fit the template's CPU count
101
+ # (with cpus, Config#validate already checks it).
102
+ def check_cores_per_socket!(rec, spec)
103
+ return unless spec.cores_per_socket && spec.cpus.nil?
104
+
105
+ vcpus = rec["VCPUs_max"].to_i
106
+ return if vcpus.positive? && (vcpus % spec.cores_per_socket.to_i).zero?
107
+
108
+ raise Error.new(:cores_per_socket_mismatch, cores: spec.cores_per_socket, cpus: vcpus)
109
+ end
110
+
111
+ # platform:cores-per-socket must divide VCPUs_max. Remove it before the
112
+ # CPU count changes; apply_platform sets the new value afterwards. A
113
+ # template value that does not divide the new count is dropped.
114
+ def clear_cores_per_socket(vm, rec, spec)
115
+ current = (rec["platform"] || {})["cores-per-socket"]
116
+ return if current.nil?
117
+
118
+ cores = current.to_i
119
+ divides = cores.positive? && spec.cpus && (spec.cpus.to_i % cores).zero?
120
+ return unless spec.cores_per_socket || (spec.cpus && !divides)
121
+
122
+ begin
123
+ @client.call("VM.remove_from_platform", vm, "cores-per-socket")
124
+ rescue Api::XapiError
125
+ nil
126
+ end
127
+ end
128
+
129
+ def apply_memory(vm, rec, spec)
130
+ return if spec.memory.nil? && spec.memory_dynamic_min.nil?
131
+
132
+ static_max = spec.memory ? spec.memory.to_i * MIB : rec["memory_static_max"].to_i
133
+ dyn_min = spec.memory_dynamic_min ? spec.memory_dynamic_min.to_i * MIB : static_max
134
+ dyn_min = static_max if dyn_min > static_max
135
+ static_min = [rec["memory_static_min"].to_i, dyn_min].min
136
+ static_min = dyn_min if static_min <= 0
137
+ @client.call("VM.set_memory_limits", vm, static_min, static_max, dyn_min, static_max)
138
+ end
139
+
140
+ def apply_firmware(vm, rec, spec)
141
+ return if spec.firmware.nil?
142
+
143
+ params = (rec["HVM_boot_params"] || {}).dup
144
+ params["firmware"] = spec.firmware.to_s
145
+ params["order"] ||= "cd"
146
+ @client.call("VM.set_HVM_boot_params", vm, params)
147
+ @client.call("VM.set_HVM_boot_policy", vm, "BIOS order") if rec["HVM_boot_policy"].to_s.empty?
148
+ end
149
+
150
+ def apply_platform(vm, _rec, spec)
151
+ platform = {}
152
+ platform["cores-per-socket"] = spec.cores_per_socket.to_s if spec.cores_per_socket
153
+ platform["secureboot"] = spec.secure_boot ? "true" : "false" unless spec.secure_boot.nil?
154
+ (spec.platform || {}).each { |k, v| platform[k.to_s] = v.to_s }
155
+ platform.each do |k, v|
156
+ begin
157
+ @client.call("VM.remove_from_platform", vm, k)
158
+ rescue Api::XapiError
159
+ nil
160
+ end
161
+ @client.call("VM.add_to_platform", vm, k, v)
162
+ end
163
+ end
164
+
165
+ def apply_vtpm(vm, rec, spec)
166
+ return remove_vtpm(vm, rec) unless spec.vtpm
167
+
168
+ raise Error.new(:vtpm_unsupported) unless @client.feature?(:vtpm)
169
+
170
+ existing = begin
171
+ @client.call("VM.get_VTPMs", vm)
172
+ rescue Api::XapiError
173
+ []
174
+ end
175
+ return unless existing.empty?
176
+
177
+ @client.call("VTPM.create", vm, false)
178
+ set_oc("VM", vm, Marking::VTPM, "true")
179
+ end
180
+
181
+ # With `vtpm` off, removes the vTPM the plugin created, with its
182
+ # contents. One that came with the box or an adopted VM stays: keys
183
+ # sealed in it may protect the guest's disks.
184
+ def remove_vtpm(vm, rec)
185
+ return unless (rec["other_config"] || {})[Marking::VTPM] == "true"
186
+
187
+ @client.call("VM.get_VTPMs", vm).each { |vtpm| @client.call("VTPM.destroy", vtpm) }
188
+ remove_oc("VM", vm, Marking::VTPM)
189
+ end
190
+
191
+ def apply_affinity(vm, spec)
192
+ host = spec.affinity_host ? @lookup.host(spec.affinity_host) : NULL_REF
193
+ @client.call("VM.set_affinity", vm, host)
194
+ end
195
+
196
+ def apply_tags(vm, rec, spec)
197
+ current = rec["tags"] || []
198
+ ([Marking::TAG] + Array(spec.tags).map(&:to_s)).uniq.each do |tag|
199
+ @client.call("VM.add_tags", vm, tag) unless current.include?(tag)
200
+ end
201
+ end
202
+
203
+ def destroy_partial(vm)
204
+ vdis = disk_vdis(vm)
205
+ @client.call("VM.destroy", vm)
206
+ vdis.each do |vdi|
207
+ ignore_missing { @client.call("VDI.destroy", vdi) }
208
+ end
209
+ rescue Api::Error => e
210
+ @ctx.logger&.warn("cleanup of half-created VM failed: #{e.message}")
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end