foreman_nutanix 0.1.4 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0782547a714f12943e80c4f2e4eef7eeabec0dd586ffa5c1744156014b20d3f
4
- data.tar.gz: 66704a2b48bcc57bacb8a44f5a996b737ab671b0eb9923079793b92e164d2864
3
+ metadata.gz: 20eff365f58c477410d10f5b140961e4825738e49601442bec1740613aaf5704
4
+ data.tar.gz: bcefb95f0276c18905b569b187436a59a5e33276320b84238d4d6204e4ea3e6a
5
5
  SHA512:
6
- metadata.gz: 8f3f940f6dd0918586e800e10d40b0ca8afad77216fffc577671e69f96518c729bafdf1e694ebf2f13d62f7a2433deb0428ec5f788f5e843c729bc71fabe4325
7
- data.tar.gz: 4f42b6419740c82a050f71121f28e893dcd1e2322560db45dc435b9b157e2eeb9fad6e44377d76bb971f3f31e47f2b4b18a7ffa9e39e5c759316f91374983afc
6
+ metadata.gz: 461cc7ed6c08843073891195b480c8356c148fadf2d2825aacfb0c8284167967abe1e30e37ac37a93c8cb102d86dc75cbab37ab93bca3789b4707cf87f63365e
7
+ data.tar.gz: bfea4f00a1a9148d6568014c9904b1afaef15172fb70033f386132fe83fd438b89896c3ecc8e6139104874f961908f9f5154cf0f7263caa0a112b9e5fea9acd6
data/Rakefile CHANGED
@@ -20,8 +20,6 @@ RDoc::Task.new(:rdoc) do |rdoc|
20
20
  rdoc.rdoc_files.include('lib/**/*.rb')
21
21
  end
22
22
 
23
- APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
24
-
25
23
  Bundler::GemHelper.install_tasks
26
24
 
27
25
  require 'rake/testtask'
@@ -43,7 +41,3 @@ rescue StandardError => _e
43
41
  rescue LoadError
44
42
  puts 'Rubocop not loaded.'
45
43
  end
46
-
47
- task :default do
48
- Rake::Task['rubocop'].execute
49
- end
@@ -33,15 +33,13 @@ module ForemanNutanix
33
33
  Rails.logger.info "=== NUTANIX: ServersCollection::get called with uuid: #{uuid} ==="
34
34
 
35
35
  # Extract the actual UUID if it has a prefix
36
- actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
36
+ actual_uuid = ShimClient.normalize_uuid(uuid)
37
37
 
38
38
  # Fetch full VM details from shim server
39
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
40
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}")
41
- response = Net::HTTP.get_response(uri)
39
+ response = shim.get("/api/v1/vmm/vms/#{actual_uuid}")
42
40
 
43
- if response.is_a?(Net::HTTPSuccess)
44
- data = JSON.parse(response.body)
41
+ if response.success?
42
+ data = response.json
45
43
 
46
44
  total_cpus = (data['num_sockets'] || 1) * (data['num_cores_per_socket'] || 1)
47
45
  memory_gb = data['memory_size_bytes'] ? (data['memory_size_bytes'].to_f / 1024**3).round : 4
@@ -82,10 +80,7 @@ module ForemanNutanix
82
80
  Rails.logger.info "=== NUTANIX: ServersCollection::all called with opts: #{opts} ==="
83
81
 
84
82
  # Fetch VMs from shim server (now includes MAC and IP)
85
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
86
- uri = URI("#{base.chomp('/')}/api/v1/vmm/list-vms")
87
- response = Net::HTTP.get_response(uri)
88
- data = JSON.parse(response.body)
83
+ data = shim.get('/api/v1/vmm/list-vms').json
89
84
 
90
85
  # Filter VMs by cluster
91
86
  filtered_data = data.select { |vm| vm['cluster_ext_id'] == @cluster }
@@ -106,6 +101,7 @@ module ForemanNutanix
106
101
  ip_addresses: vm_data['ip_addresses'] || [],
107
102
  create_time: vm_data['create_time'],
108
103
  disk_size_gb: vm_data['disk_size_bytes'].to_i / (1024**3),
104
+ gpus: vm_data['gpus'],
109
105
  })
110
106
  vm.instance_variable_set(:@persisted, true)
