foreman_nutanix 0.1.4 → 0.1.5

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: e4f699c1534db4e1dc7d31dab1ed7af4ea04f69ec364f1ade64b243c8b3ab19d
4
+ data.tar.gz: 5c82e86ec3fdd45fc9a282cdf7d5c688b16931fa656b7dda6407175bf48d551d
5
5
  SHA512:
6
- metadata.gz: 8f3f940f6dd0918586e800e10d40b0ca8afad77216fffc577671e69f96518c729bafdf1e694ebf2f13d62f7a2433deb0428ec5f788f5e843c729bc71fabe4325
7
- data.tar.gz: 4f42b6419740c82a050f71121f28e893dcd1e2322560db45dc435b9b157e2eeb9fad6e44377d76bb971f3f31e47f2b4b18a7ffa9e39e5c759316f91374983afc
6
+ metadata.gz: c5b08a2a38277497d102b4b383daf31c5a5b407acfd33df940499e86bfe39016dea843faa21b98596a703e94f5280f55b655c0c72df2821f6e00684e485ea6b5
7
+ data.tar.gz: 32e6620c3dff62d75fe1dc17a101648d076f11928c21e1f571642c26286b53fa254e4e643ae165fa2ac1637c3e20c2d25d3786120ba01fff073bd6238601c8aa
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
@@ -146,13 +138,10 @@ module ForemanNutanix
146
138
  # Cluster resource statistics (CPU, memory, storage usage)
147
139
  def cluster_resource_stats
148
140
  Rails.logger.info '=== NUTANIX: Fetching cluster resource stats from shim server ==='
149
- base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
150
141
  cluster_id = cluster
151
142
  return nil unless cluster_id
152
143
 
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)
144
+ data = shim.get("/api/v1/clustermgmt/clusters/#{cluster_id}/stats").json
156
145
 
157
146
  OpenStruct.new(data)
158
147
  rescue StandardError => e
@@ -163,10 +152,7 @@ module ForemanNutanix
163
152
  # Available images
164
153
  def available_images(_opts = {})
165
154
  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)
155
+ data = shim.get('/api/v1/vmm/list-images').json
170
156
 
171
157
  # Filter images by cluster if cluster_location_ext_ids is available
172
158
  cluster_ext_id = cluster
@@ -286,20 +272,10 @@ module ForemanNutanix
286
272
  # Start VM - called by Foreman for power on
287
273
  def start_vm(uuid)
288
274
  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
275
+ actual_uuid = ShimClient.normalize_uuid(uuid)
290
276
 
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)
277
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_ON' })
278
+ response.success?
303
279
  rescue StandardError => e
304
280
  Rails.logger.error "=== NUTANIX: START_VM ERROR: #{e.message} ==="
305
281
  raise e
@@ -308,20 +284,10 @@ module ForemanNutanix
308
284
  # Stop VM - called by Foreman for power off
309
285
  def stop_vm(uuid)
310
286
  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'
287
+ actual_uuid = ShimClient.normalize_uuid(uuid)
318
288
 
319
- request = Net::HTTP::Post.new(uri.path)
320
- request['Content-Type'] = 'application/json'
321
- request.body = { action: 'POWER_OFF' }.to_json
322
-
323
- response = http.request(request)
324
- response.is_a?(Net::HTTPSuccess)
289
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_OFF' })
290
+ response.success?
325
291
  rescue StandardError => e
326
292
  Rails.logger.error "=== NUTANIX: STOP_VM ERROR: #{e.message} ==="
327
293
  raise e
@@ -331,14 +297,12 @@ module ForemanNutanix
331
297
  def vm_power_state(vm)
332
298
  Rails.logger.info "=== NUTANIX: VM_POWER_STATE CALLED for vm: #{vm} ==="
333
299
  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
300
+ actual_uuid = ShimClient.normalize_uuid(uuid)
335
301
 
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)
302
+ response = shim.get("/api/v1/vmm/vms/#{actual_uuid}/power-state")
339
303
 
