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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/LICENSE +21 -0
- data/README.md +460 -4
- data/docs/architecture.md +1420 -0
- data/docs/operations.md +531 -0
- data/lib/vagrant-xapi/action/base.rb +103 -0
- data/lib/vagrant-xapi/action/cloud_init.rb +130 -0
- data/lib/vagrant-xapi/action/create.rb +127 -0
- data/lib/vagrant-xapi/action/destroy.rb +68 -0
- data/lib/vagrant-xapi/action/network.rb +229 -0
- data/lib/vagrant-xapi/action/package.rb +103 -0
- data/lib/vagrant-xapi/action/power.rb +185 -0
- data/lib/vagrant-xapi/action/snapshots.rb +43 -0
- data/lib/vagrant-xapi/action.rb +317 -0
- data/lib/vagrant-xapi/api/client.rb +393 -0
- data/lib/vagrant-xapi/api/errors.rb +98 -0
- data/lib/vagrant-xapi/api/http_transfer.rb +332 -0
- data/lib/vagrant-xapi/api/redactor.rb +77 -0
- data/lib/vagrant-xapi/api/session_registry.rb +59 -0
- data/lib/vagrant-xapi/api/task.rb +156 -0
- data/lib/vagrant-xapi/api/transport.rb +249 -0
- data/lib/vagrant-xapi/cap.rb +90 -0
- data/lib/vagrant-xapi/command/adopt.rb +92 -0
- data/lib/vagrant-xapi/command/base.rb +79 -0
- data/lib/vagrant-xapi/command/console.rb +87 -0
- data/lib/vagrant-xapi/command/diff.rb +267 -0
- data/lib/vagrant-xapi/command/hosts.rb +63 -0
- data/lib/vagrant-xapi/command/info.rb +94 -0
- data/lib/vagrant-xapi/command/marked.rb +85 -0
- data/lib/vagrant-xapi/command/orphans.rb +96 -0
- data/lib/vagrant-xapi/command/root.rb +55 -0
- data/lib/vagrant-xapi/command/templates.rb +91 -0
- data/lib/vagrant-xapi/config.rb +333 -0
- data/lib/vagrant-xapi/errors.rb +113 -0
- data/lib/vagrant-xapi/extensions.rb +80 -0
- data/lib/vagrant-xapi/glue.rb +330 -0
- data/lib/vagrant-xapi/legacy/plugin.rb +61 -0
- data/lib/vagrant-xapi/legacy/xenapi.rb +129 -0
- data/lib/vagrant-xapi/plugin.rb +58 -0
- data/lib/vagrant-xapi/provider.rb +135 -0
- data/lib/vagrant-xapi/services/adoption.rb +73 -0
- data/lib/vagrant-xapi/services/base.rb +242 -0
- data/lib/vagrant-xapi/services/box_source.rb +218 -0
- data/lib/vagrant-xapi/services/console.rb +115 -0
- data/lib/vagrant-xapi/services/content_diff.rb +96 -0
- data/lib/vagrant-xapi/services/destroyer.rb +104 -0
- data/lib/vagrant-xapi/services/diff_disks.rb +346 -0
- data/lib/vagrant-xapi/services/differ.rb +382 -0
- data/lib/vagrant-xapi/services/disk_image.rb +122 -0
- data/lib/vagrant-xapi/services/disks.rb +281 -0
- data/lib/vagrant-xapi/services/exporter.rb +128 -0
- data/lib/vagrant-xapi/services/file_diff.rb +249 -0
- data/lib/vagrant-xapi/services/guest_files.rb +730 -0
- data/lib/vagrant-xapi/services/hosts.rb +55 -0
- data/lib/vagrant-xapi/services/image_upload.rb +172 -0
- data/lib/vagrant-xapi/services/inventory.rb +225 -0
- data/lib/vagrant-xapi/services/ip_detector.rb +107 -0
- data/lib/vagrant-xapi/services/lock.rb +293 -0
- data/lib/vagrant-xapi/services/lookup.rb +181 -0
- data/lib/vagrant-xapi/services/networks.rb +248 -0
- data/lib/vagrant-xapi/services/power.rb +108 -0
- data/lib/vagrant-xapi/services/rfb.rb +129 -0
- data/lib/vagrant-xapi/services/snapshots.rb +81 -0
- data/lib/vagrant-xapi/services/template_cache.rb +263 -0
- data/lib/vagrant-xapi/services/tunnels.rb +217 -0
- data/lib/vagrant-xapi/services/vm_builder.rb +215 -0
- data/lib/vagrant-xapi/version.rb +7 -0
- data/lib/vagrant-xapi/xenserver/config.rb +69 -0
- data/lib/vagrant-xapi/xenserver/migrate.rb +103 -0
- data/lib/vagrant-xapi/xenserver/plugin.rb +96 -0
- data/lib/vagrant-xapi/xenserver/provider.rb +22 -0
- data/lib/vagrant-xapi/xenserver/takeover.rb +79 -0
- data/lib/vagrant-xapi.rb +21 -2
- data/lib/xenapi.rb +22 -0
- data/locales/en.yml +517 -0
- data/locales/legacy.en.yml +6 -0
- 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
|