111
107
  vm
@@ -114,6 +110,14 @@ module ForemanNutanix
114
110
  Rails.logger.error "=== NUTANIX: Error fetching VMs: #{e.message} ==="
115
111
  []
116
112
  end
113
+
114
+ private
115
+
116
+ # ServersCollection holds no reference to the ComputeResource, so it builds
117
+ # its own client off the shim server address in the environment.
118
+ def shim
119
+ @shim ||= ShimClient.new
120
+ end
117
121
  end
118
122
  end
119
123
  end
@@ -40,8 +40,9 @@ module ForemanNutanix
40
40
  "#{name} (#{provider_friendly_name})"
41
41
  end
42
42
 
43
+ # Kept public: app/views/compute_resources/show/_nutanix.html.erb renders it.
43
44
  def shim_server_url
44
- ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
45
+ ShimClient.default_base_url
45
46
  end
46
47
 
47
48
  def provided_attributes
@@ -56,10 +57,7 @@ module ForemanNutanix
56
57
 
57
58
  # Available clusters for selection
58
59
  def available_clusters
59
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
60
- uri = URI("#{base.chomp('/')}/api/v1/clustermgmt/list-clusters")
61
- response = Net::HTTP.get_response(uri)
62
- data = JSON.parse(response.body)
60
+ data = shim.get('/api/v1/clustermgmt/list-clusters').json
63
61
 
64
62
  data.map do |cluster|
65
63
  cluster[:name] = "#{cluster['name']} (#{cluster['arch']})"
@@ -80,10 +78,7 @@ module ForemanNutanix
80
78
  # Available networks for VMs
81
79
  def available_networks
82
80
  Rails.logger.info '=== NUTANIX: Fetching available networks from shim server ==='
83
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
84
- uri = URI("#{base.chomp('/')}/api/v1/networking/list-networks")
85
- response = Net::HTTP.get_response(uri)
86
- data = JSON.parse(response.body)
81
+ data = shim.get('/api/v1/networking/list-networks').json
87
82
 
88
83
  # Filter networks by the cluster associated with this compute resource
89
84
  cluster_ext_id = cluster
@@ -114,10 +109,7 @@ module ForemanNutanix
114
109
  # Available storage containers for VMs
115
110
  def available_storage_containers
116
111
  Rails.logger.info '=== NUTANIX: Fetching available storage containers from shim server ==='
117
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
118
- uri = URI("#{base.chomp('/')}/api/v1/clustermgmt/list-storage-containers")
119
- response = Net::HTTP.get_response(uri)
120
- data = JSON.parse(response.body)
112
+ data = shim.get('/api/v1/clustermgmt/list-storage-containers').json
121
113
 
122
114
  # Filter storage containers by the cluster associated with this compute resource
123
115
  cluster_ext_id = cluster
@@ -143,16 +135,55 @@ module ForemanNutanix
143
135
  []
144
136
  end
145
137
 
138
+ # Physical GPU profiles on this cluster, as options for the VM form's single
139
+ # GPU select.
140
+ #
141
+ # One select can only post one value, so the option id is a composite of the
142
+ # three things the shim's provision call needs:
143
+ # "<device_id>:<vendor_name>:<gpu_type>", e.g. "7864:NVIDIA:PASSTHROUGH_GRAPHICS".
144
+ # NutanixCompute#save splits it back apart.
145
+ #
146
+ # A profile ext_id is deliberately NOT the value: it pins the VM to one
147
+ # physical card, which fails the moment that card is busy. A device_id names
148
+ # a GPU model, so Nutanix schedules any free matching card.
149
+ def available_gpus
150
+ Rails.logger.info '=== NUTANIX: Fetching available GPU profiles from shim server ==='
151
+ cluster_ext_id = cluster
152
+ return [] unless cluster_ext_id
153
+
154
+ data = shim.get("/api/v1/clustermgmt/clusters/#{cluster_ext_id}/gpu-profiles").json
155
+
156
+ # Passthrough only, and only profiles whose composite round-trips. See
157
+ # ForemanNutanix::GpuProfile.usable? - an unusable profile offered here
158
+ # would provision a VM with no GPU and still report success.
159
+ filtered_data = data.select { |profile| GpuProfile.usable?(profile) }
160
+ Rails.logger.info "=== NUTANIX: GPU profiles - total: #{data.count}, usable: #{filtered_data.count} ==="
161
+
162
+ filtered_data.map do |profile|
163
+ OpenStruct.new({
164
+ id: GpuProfile.build(profile),
165
+ ext_id: profile['ext_id'],
166
+ name: GpuProfile.label(profile),
167
+ device_id: profile['device_id'],
168
+ device_name: profile['device_name'],
169
+ vendor_name: profile['vendor_name'],
170
+ gpu_type: profile['gpu_type'],
171
+ assignable: profile['assignable'],
172
+ is_in_use: profile['is_in_use'],
173
+ })
174
+ end
175
+ rescue StandardError => e
176
+ Rails.logger.error "=== NUTANIX: Error fetching GPU profiles: #{e.message} ==="
177
+ []
178
+ end
179
+
146
180
  # Cluster resource statistics (CPU, memory, storage usage)