340
- if response.is_a?(Net::HTTPSuccess)
341
- data = JSON.parse(response.body)
304
+ if response.success?
305
+ data = response.json
342
306
  state = data['power_state']
343
307
  Rails.logger.info "=== NUTANIX: VM_POWER_STATE returning: #{state} ==="
344
308
  # Return hash that Foreman expects
@@ -375,19 +339,12 @@ module ForemanNutanix
375
339
  return true if uuid.nil? || uuid.to_s.strip.empty?
376
340
 
377
341
  # Extract the actual UUID if it has a prefix
378
- actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
342
+ actual_uuid = ShimClient.normalize_uuid(uuid)
379
343
 
380
344
  # Call the shim server to delete the VM
381
- base = shim_server_url
382
- uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}")
383
-
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)
345
+ response = shim.delete("/api/v1/vmm/vms/#{actual_uuid}")
389
346
 
390
- if response.is_a?(Net::HTTPNoContent) || response.is_a?(Net::HTTPSuccess)
347
+ if response.no_content? || response.success?
391
348
  Rails.logger.info "=== NUTANIX: VM #{actual_uuid} deleted successfully ==="
392
349
  true
393
350
  else
@@ -455,6 +412,10 @@ module ForemanNutanix
455
412
 
456
413
  private
457
414
 
415
+ def shim
416
+ @shim ||= ShimClient.new(shim_server_url)
417
+ end
418
+
458
419
  def client
459
420
  Rails.logger.info "=== NUTANIX: Creating client for cluster #{cluster} ==="
460
421
  @client ||= NutanixAdapter.new(cluster)
@@ -46,8 +46,10 @@ 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] || []
51
53
  end
52
54
 
53
55
  # Required by Foreman - indicates if VM exists
@@ -99,20 +101,10 @@ module ForemanNutanix
99
101
  Rails.logger.info "=== NUTANIX: Provisioning VM with request: #{provision_request} ==="
100
102
 
101
103
  # 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
+ response = shim.post('/api/v1/vmm/provision-vm', provision_request)
104
105
 
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
111
-
112
- response = http.request(request)
113
-
114
- if response.is_a?(Net::HTTPSuccess)
115
- result = JSON.parse(response.body)
106
+ if response.success?
107
+ result = response.json
116
108
  Rails.logger.info "=== NUTANIX: VM provisioned successfully: #{result} ==="
117
109
 
118
110
  # Update identity with the real ext_id from Nutanix
@@ -176,22 +168,12 @@ module ForemanNutanix
176
168
  return false unless persisted?
177
169
 
178
170
  # Extract actual UUID (handle ZXJnb24=:uuid format)
179
- actual_uuid = @identity.to_s.include?(':') ? @identity.to_s.split(':').last : @identity.to_s
171
+ actual_uuid = ShimClient.normalize_uuid(@identity)
180
172
 
181
173
  # 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
174
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_ON' })
191
175
 
192
- response = http.request(request)
193
-
194
- if response.is_a?(Net::HTTPSuccess)
176
+ if response.success?
195
177
  Rails.logger.info "=== NUTANIX: VM #{actual_uuid} powered on successfully ==="
196
178
  @power_state = 'ON'
197
179
  true
@@ -211,22 +193,12 @@ module ForemanNutanix
211
193
  return false unless persisted?
212
194
 
213
195
  # Extract actual UUID (handle ZXJnb24=:uuid format)
214
- actual_uuid = @identity.to_s.include?(':') ? @identity.to_s.split(':').last : @identity.to_s
196
+ actual_uuid = ShimClient.normalize_uuid(@identity)
215
197
 
216
198
  # 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
226
-
227
- response = http.request(request)
199
+ response = shim.post("/api/v1/vmm/vms/#{actual_uuid}/power-state", { action: 'POWER_OFF' })
228
200
 