147
181
  def cluster_resource_stats
148
182
  Rails.logger.info '=== NUTANIX: Fetching cluster resource stats from shim server ==='
149
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
150
183
  cluster_id = cluster
151
184
  return nil unless cluster_id
152
185
 
153
- uri = URI("#{base.chomp('/')}/api/v1/clustermgmt/clusters/#{cluster_id}/stats")
154
- response = Net::HTTP.get_response(uri)
155
- data = JSON.parse(response.body)
186
+ data = shim.get("/api/v1/clustermgmt/clusters/#{cluster_id}/stats").json
156
187
 
157
188
  OpenStruct.new(data)
158
189
  rescue StandardError => e
@@ -163,10 +194,7 @@ module ForemanNutanix
163
194
  # Available images
164
195
  def available_images(_opts = {})
165
196
  Rails.logger.info '=== NUTANIX: Fetching available images from shim server ==='
166
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
167
- uri = URI("#{base.chomp('/')}/api/v1/vmm/list-images")
168
- response = Net::HTTP.get_response(uri)
169
- data = JSON.parse(response.body)
197
+ data = shim.get('/api/v1/vmm/list-images').json
170
198
 
171
199
  # Filter images by cluster if cluster_location_ext_ids is available
172
200
  cluster_ext_id = cluster
@@ -286,20 +314,10 @@ module ForemanNutanix
286
314
  # Start VM - called by Foreman for power on
287
315
  def start_vm(uuid)
288
316
  Rails.logger.info "=== NUTANIX: START_VM CALLED with uuid: #{uuid} ==="
289
- actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
317
+ actual_uuid = ShimClient.normalize_uuid(uuid)
290
318
 
291
- base = shim_server_url
292
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
293
-
294
- http = Net::HTTP.new(uri.host, uri.port)
295
- http.use_ssl = uri.scheme == 'https'
296
-
297
- request = Net::HTTP::Post.new(uri.path)
298
- request['Content-Type'] = 'application/json'
299
- request.body = { action: 'POWER_ON' }.to_json
300
-
301
- response = http.request(request)
302
- response.is_a?(Net::HTTPSuccess)
319
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_ON' })
320
+ response.success?
303
321
  rescue StandardError => e
304
322
  Rails.logger.error "=== NUTANIX: START_VM ERROR: #{e.message} ==="
305
323
  raise e
@@ -308,20 +326,10 @@ module ForemanNutanix
308
326
  # Stop VM - called by Foreman for power off
309
327
  def stop_vm(uuid)
310
328
  Rails.logger.info "=== NUTANIX: STOP_VM CALLED with uuid: #{uuid} ==="
311
- actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
312
-
313
- base = shim_server_url
314
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
315
-
316
- http = Net::HTTP.new(uri.host, uri.port)
317
- http.use_ssl = uri.scheme == 'https'
318
-
319
- request = Net::HTTP::Post.new(uri.path)
320
- request['Content-Type'] = 'application/json'
321
- request.body = { action: 'POWER_OFF' }.to_json
329
+ actual_uuid = ShimClient.normalize_uuid(uuid)
322
330
 
323
- response = http.request(request)
324
- response.is_a?(Net::HTTPSuccess)
331
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_OFF' })
332
+ response.success?
325
333
  rescue StandardError => e
326
334
  Rails.logger.error "=== NUTANIX: STOP_VM ERROR: #{e.message} ==="
327
335
  raise e
@@ -331,14 +339,12 @@ module ForemanNutanix
331
339
  def vm_power_state(vm)
332
340
  Rails.logger.info "=== NUTANIX: VM_POWER_STATE CALLED for vm: #{vm} ==="
333
341
  uuid = vm.respond_to?(:identity) ? vm.identity : vm.to_s
334
- actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
342
+ actual_uuid = ShimClient.normalize_uuid(uuid)
335
343
 
336
- base = shim_server_url
337
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
338
- response = Net::HTTP.get_response(uri)
344
+ response = shim.get("/api/v1/vmm/vms/#{actual_uuid}/power-state")
339
345
 
340
- if response.is_a?(Net::HTTPSuccess)
341
- data = JSON.parse(response.body)
346
+ if response.success?
347
+ data = response.json
342
348
  state = data['power_state']
343
349
  Rails.logger.info "=== NUTANIX: VM_POWER_STATE returning: #{state} ==="
344
350
  # Return hash that Foreman expects
@@ -375,19 +381,12 @@ module ForemanNutanix
375
381
  return true if uuid.nil? || uuid.to_s.strip.empty?
376
382
 
377
383
  # Extract the actual UUID if it has a prefix
378
- actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
384
+ actual_uuid = ShimClient.normalize_uuid(uuid)
379
385
 
380
386
  # Call the shim server to delete the VM
381
- base = shim_server_url
382
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}")
387
+ response = shim.delete("/api/v1/vmm/vms/#{actual_uuid}")
383
388
 
384
- http = Net::HTTP.new(uri.host, uri.port)
385
- http.use_ssl = uri.scheme == 'https'
386
-
387
- request = Net::HTTP::Delete.new(uri.path)
388
- response = http.request(request)
389
-
390
- if response.is_a?(Net::HTTPNoContent) || response.is_a?(Net::HTTPSuccess)
389
+ if response.no_content? || response.success?
391
390
  Rails.logger.info "=== NUTANIX: VM #{actual_uuid} deleted successfully ==="
392
391
  true
393
392
  else
@@ -455,6 +454,13 @@ module ForemanNutanix
455
454
 
456
455
  private
457
456
 
457
+ # "NVIDIA Tesla T4 (2 assignable)". Every part is optional in the shim's
458
+ # payload, so degrade instead of rendering a blank option.
459
+
460
+ def shim
461
+ @shim ||= ShimClient.new(shim_server_url)
462
+ end
463
+
458
464
  def client
459
465
  Rails.logger.info "=== NUTANIX: Creating client for cluster #{cluster} ==="
460
466
  @client ||= NutanixAdapter.new(cluster)
@@ -7,7 +7,7 @@ module ForemanNutanix
7
7
  :storage_container_ext_id, :num_sockets, :num_cores_per_socket,
8
8
  :disk_size_bytes, :description, :network_id, :storage_container,
9
9
  :disk_size_gb, :mac_address, :vm_ip_addresses, :create_time,
10
- :boot_method, :secure_boot, :gpus
10
+ :boot_method, :secure_boot, :gpus, :gpu, :gpu_count
11
11
 
12
12
  def initialize(cluster = nil, args = {})
13
13
  Rails.logger.info "=== NUTANIX: NutanixCompute::initialize cluster=#{cluster} args=#{args} ==="
@@ -46,8 +46,17 @@ module ForemanNutanix
46
46
  @boot_method = args[:boot_method]
47
47
  @secure_boot = args[:secure_boot]
48
48
 
49
- # GPUs
50
- @gpus = args[:gpus]
49
+ # GPUs. Defaults to [] rather than nil: the shim's /list-vms response
50
+ # carries no gpus field, so VMs built by ServersCollection#all never
51
+ # receive one, and the index view calls #gpus.empty? on every row.
52
+ @gpus = args[:gpus] || []
53
+
54
+ # GPU passthrough selection. `gpu` is the composite the form's single
55
+ # select carries: "<device_id>:<vendor_name>:<gpu_type>". Both stay nil
56
+ # when nothing was selected, which is what keeps #save's payload
57
+ # byte-identical to a GPU-less provision.
58
+ @gpu = args[:gpu]
59
+ @gpu_count = args[:gpu_count]
51
60
  end