229
- if response.is_a?(Net::HTTPSuccess)
201
+ if response.success?
230
202
  Rails.logger.info "=== NUTANIX: VM #{actual_uuid} powered off successfully ==="
231
203
  @power_state = 'OFF'
232
204
  true
@@ -240,6 +212,20 @@ module ForemanNutanix
240
212
  raise e
241
213
  end
242
214
 
215
+ # Human-readable GPU labels for the views, e.g. "NVIDIA Tesla T4".
216
+ # The shim returns each GPU as a hash; a shim older than 0.1.5 omits the
217
+ # field on /list-vms entirely, which #initialize turns into [].
218
+ def gpu_labels
219
+ @gpus.map do |gpu|
220
+ next gpu.to_s unless gpu.is_a?(Hash)
221
+
222
+ name = gpu['name']
223
+ name = gpu['device_id'] if blank_gpu_field?(name)
224
+ label = [gpu['vendor'], name].reject { |part| blank_gpu_field?(part) }.join(' ')
225
+ label.empty? ? 'GPU' : label
226
+ end
227
+ end
228
+
243
229
  # Required by Foreman - CPU count
244
230
  def cpu
245
231
  Rails.logger.info '=== NUTANIX: NutanixCompute::cpu called ==='
@@ -377,11 +363,26 @@ module ForemanNutanix
377
363
  yield if block_given?
378
364
  end
379
365
 
366
+ # Deliberately not ActiveSupport's #blank?: this class is loaded without
367
+ # Rails in test/nutanix_compute_test.rb.
368
+ def blank_gpu_field?(value)
369
+ value.nil? || value.to_s.strip.empty?
370
+ end
371
+ private :blank_gpu_field?
372
+
380
373
  # Required by Foreman - destroy VM
381
374
  def destroy
382
375
  Rails.logger.info '=== NUTANIX: NutanixCompute::destroy called ==='
383
376
  @persisted = false
384
377
  true
385
378
  end
379
+
380
+ private
381
+
382
+ # NutanixCompute holds no reference to the ComputeResource, so it builds its
383
+ # own client off the shim server address in the environment.
384
+ def shim
385
+ @shim ||= ShimClient.new
386
+ end
386
387
  end
387
388
  end
@@ -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,93 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'uri'
4
+
5
+ module ForemanNutanix
6
+ # Single HTTP seam for every call the plugin makes to the nutanix-shim-server.
7
+ #
8
+ # Deliberately plain Ruby: no Rails, no ActiveSupport, no Rails.logger. It must
9
+ # stay require-able from a bare minitest process with no Rails booted.
10
+ #
11
+ # Callers get back a Response value object rather than parsed data so that each
12
+ # one keeps its own (and today wildly inconsistent) success/failure branching.
13
+ class ShimClient
14
+ DEFAULT_BASE_URL = 'http://localhost:8000'.freeze
15
+
16
+ # Thin value object over a Net::HTTPResponse.
17
+ class Response
18
+ def initialize(raw)
19
+ @raw = raw
20
+ end
21
+
22
+ def success?
23
+ @raw.is_a?(Net::HTTPSuccess)
24
+ end
25
+
26
+ # Net::HTTPNoContent is the 204 response class. Exposed separately because
27
+ # destroy_vm historically tested for it explicitly alongside success?.
28
+ def no_content?
29
+ @raw.is_a?(Net::HTTPNoContent)
30
+ end
31
+
32
+ def code
33
+ @raw.code
34
+ end
35
+
36
+ def body
37
+ @raw.body
38
+ end
39
+
40
+ # Intentionally does NOT rescue: a non-JSON body (an HTML 500 page, say)
41
+ # must keep raising JSON::ParserError out to the caller, exactly as the
42
+ # inline JSON.parse(response.body) calls did before.
43
+ def json
44
+ JSON.parse(body)
45
+ end
46
+ end
47
+
48
+ def self.default_base_url
49
+ ENV['NUTANIX_SHIM_SERVER_ADDR'] || DEFAULT_BASE_URL
50
+ end
51
+
52
+ # Strips the "prefix:" from identities of the form "ZXJnb24=:<uuid>".
53
+ def self.normalize_uuid(uuid)
54
+ uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
55
+ end
56
+
57
+ attr_reader :base_url
58
+
59
+ # http: is an internal seam for tests only; production code never passes it.
60
+ def initialize(base_url = nil, http: Net::HTTP)
61
+ @base_url = (base_url || self.class.default_base_url).to_s.chomp('/')
62
+ @http = http
63
+ end
64
+
65
+ def get(path)
66
+ execute(Net::HTTP::Get, path)
67
+ end
68
+
69
+ def post(path, body = nil)
70
+ execute(Net::HTTP::Post, path, body)
71
+ end
72
+
73
+ def delete(path)
74
+ execute(Net::HTTP::Delete, path)
75
+ end
76
+
77
+ private
78
+
79
+ def execute(verb_class, path, body = nil)
80
+ uri = URI("#{@base_url}#{path}")
81
+ connection = @http.new(uri.host, uri.port)
82
+ connection.use_ssl = uri.scheme == 'https'
83
+
84
+ request = verb_class.new(uri.request_uri)
85
+ unless body.nil?
86
+ request['Content-Type'] = 'application/json'
87
+ request.body = body.to_json
88
+ end
89
+
90
+ Response.new(connection.request(request))
91
+ end
92
+ end
93
+ end
@@ -1,3 +1,3 @@
1
1
  module ForemanNutanix