52
61
 
53
62
  # Required by Foreman - indicates if VM exists
@@ -96,23 +105,17 @@ module ForemanNutanix
96
105
  boot_method: @boot_method,
97
106
  }
98
107
 
108
+ # Adds nothing at all unless a GPU was actually selected, so a GPU-less
109
+ # provision posts exactly the request it always did.
110
+ provision_request.merge!(gpu_provision_fields)
111
+
99
112
  Rails.logger.info "=== NUTANIX: Provisioning VM with request: #{provision_request} ==="
100
113
 
101
114
  # Call the shim server to provision the VM
102
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
103
- uri = URI("#{base.chomp('/')}/api/v1/vmm/provision-vm")
104
-
105
- http = Net::HTTP.new(uri.host, uri.port)
106
- http.use_ssl = uri.scheme == 'https'
107
-
108
- request = Net::HTTP::Post.new(uri.path)
109
- request['Content-Type'] = 'application/json'
110
- request.body = provision_request.to_json
115
+ response = shim.post('/api/v1/vmm/provision-vm', provision_request)
111
116
 
112
- response = http.request(request)
113
-
114
- if response.is_a?(Net::HTTPSuccess)
115
- result = JSON.parse(response.body)
117
+ if response.success?
118
+ result = response.json
116
119
  Rails.logger.info "=== NUTANIX: VM provisioned successfully: #{result} ==="
117
120
 
118
121
  # Update identity with the real ext_id from Nutanix
@@ -176,22 +179,12 @@ module ForemanNutanix
176
179
  return false unless persisted?
177
180
 
178
181
  # Extract actual UUID (handle ZXJnb24=:uuid format)
179
- actual_uuid = @identity.to_s.include?(':') ? @identity.to_s.split(':').last : @identity.to_s
182
+ actual_uuid = ShimClient.normalize_uuid(@identity)
180
183
 
181
184
  # Call the shim server to power on the VM
182
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
183
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
184
-
185
- http = Net::HTTP.new(uri.host, uri.port)
186
- http.use_ssl = uri.scheme == 'https'
187
-
188
- request = Net::HTTP::Post.new(uri.path)
189
- request['Content-Type'] = 'application/json'
190
- request.body = { action: 'POWER_ON' }.to_json
185
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_ON' })
191
186
 
192
- response = http.request(request)
193
-
194
- if response.is_a?(Net::HTTPSuccess)
187
+ if response.success?
195
188
  Rails.logger.info "=== NUTANIX: VM #{actual_uuid} powered on successfully ==="
196
189
  @power_state = 'ON'
197
190
  true
@@ -211,22 +204,12 @@ module ForemanNutanix
211
204
  return false unless persisted?
212
205
 
213
206
  # Extract actual UUID (handle ZXJnb24=:uuid format)
214
- actual_uuid = @identity.to_s.include?(':') ? @identity.to_s.split(':').last : @identity.to_s
207
+ actual_uuid = ShimClient.normalize_uuid(@identity)
215
208
 
216
209
  # Call the shim server to power off the VM
217
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
218
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
219
-
220
- http = Net::HTTP.new(uri.host, uri.port)
221
- http.use_ssl = uri.scheme == 'https'
222
-
223
- request = Net::HTTP::Post.new(uri.path)
224
- request['Content-Type'] = 'application/json'
225
- request.body = { action: 'POWER_OFF' }.to_json
210
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_OFF' })
226
211
 
227
- response = http.request(request)
228
-
229
- if response.is_a?(Net::HTTPSuccess)
212
+ if response.success?
230
213
  Rails.logger.info "=== NUTANIX: VM #{actual_uuid} powered off successfully ==="
231
214
  @power_state = 'OFF'
232
215
  true
@@ -240,6 +223,20 @@ module ForemanNutanix
240
223
  raise e
241
224
  end
242
225
 
226
+ # Human-readable GPU labels for the views, e.g. "NVIDIA Tesla T4".
227
+ # The shim returns each GPU as a hash; a shim older than 0.1.5 omits the
228
+ # field on /list-vms entirely, which #initialize turns into [].
229
+ def gpu_labels
230
+ @gpus.map do |gpu|
231
+ next gpu.to_s unless gpu.is_a?(Hash)
232
+
233
+ name = gpu['name']
234
+ name = gpu['device_id'] if blank_gpu_field?(name)
235
+ label = [gpu['vendor'], name].reject { |part| blank_gpu_field?(part) }.join(' ')
236
+ label.empty? ? 'GPU' : label
237
+ end
238
+ end
239
+
243
240
  # Required by Foreman - CPU count
244
241
  def cpu
245
242
  Rails.logger.info '=== NUTANIX: NutanixCompute::cpu called ==='
@@ -377,11 +374,62 @@ module ForemanNutanix
377
374
  yield if block_given?
378
375
  end
379
376
 
377
+ # Deliberately not ActiveSupport's #blank?: this class is loaded without
378
+ # Rails in test/nutanix_compute_test.rb.
379
+ def blank_gpu_field?(value)
380
+ value.nil? || value.to_s.strip.empty?
381
+ end
382
+ private :blank_gpu_field?
383
+
380
384
  # Required by Foreman - destroy VM
381
385
  def destroy
382
386
  Rails.logger.info '=== NUTANIX: NutanixCompute::destroy called ==='
383
387
  @persisted = false
384
388
  true
385
389
  end
390
+
391
+ private
392
+
393
+ # The form's GPU select carries a single composite value,
394
+ # "<device_id>:<vendor_name>:<gpu_type>" (e.g. "7864:NVIDIA:PASSTHROUGH_GRAPHICS"),
395
+ # because one select cannot post three fields. Split it back apart here.
396
+ #
397
+ # Returns {} unless all three parts are present: a half-parsed composite
398
+ # must contribute nothing rather than provision a VM against a partially
399
+ # specified GPU. {} is also the no-GPU-selected case, which is what keeps
400
+ # the provision request byte-identical to a pre-GPU one.
401
+ def gpu_provision_fields
402
+ # Nothing selected is the common case and means exactly that.
403
+ return {} if GpuProfile.blank?(@gpu)
404
+
405
+ # Selected but unparseable is different: the user asked for a GPU. Dropping
406
+ # it here would build a GPU-less VM and report the build as successful, so
407
+ # fail the way a missing network or storage container does.
408
+ parts = GpuProfile.parse(@gpu)
409
+ if parts.nil?
410
+ raise StandardError, "Malformed GPU selection #{@gpu.inspect}; expected " \
411
+ "'<device_id>:<vendor>:<gpu_type>'"
412
+ end
413
+
414
+ device_id, vendor, mode = parts
415
+
416
+ # The count field is hidden until a GPU is picked, so it routinely arrives
417
+ # nil or empty; one GPU is what selecting a GPU means.
418
+ count = @gpu_count.to_i
419
+ count = 1 if count < 1
420
+
421
+ {
422
+ gpu_device_id: device_id.to_i,
423
+ gpu_vendor: vendor,
424
+ gpu_mode: mode,
425
+ gpu_count: count,
426
+ }
427
+ end
428
+
429
+ # NutanixCompute holds no reference to the ComputeResource, so it builds its
430
+ # own client off the shim server address in the environment.
431
+ def shim
432
+ @shim ||= ShimClient.new
433
+ end
386
434
  end
387
435
  end
@@ -66,6 +66,32 @@ compute_resource.available_storage_containers,
66
66
  max: 10_000,
67
67
  } %>
68
68
 
69
+ <%# One select for three shim fields: the option value is the composite
70
+ # "<device_id>:<vendor_name>:<gpu_type>". NutanixCompute#save splits it.
71
+ # Passthrough only - available_gpus filters vGPU profiles out. %>
72
+ <%= select_f f,
73
+ :gpu,
74
+ compute_resource.available_gpus,
75
+ :id,
76
+ :name,
77
+ { include_blank: _("No GPU"), selected: f.object.try(:gpu) },
78
+ {
79
+ label: _("GPU"),
80
+ help_inline: _("Passthrough GPU to attach when the VM is created"),
81
+ id: "gpu-select",
82
+ } %>
83
+
84
+ <div id="gpu-options" class="hidden">
85
+ <%= counter_f f,
86
+ :gpu_count,
87
+ {
88
+ label: _("GPU Count"),
89
+ help_inline: _("How many of the selected GPU to attach"),
90
+ min: 1,
91
+ max: 8,
92
+ } %>
93
+ </div>
94
+
69
95
  <%# `checked:` must reflect the saved value, else the box always renders