2
- VERSION = '0.1.4'.freeze
2
+ VERSION = '0.1.5'.freeze
3
3
  end
@@ -1,3 +1,4 @@
1
+ require 'foreman_nutanix/shim_client'
1
2
  require 'foreman_nutanix/engine'
2
3
 
3
4
  module ForemanNutanix
data/test/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # Tests
2
+
3
+ Standalone minitest. No Rails, no Foreman, no database, no `test/dummy`.
4
+
5
+ Run with:
6
+
7
+ ```
8
+ bundle exec rake test
9
+ ```
10
+
11
+ `test_helper.rb` requires only `lib/foreman_nutanix/shim_client.rb`, which is
12
+ deliberately plain Ruby (net/http, json, uri). That is the whole reason the
13
+ suite boots in milliseconds with nothing installed but minitest and rake.
14
+
15
+ ## What this covers
16
+
17
+ `ForemanNutanix::ShimClient` — base URL resolution, trailing-slash chomping,
18
+ verb and path construction, JSON body encoding, `success?` / `no_content?` /
19
+ `code` / `body` / `json` on the response value object, and `normalize_uuid`.
20
+ HTTP is faked through the `http:` keyword seam; no network, no webmock.
21
+
22
+ ## What cannot be tested here
23
+
24
+ Everything that needs a booted Foreman:
25
+
26
+ - `ForemanNutanix::Nutanix` — it is a `ComputeResource` subclass, and `cluster`
27
+ is stored in the inherited `url` column, so instantiating it at all needs
28
+ ActiveRecord, the Foreman schema, and the compute-resource STI machinery.
29
+ - `ForemanNutanix::HostManagedExtensions` — a concern mixed into `Host::Managed`.
30
+ - The `.erb` views under `app/views/`.
31
+ - The API controller extensions under `app/controllers/`.
32
+ - Anything touching the database, `Rails.logger`, or `Foreman::Plugin`.
33
+
34
+ `NutanixCompute` and `NutanixAdapter` are plain objects but still call
35
+ `Rails.logger` on nearly every method, so they are not reachable from here
36
+ either without stubbing Rails.
37
+
38
+ ## What a full harness would need
39
+
40
+ 1. A `test/dummy` Rails app generated against a Foreman core checkout.
41
+ 2. That Foreman core pinned to a known version (the plugin requires >= 3.14.0),
42
+ since plugin tests load Foreman's own `test_helper`.
43
+ 3. A database created and migrated in CI before the suite runs.
44
+
45
+ Not built. The standalone suite above is the part that pays for itself today.
@@ -0,0 +1,71 @@
1
+ require 'test_helper'
2
+
3
+ # NutanixCompute is a plain class, but it logs in #initialize. Stub just enough
4
+ # Rails to load it standalone; see test/README.md.
5
+ require 'logger'
6
+ module Rails
7
+ def self.logger
8
+ @logger ||= Logger.new(IO::NULL)
9
+ end
10
+ end
11
+
12
+ require 'ostruct'
13
+ require_relative '../app/models/foreman_nutanix/nutanix_compute'
14
+
15
+ class NutanixComputeTest < Minitest::Test
16
+ # Regression: ServersCollection#all builds VMs without a :gpus key, because
17
+ # the shim's /list-vms payload has no gpus field. The VM index template calls
18
+ # vm.gpus.empty? on every row, so a nil default raised NoMethodError there.
19
+ def test_gpus_defaults_to_an_empty_array_when_absent
20
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1')
21
+ assert_equal [], vm.gpus
22
+ assert_empty vm.gpus
23
+ end
24
+
25
+ def test_gpus_passes_through_when_supplied
26
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1', gpus: %w[gpu-a gpu-b])
27
+ assert_equal %w[gpu-a gpu-b], vm.gpus
28
+ refute_empty vm.gpus
29
+ end
30
+
31
+ def test_gpus_explicit_nil_still_yields_an_empty_array
32
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1', gpus: nil)
33
+ assert_equal [], vm.gpus
34
+ end
35
+ end
36
+
37
+ class NutanixComputeGpuLabelsTest < Minitest::Test
38
+ def build(gpus)
39
+ ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1', gpus: gpus)
40
+ end
41
+
42
+ def test_no_gpus_gives_no_labels
43
+ assert_equal [], build(nil).gpu_labels
44
+ assert_equal [], build([]).gpu_labels
45
+ end
46
+
47
+ def test_vendor_and_name_are_joined
48
+ labels = build([{ 'vendor' => 'NVIDIA', 'name' => 'Tesla T4', 'device_id' => 7864 }]).gpu_labels
49
+ assert_equal ['NVIDIA Tesla T4'], labels
50
+ end
51
+
52
+ def test_falls_back_to_device_id_when_name_is_missing_or_blank
53
+ assert_equal ['NVIDIA 7864'], build([{ 'vendor' => 'NVIDIA', 'device_id' => 7864 }]).gpu_labels
54
+ assert_equal ['NVIDIA 7864'], build([{ 'vendor' => 'NVIDIA', 'name' => ' ', 'device_id' => 7864 }]).gpu_labels
55
+ end
56
+
57
+ def test_name_alone_is_enough
58
+ assert_equal ['Tesla T4'], build([{ 'name' => 'Tesla T4' }]).gpu_labels
59
+ end
60
+
61
+ def test_entirely_empty_gpu_still_gets_a_label
62
+ assert_equal ['GPU'], build([{}]).gpu_labels
63
+ assert_equal ['GPU'], build([{ 'vendor' => nil, 'name' => nil, 'device_id' => nil }]).gpu_labels
64
+ end
65
+
66
+ def test_label_count_always_matches_gpu_count
67
+ labels = build([{ 'name' => 'A' }, {}, { 'vendor' => 'AMD' }]).gpu_labels
68
+ assert_equal 3, labels.size
69
+ refute_includes labels, ''
70
+ end
71
+ end
@@ -0,0 +1,257 @@
1
+ require 'test_helper'
2
+
3
+ class ShimClientTest < Minitest::Test
4
+ # --- Fakes injected through the http: keyword -----------------------------
5
+
6
+ # Stands in for a Net::HTTP connection object.
7
+ class FakeConnection
8
+ attr_reader :host, :port, :requests
9
+ attr_accessor :use_ssl
10
+
11
+ def initialize(host, port, response)
12
+ @host = host
13
+ @port = port
14
+ @response = response
15
+ @requests = []
16
+ @use_ssl = nil
17
+ end
18
+
19
+ def request(req)
20
+ @requests << req
21
+ @response
22
+ end
23
+ end
24
+
25
+ # Stands in for the Net::HTTP class itself.
26
+ class FakeHttp
27
+ attr_reader :connections
28
+
29
+ def initialize(response)
30
+ @response = response
31
+ @connections = []
32
+ end
33
+
34
+ def new(host, port)
35
+ conn = FakeConnection.new(host, port, @response)
36
+ @connections << conn
37
+ conn
38
+ end
39
+
40
+ def last_connection
41
+ @connections.last
42
+ end
43
+
44
+ def last_request
45
+ last_connection.requests.last
46
+ end
47
+ end
48
+
49
+ def stub_response(klass = Net::HTTPOK, code: '200', body: '{}')
50
+ response = klass.new('1.1', code, 'Stub')
51
+ response.instance_variable_set(:@body, body)
52
+ response.instance_variable_set(:@read, true)
53
+ response
54
+ end
55
+
56
+ def client(base_url = 'http://shim.example:8000', response: stub_response)
57
+ http = FakeHttp.new(response)
58
+ [ForemanNutanix::ShimClient.new(base_url, http: http), http]
59
+ end
60
+
61
+ def with_env(value)
62
+ previous = ENV['NUTANIX_SHIM_SERVER_ADDR']
63
+ had_key = ENV.key?('NUTANIX_SHIM_SERVER_ADDR')
64
+ if value.nil?
65
+ ENV.delete('NUTANIX_SHIM_SERVER_ADDR')
66
+ else
67
+ ENV['NUTANIX_SHIM_SERVER_ADDR'] = value
68
+ end
69
+ yield
70
+ ensure
71
+ if had_key
72
+ ENV['NUTANIX_SHIM_SERVER_ADDR'] = previous
73
+ else
74
+ ENV.delete('NUTANIX_SHIM_SERVER_ADDR')
75
+ end
76
+ end
77
+
78
+ # --- Base URL -------------------------------------------------------------
79
+
80
+ def test_base_url_defaults_to_localhost_when_env_is_unset
81
+ with_env(nil) do
82
+ assert_equal 'http://localhost:8000', ForemanNutanix::ShimClient.new.base_url
83
+ assert_equal 'http://localhost:8000', ForemanNutanix::ShimClient.default_base_url
84
+ end
85
+ end
86
+
87
+ def test_base_url_comes_from_env_when_set
88
+ with_env('http://shim.internal:9000') do
89
+ assert_equal 'http://shim.internal:9000', ForemanNutanix::ShimClient.new.base_url
90
+ assert_equal 'http://shim.internal:9000', ForemanNutanix::ShimClient.default_base_url
91
+ end
92
+ end
93
+
94
+ def test_explicit_base_url_wins_over_env
95
+ with_env('http://shim.internal:9000') do
96
+ assert_equal 'http://explicit:1234', ForemanNutanix::ShimClient.new('http://explicit:1234').base_url
97
+ end
98
+ end
99
+
100
+ def test_trailing_slash_is_chomped
101
+ shim = ForemanNutanix::ShimClient.new('http://shim.example:8000/')
102
+ assert_equal 'http://shim.example:8000', shim.base_url
103
+ end
104
+
105
+ def test_trailing_slash_from_env_is_chomped
106
+ with_env('http://shim.internal:9000/') do
107
+ assert_equal 'http://shim.internal:9000', ForemanNutanix::ShimClient.new.base_url
108
+ end
109
+ end
110
+
111
+ # --- Verbs and path construction -----------------------------------------
112
+
113
+ def test_get_builds_a_get_request_at_the_joined_path
114
+ shim, http = client
115
+ shim.get('/api/v1/vmm/list-images')
116
+
117
+ request = http.last_request
118
+ assert_instance_of Net::HTTP::Get, request
119
+ assert_equal '/api/v1/vmm/list-images', request.path
120
+ assert_equal 'shim.example', http.last_connection.host
121
+ assert_equal 8000, http.last_connection.port
122
+ end
123
+
124
+ def test_post_builds_a_post_request_at_the_joined_path
125
+ shim, http = client
126
+ shim.post('/api/v1/vmm/vms/abc/power-state', { action: 'POWER_ON' })
127
+
128
+ assert_instance_of Net::HTTP::Post, http.last_request
129
+ assert_equal '/api/v1/vmm/vms/abc/power-state', http.last_request.path
130
+ end
131
+
132
+ def test_delete_builds_a_delete_request_at_the_joined_path
133
+ shim, http = client
134
+ shim.delete('/api/v1/vmm/vms/abc')
135
+
136
+ assert_instance_of Net::HTTP::Delete, http.last_request
137
+ assert_equal '/api/v1/vmm/vms/abc', http.last_request.path
138
+ end
139
+
140
+ def test_path_is_joined_to_a_base_url_that_had_a_trailing_slash
141
+ http = FakeHttp.new(stub_response)
142
+ ForemanNutanix::ShimClient.new('http://shim.example:8000/', http: http).get('/api/v1/vmm/list-vms')
143
+
144
+ assert_equal '/api/v1/vmm/list-vms', http.last_request.path
145
+ end
146
+
147
+ # --- Body encoding --------------------------------------------------------
148
+
149
+ def test_post_json_encodes_the_body_and_sets_content_type
150
+ shim, http = client
151
+ shim.post('/api/v1/vmm/provision-vm', { name: 'vm-1', num_sockets: 2 })
152
+
153
+ assert_equal 'application/json', http.last_request['Content-Type']
154
+ assert_equal({ 'name' => 'vm-1', 'num_sockets' => 2 }, JSON.parse(http.last_request.body))
155
+ end
156
+
157
+ def test_post_without_a_body_sends_no_body_and_no_content_type
158
+ shim, http = client
159
+ shim.post('/api/v1/vmm/noop')
160
+
161
+ assert_nil http.last_request.body
162
+ assert_nil http.last_request['Content-Type']
163
+ end
164
+
165
+ def test_get_sends_no_body_and_no_content_type
166
+ shim, http = client
167
+ shim.get('/api/v1/vmm/list-vms')
168
+
169
+ assert_nil http.last_request.body
170
+ assert_nil http.last_request['Content-Type']
171
+ end
172
+
173
+ # --- Scheme ---------------------------------------------------------------
174
+
175
+ def test_https_is_inferred_from_the_scheme
176
+ http = FakeHttp.new(stub_response)
177
+ ForemanNutanix::ShimClient.new('https://shim.example', http: http).get('/api/v1/vmm/list-vms')
178
+
179
+ assert_equal true, http.last_connection.use_ssl
180
+ assert_equal 443, http.last_connection.port
181
+ end
182
+
183
+ def test_http_scheme_does_not_enable_ssl
184
+ shim, http = client
185
+ shim.get('/api/v1/vmm/list-vms')
186
+
187
+ assert_equal false, http.last_connection.use_ssl
188
+ end
189
+
190
+ # --- Response -------------------------------------------------------------
191
+
192
+ def test_success_is_true_for_200
193
+ shim, = client(response: stub_response(Net::HTTPOK, code: '200'))
194
+ response = shim.get('/whatever')
195
+
196
+ assert_equal true, response.success?
197
+ assert_equal '200', response.code
198
+ end
199
+
200
+ def test_success_is_false_for_404
201
+ shim, = client(response: stub_response(Net::HTTPNotFound, code: '404', body: 'not found'))
202
+ response = shim.get('/whatever')
203
+
204
+ assert_equal false, response.success?
205
+ assert_equal '404', response.code
206
+ assert_equal 'not found', response.body
207
+ end
208
+
209
+ def test_success_is_false_for_500
210
+ error = stub_response(Net::HTTPInternalServerError, code: '500', body: '<html>boom</html>')
211
+ shim, = client(response: error)
212
+ response = shim.get('/whatever')
213
+
214
+ assert_equal false, response.success?
215
+ assert_equal '500', response.code
216
+ end
217
+
218
+ def test_no_content_is_true_only_for_204
219
+ shim, = client(response: stub_response(Net::HTTPNoContent, code: '204', body: nil))
220
+ assert_equal true, shim.get('/whatever').no_content?
221
+
222
+ shim_ok, = client(response: stub_response(Net::HTTPOK, code: '200'))
223
+ assert_equal false, shim_ok.get('/whatever').no_content?
224
+ end
225
+
226
+ def test_json_parses_the_body
227
+ shim, = client(response: stub_response(Net::HTTPOK, body: '[{"ext_id":"abc"}]'))
228
+
229
+ assert_equal [{ 'ext_id' => 'abc' }], shim.get('/whatever').json
230
+ end
231
+
232
+ def test_json_propagates_parser_error_on_non_json_bodies
233
+ error = stub_response(Net::HTTPInternalServerError, code: '500', body: '<html>boom</html>')
234
+ shim, = client(response: error)
235
+
236
+ assert_raises(JSON::ParserError) { shim.get('/whatever').json }
237
+ end
238
+
239
+ # --- normalize_uuid -------------------------------------------------------
240
+
241
+ def test_normalize_uuid_strips_a_prefix
242
+ assert_equal 'the-uuid', ForemanNutanix::ShimClient.normalize_uuid('ZXJnb24=:the-uuid')
243
+ end
244
+
245
+ def test_normalize_uuid_leaves_a_bare_uuid_alone
246
+ assert_equal 'the-uuid', ForemanNutanix::ShimClient.normalize_uuid('the-uuid')
247
+ end
248
+
249
+ def test_normalize_uuid_coerces_non_strings
250
+ assert_equal '', ForemanNutanix::ShimClient.normalize_uuid(nil)
251
+ assert_equal '42', ForemanNutanix::ShimClient.normalize_uuid(42)
252
+ end
253
+
254
+ def test_normalize_uuid_keeps_only_the_last_segment
255
+ assert_equal 'c', ForemanNutanix::ShimClient.normalize_uuid('a:b:c')
256
+ end
257
+ end
@@ -0,0 +1,5 @@
1
+ require 'minitest/autorun'
2
+
3
+ # Standalone: no Rails, no Foreman, no database. Only the plain-Ruby seam is
4
+ # loaded here. See test/README.md for what that rules out.
5
+ require 'foreman_nutanix/shim_client'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_nutanix
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Granger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-14 00:00:00.000000000 Z
11
+ date: 2026-09-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rdoc
@@ -54,10 +54,15 @@ files:
54
54
  - config/routes.rb
55
55
  - lib/foreman_nutanix.rb
56
56
  - lib/foreman_nutanix/engine.rb
57
+ - lib/foreman_nutanix/shim_client.rb
57
58
  - lib/foreman_nutanix/version.rb
58
59
  - lib/tasks/foreman_nutanix_tasks.rake
59
60
  - locale/foreman_nutanix.pot
60
61
  - package.json
62
+ - test/README.md
63
+ - test/nutanix_compute_test.rb
64
+ - test/shim_client_test.rb
65
+ - test/test_helper.rb
61
66
  - webpack/global_index.js
62
67
  - webpack/global_test_setup.js
63
68
  - webpack/index.js
@@ -94,4 +99,8 @@ rubygems_version: 3.4.19
94
99
  signing_key:
95
100
  specification_version: 4
96
101
  summary: Nutanix plugin for Foreman
97
- test_files: []
102
+ test_files:
103
+ - test/README.md
104
+ - test/nutanix_compute_test.rb
105
+ - test/shim_client_test.rb
106
+ - test/test_helper.rb