70
96
  # checked and the compute profile's "off" never survives a page render.
71
97
  # f.object is compute_resource.new_vm(vm_attrs); NutanixCompute has no :start
@@ -106,11 +132,20 @@ compute_resource.available_boot_methods,
106
132
 
107
133
  <script>
108
134
  var $bootMethod = $('#boot-method-select');
135
+ var $gpu = $('#gpu-select');
109
136
 
110
137
  function toggleUefiOptions() {
111
138
  $('#uefi-options').toggleClass('hidden', $bootMethod.val() !== 'uefi');
112
139
  }
113
140
 
141
+ // Same pattern as above: the count only means anything once a GPU is picked,
142
+ // and the blank "No GPU" option posts an empty value.
143
+ function toggleGpuOptions() {
144
+ $('#gpu-options').toggleClass('hidden', !$gpu.val());
145
+ }
146
+
114
147
  toggleUefiOptions();
148
+ toggleGpuOptions();
115
149
  $bootMethod.on('change', toggleUefiOptions);
150
+ $gpu.on('change', toggleGpuOptions);
116
151
  </script>
@@ -53,7 +53,8 @@
53
53
  </tr>
54
54
  <tr>
55
55
  <td><%= _("GPUs") %></td>
56
- <td><%= @vm.gpus %></td>
56
+ <% gpu_labels = @vm.gpu_labels %>
57
+ <td><%= gpu_labels.any? ? gpu_labels.join(", ") : _("None") %></td>
57
58
  </tr>
58
59
  </tbody>
59
60
  </table>
@@ -0,0 +1,54 @@
1
+ module ForemanNutanix
2
+ # The compute profile form is a single select, so a GPU choice is stored as one
3
+ # composite string: "<device_id>:<vendor_name>:<gpu_type>". This module owns that
4
+ # format at both ends - Nutanix#available_gpus builds it, NutanixCompute#save
5
+ # parses it - so the two can never disagree about it.
6
+ #
7
+ # Plain Ruby, no Rails: loaded directly by the standalone tests. See test/README.md.
8
+ module GpuProfile
9
+ SEPARATOR = ':'.freeze
10
+ PARTS = %w[device_id vendor_name gpu_type].freeze
11
+
12
+ module_function
13
+
14
+ def blank?(value)
15
+ value.nil? || value.to_s.strip.empty?
16
+ end
17
+
18
+ # Only offer a profile that can survive the round trip: passthrough (the shim
19
+ # rejects VIRTUAL), and every composite part present. A profile missing one
20
+ # part builds a composite that #parse rejects, which would provision a VM with
21
+ # no GPU while reporting success.
22
+ def usable?(profile)
23
+ return false unless profile[PARTS.last].to_s.start_with?('PASSTHROUGH')
24
+
25
+ PARTS.none? { |part| blank?(profile[part]) }
26
+ end
27
+
28
+ def build(profile)
29
+ PARTS.map { |part| profile[part] }.join(SEPARATOR)
30
+ end
31
+
32
+ # [device_id, vendor, mode], or nil if this is not a usable composite.
33
+ # Caller distinguishes "nothing selected" from "malformed" by checking blank?
34
+ # first - the two mean very different things at provision time.
35
+ def parse(composite)
36
+ return nil if blank?(composite)
37
+
38
+ parts = composite.to_s.split(SEPARATOR, PARTS.length)
39
+ return nil unless parts.length == PARTS.length
40
+ return nil if parts.any? { |part| blank?(part) }
41
+
42
+ parts
43
+ end
44
+
45
+ def label(profile)
46
+ name = [profile['vendor_name'], profile['device_name']]
47
+ .reject { |part| blank?(part) }
48
+ .join(' ')
49
+ name = "GPU #{profile['device_id']}" if name.empty?
50
+ assignable = profile['assignable']
51
+ assignable.nil? ? name : "#{name} (#{assignable} assignable)"
52
+ end
53
+ end
54
+ end