foreman_nutanix 0.1.5 → 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: e4f699c1534db4e1dc7d31dab1ed7af4ea04f69ec364f1ade64b243c8b3ab19d
4
- data.tar.gz: 5c82e86ec3fdd45fc9a282cdf7d5c688b16931fa656b7dda6407175bf48d551d
3
+ metadata.gz: 20eff365f58c477410d10f5b140961e4825738e49601442bec1740613aaf5704
4
+ data.tar.gz: bcefb95f0276c18905b569b187436a59a5e33276320b84238d4d6204e4ea3e6a
5
5
  SHA512:
6
- metadata.gz: c5b08a2a38277497d102b4b383daf31c5a5b407acfd33df940499e86bfe39016dea843faa21b98596a703e94f5280f55b655c0c72df2821f6e00684e485ea6b5
7
- data.tar.gz: 32e6620c3dff62d75fe1dc17a101648d076f11928c21e1f571642c26286b53fa254e4e643ae165fa2ac1637c3e20c2d25d3786120ba01fff073bd6238601c8aa
6
+ metadata.gz: 461cc7ed6c08843073891195b480c8356c148fadf2d2825aacfb0c8284167967abe1e30e37ac37a93c8cb102d86dc75cbab37ab93bca3789b4707cf87f63365e
7
+ data.tar.gz: bfea4f00a1a9148d6568014c9904b1afaef15172fb70033f386132fe83fd438b89896c3ecc8e6139104874f961908f9f5154cf0f7263caa0a112b9e5fea9acd6
@@ -135,6 +135,48 @@ module ForemanNutanix
135
135
  []
136
136
  end
137
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
+
138
180
  # Cluster resource statistics (CPU, memory, storage usage)
139
181
  def cluster_resource_stats
140
182
  Rails.logger.info '=== NUTANIX: Fetching cluster resource stats from shim server ==='
@@ -412,6 +454,9 @@ module ForemanNutanix
412
454
 
413
455
  private
414
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
+
415
460
  def shim
416
461
  @shim ||= ShimClient.new(shim_server_url)
417
462
  end
@@ -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} ==="
@@ -50,6 +50,13 @@ module ForemanNutanix
50
50
  # carries no gpus field, so VMs built by ServersCollection#all never
51
51
  # receive one, and the index view calls #gpus.empty? on every row.
52
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]
53
60
  end
54
61
 
55
62
  # Required by Foreman - indicates if VM exists
@@ -98,6 +105,10 @@ module ForemanNutanix
98
105
  boot_method: @boot_method,
99
106
  }
100
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
+
101
112
  Rails.logger.info "=== NUTANIX: Provisioning VM with request: #{provision_request} ==="
102
113
 
103
114
  # Call the shim server to provision the VM
@@ -379,6 +390,42 @@ module ForemanNutanix
379
390
 
380
391
  private
381
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
+
382
429
  # NutanixCompute holds no reference to the ComputeResource, so it builds its
383
430
  # own client off the shim server address in the environment.
384
431
  def shim
@@ -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>
@@ -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
@@ -1,3 +1,3 @@
1
1
  module ForemanNutanix
2
- VERSION = '0.1.5'.freeze
2
+ VERSION = '0.1.6'.freeze
3
3
  end
@@ -1,4 +1,5 @@
1
1
  require 'foreman_nutanix/shim_client'
2
+ require 'foreman_nutanix/gpu_profile'
2
3
  require 'foreman_nutanix/engine'
3
4
 
4
5
  module ForemanNutanix
data/test/README.md CHANGED
@@ -9,8 +9,9 @@ bundle exec rake test
9
9
  ```
10
10
 
11
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.
12
+ deliberately plain Ruby (net/http, json, uri), plus a five-line `Rails.logger`
13
+ stub. That is the whole reason the suite boots in milliseconds with nothing
14
+ installed but minitest and rake.
14
15
 
15
16
  ## What this covers
16
17
 
@@ -0,0 +1,98 @@
1
+ require 'test_helper'
2
+ require 'foreman_nutanix/gpu_profile'
3
+
4
+ # A profile as it arrives from the shim's /gpu-profiles endpoint.
5
+ def profile(overrides = {})
6
+ {
7
+ 'ext_id' => '0005a1b2-1111-2222-3333-444455556666',
8
+ 'device_id' => 7864,
9
+ 'device_name' => 'Tesla T4',
10
+ 'vendor_name' => 'NVIDIA',
11
+ 'gpu_type' => 'PASSTHROUGH_GRAPHICS',
12
+ 'assignable' => 3,
13
+ 'is_in_use' => false,
14
+ }.merge(overrides)
15
+ end
16
+
17
+ class GpuProfileUsableTest < Minitest::Test
18
+ def test_a_complete_passthrough_profile_is_usable
19
+ assert ForemanNutanix::GpuProfile.usable?(profile)
20
+ assert ForemanNutanix::GpuProfile.usable?(profile('gpu_type' => 'PASSTHROUGH_COMPUTE'))
21
+ end
22
+
23
+ def test_virtual_profiles_are_not_usable
24
+ # vGPU is rejected by the shim, so offering one would only let a user pick a
25
+ # provision that cannot succeed.
26
+ refute ForemanNutanix::GpuProfile.usable?(profile('gpu_type' => 'VIRTUAL'))
27
+ end
28
+
29
+ # Regression: a profile with a nil part still passed the PASSTHROUGH filter and
30
+ # was offered in the dropdown. Selecting it posted a composite that parse
31
+ # rejects, so the VM was created with NO GPU and the build reported success.
32
+ def test_a_profile_missing_any_composite_part_is_not_usable
33
+ refute ForemanNutanix::GpuProfile.usable?(profile('vendor_name' => nil))
34
+ refute ForemanNutanix::GpuProfile.usable?(profile('device_id' => nil))
35
+ refute ForemanNutanix::GpuProfile.usable?(profile('vendor_name' => ' '))
36
+ refute ForemanNutanix::GpuProfile.usable?(profile('gpu_type' => nil))
37
+ end
38
+
39
+ def test_an_entirely_empty_profile_is_not_usable
40
+ refute ForemanNutanix::GpuProfile.usable?({})
41
+ end
42
+
43
+ def test_every_usable_profile_round_trips_through_parse
44
+ built = ForemanNutanix::GpuProfile.build(profile)
45
+ assert_equal %w[7864 NVIDIA PASSTHROUGH_GRAPHICS], ForemanNutanix::GpuProfile.parse(built)
46
+ end
47
+ end
48
+
49
+ class GpuProfileParseTest < Minitest::Test
50
+ def parse(value)
51
+ ForemanNutanix::GpuProfile.parse(value)
52
+ end
53
+
54
+ def test_a_well_formed_composite_yields_three_parts
55
+ assert_equal %w[7864 NVIDIA PASSTHROUGH_GRAPHICS], parse('7864:NVIDIA:PASSTHROUGH_GRAPHICS')
56
+ end
57
+
58
+ def test_too_few_parts_are_rejected
59
+ assert_nil parse('7864')
60
+ assert_nil parse('7864:NVIDIA')
61
+ end
62
+
63
+ def test_blank_parts_are_rejected
64
+ assert_nil parse('::')
65
+ assert_nil parse('7864::PASSTHROUGH_GRAPHICS')
66
+ assert_nil parse(':NVIDIA:PASSTHROUGH_GRAPHICS')
67
+ assert_nil parse('7864:NVIDIA:')
68
+ end
69
+
70
+ def test_unselected_values_are_rejected
71
+ assert_nil parse(nil)
72
+ assert_nil parse('')
73
+ assert_nil parse(' ')
74
+ end
75
+
76
+ def test_split_caps_at_three_so_gpu_type_is_never_truncated
77
+ assert_equal %w[7864 NVIDIA A:B], parse('7864:NVIDIA:A:B')
78
+ end
79
+ end
80
+
81
+ class GpuProfileLabelTest < Minitest::Test
82
+ def label(overrides = {})
83
+ ForemanNutanix::GpuProfile.label(profile(overrides))
84
+ end
85
+
86
+ def test_vendor_name_and_assignable_count
87
+ assert_equal 'NVIDIA Tesla T4 (3 assignable)', label
88
+ end
89
+
90
+ def test_missing_assignable_drops_the_suffix
91
+ assert_equal 'NVIDIA Tesla T4', label('assignable' => nil)
92
+ end
93
+
94
+ def test_missing_device_name_falls_back_to_the_device_id
95
+ assert_equal 'NVIDIA (3 assignable)', label('device_name' => nil)
96
+ assert_equal 'GPU 7864 (3 assignable)', label('device_name' => nil, 'vendor_name' => nil)
97
+ end
98
+ end
@@ -0,0 +1,207 @@
1
+ require 'test_helper'
2
+
3
+ require 'ostruct'
4
+ require_relative '../app/models/foreman_nutanix/nutanix_compute'
5
+
6
+ class NutanixComputeGpuAccessorsTest < Minitest::Test
7
+ def build(attrs = {})
8
+ ForemanNutanix::NutanixCompute.new('cluster-1', { name: 'vm1' }.merge(attrs))
9
+ end
10
+
11
+ def test_gpu_composite_comes_from_args
12
+ assert_equal '7864:NVIDIA:PASSTHROUGH_GRAPHICS',
13
+ build(gpu: '7864:NVIDIA:PASSTHROUGH_GRAPHICS').gpu
14
+ end
15
+
16
+ def test_gpu_defaults_to_nil
17
+ assert_nil build.gpu
18
+ end
19
+
20
+ def test_gpu_count_comes_from_args
21
+ assert_equal 4, build(gpu_count: 4).gpu_count
22
+ end
23
+
24
+ def test_gpu_count_defaults_to_nil
25
+ assert_nil build.gpu_count
26
+ end
27
+
28
+ def test_gpu_and_gpu_count_are_writable
29
+ vm = build
30
+ vm.gpu = '7864:NVIDIA:PASSTHROUGH_COMPUTE'
31
+ vm.gpu_count = 2
32
+ assert_equal '7864:NVIDIA:PASSTHROUGH_COMPUTE', vm.gpu
33
+ assert_equal 2, vm.gpu_count
34
+ end
35
+ end
36
+
37
+ # #save posts the provision request through the private #shim memo. Seed that
38
+ # memo with a recorder instead of reaching for the network; ShimClient itself is
39
+ # covered separately in shim_client_test.rb.
40
+ class FakeShimResponse
41
+ def success?
42
+ true
43
+ end
44
+
45
+ def json
46
+ { 'ext_id' => 'vm-ext-1' }
47
+ end
48
+
49
+ def code
50
+ '200'
51
+ end
52
+
53
+ def body
54
+ '{}'
55
+ end
56
+ end
57
+
58
+ class RecordingShim
59
+ attr_reader :path, :payload
60
+
61
+ def post(path, payload = nil)
62
+ @path = path
63
+ @payload = payload
64
+ FakeShimResponse.new
65
+ end
66
+ end
67
+
68
+ module GpuProvisionHelper
69
+ # A provision that supplies everything #save demands and nothing more.
70
+ BASE_ATTRS = {
71
+ name: 'vm1',
72
+ subnet_ext_id: 'subnet-1',
73
+ storage_container_ext_id: 'sc-1',
74
+ memory: 4,
75
+ disk_size_gb: 50,
76
+ }.freeze
77
+
78
+ # Exactly what the plugin posted before GPU support existed. Any drift here is
79
+ # a regression: a GPU-less provision must stay byte-identical.
80
+ PRE_GPU_PAYLOAD = {
81
+ name: 'vm1',
82
+ cluster_ext_id: 'cluster-1',
83
+ subnet_ext_id: 'subnet-1',
84
+ storage_container_ext_id: 'sc-1',
85
+ num_sockets: 1,
86
+ num_cores_per_socket: 2,
87
+ memory_size_bytes: 4_294_967_296,
88
+ disk_size_bytes: 53_687_091_200,
89
+ description: '',
90
+ secure_boot: nil,
91
+ boot_method: nil,
92
+ }.freeze
93
+
94
+ GPU_KEYS = %i[gpu_device_id gpu_vendor gpu_mode gpu_count].freeze
95
+
96
+ def provision(attrs = {})
97
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', BASE_ATTRS.merge(attrs))
98
+ shim = RecordingShim.new
99
+ vm.instance_variable_set(:@shim, shim)
100
+ vm.save
101
+ shim.payload
102
+ end
103
+
104
+ end
105
+
106
+ class NutanixComputeGpuPayloadTest < Minitest::Test
107
+ include GpuProvisionHelper
108
+
109
+ def test_no_gpu_selected_leaves_the_payload_exactly_as_it_was
110
+ assert_equal PRE_GPU_PAYLOAD, provision
111
+ assert_equal PRE_GPU_PAYLOAD.keys, provision.keys
112
+ end
113
+
114
+ def test_no_gpu_selected_adds_no_gpu_keys
115
+ payload = provision
116
+ GPU_KEYS.each { |key| refute_includes payload.keys, key }
117
+ end
118
+
119
+ def test_well_formed_composite_is_split_into_three_fields
120
+ payload = provision(gpu: '7864:NVIDIA:PASSTHROUGH_GRAPHICS')
121
+ assert_equal 7864, payload[:gpu_device_id]
122
+ assert_equal 'NVIDIA', payload[:gpu_vendor]
123
+ assert_equal 'PASSTHROUGH_GRAPHICS', payload[:gpu_mode]
124
+ end
125
+
126
+ def test_selecting_a_gpu_leaves_every_other_key_untouched
127
+ payload = provision(gpu: '7864:NVIDIA:PASSTHROUGH_GRAPHICS')
128
+ assert_equal PRE_GPU_PAYLOAD, payload.reject { |key, _| GPU_KEYS.include?(key) }
129
+ end
130
+ end
131
+
132
+ class NutanixComputeGpuCountTest < Minitest::Test
133
+ include GpuProvisionHelper
134
+
135
+ GPU = '7864:NVIDIA:PASSTHROUGH_GRAPHICS'.freeze
136
+
137
+ def test_count_defaults_to_one_when_a_gpu_is_selected_without_one
138
+ assert_equal 1, provision(gpu: GPU)[:gpu_count]
139
+ end
140
+
141
+ def test_count_defaults_to_one_when_the_hidden_field_posts_empty
142
+ assert_equal 1, provision(gpu: GPU, gpu_count: '')[:gpu_count]
143
+ assert_equal 1, provision(gpu: GPU, gpu_count: nil)[:gpu_count]
144
+ end
145
+
146
+ def test_an_explicit_count_is_carried_through
147
+ assert_equal 3, provision(gpu: GPU, gpu_count: 3)[:gpu_count]
148
+ end
149
+
150
+ def test_a_string_count_from_the_form_is_coerced
151
+ assert_equal 3, provision(gpu: GPU, gpu_count: '3')[:gpu_count]
152
+ end
153
+
154
+ def test_a_nonsensical_count_still_provisions_the_selected_gpu
155
+ assert_equal 1, provision(gpu: GPU, gpu_count: 0)[:gpu_count]
156
+ assert_equal 1, provision(gpu: GPU, gpu_count: -2)[:gpu_count]
157
+ end
158
+ end
159
+
160
+ class NutanixComputeMalformedGpuCompositeTest < Minitest::Test
161
+ include GpuProvisionHelper
162
+
163
+ # A composite that did not survive the round trip must omit the GPU entirely.
164
+ # Provisioning against half a GPU spec is worse than provisioning without one.
165
+ def assert_no_gpu_fields(gpu)
166
+ payload = provision(gpu: gpu)
167
+ GPU_KEYS.each do |key|
168
+ refute_includes payload.keys, key, "expected #{gpu.inspect} to contribute no #{key}"
169
+ end
170
+ assert_equal PRE_GPU_PAYLOAD, payload
171
+ end
172
+
173
+ # Unselected: no GPU fields, no error. This is what an untouched dropdown posts.
174
+ def test_unselected_values_contribute_no_gpu_fields
175
+ assert_no_gpu_fields ''
176
+ assert_no_gpu_fields ' '
177
+ assert_no_gpu_fields nil
178
+ end
179
+
180
+ # Selected but malformed: raise. Silently building a GPU-less VM and reporting
181
+ # success is the worst available outcome, and matches how a missing network or
182
+ # storage container already behaves in #save.
183
+ def assert_raises_on(gpu)
184
+ error = assert_raises(StandardError) { provision(gpu: gpu) }
185
+ assert_match(/Malformed GPU selection/, error.message)
186
+ end
187
+
188
+ def test_too_few_parts_raise
189
+ assert_raises_on '7864'
190
+ assert_raises_on '7864:NVIDIA'
191
+ end
192
+
193
+ def test_blank_parts_raise
194
+ assert_raises_on '::'
195
+ assert_raises_on '7864::PASSTHROUGH_GRAPHICS'
196
+ assert_raises_on ':NVIDIA:PASSTHROUGH_GRAPHICS'
197
+ assert_raises_on '7864:NVIDIA:'
198
+ end
199
+
200
+ # split(':', 3) caps at three, so a gpu_type is never truncated by a stray
201
+ # colon further right.
202
+ def test_extra_colons_stay_with_the_gpu_type
203
+ payload = provision(gpu: '7864:NVIDIA:PASSTHROUGH_GRAPHICS:EXTRA')
204
+ assert_equal 'PASSTHROUGH_GRAPHICS:EXTRA', payload[:gpu_mode]
205
+ assert_equal 'NVIDIA', payload[:gpu_vendor]
206
+ end
207
+ end
@@ -1,14 +1,5 @@
1
1
  require 'test_helper'
2
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
3
  require 'ostruct'
13
4
  require_relative '../app/models/foreman_nutanix/nutanix_compute'
14
5
 
data/test/test_helper.rb CHANGED
@@ -1,5 +1,15 @@
1
1
  require 'minitest/autorun'
2
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.
3
+ # Standalone: no Rails, no Foreman, no database. See test/README.md for what
4
+ # that rules out.
5
5
  require 'foreman_nutanix/shim_client'
6
+
7
+ # NutanixCompute is a plain object, but it logs in nearly every method. This is
8
+ # the whole of the Rails surface the suite needs; it lives here rather than in
9
+ # one test file so a second file defining it cannot redefine the first's.
10
+ require 'logger'
11
+ module Rails
12
+ def self.logger
13
+ @logger ||= Logger.new(IO::NULL)
14
+ end
15
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foreman_nutanix
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Granger
@@ -54,12 +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/gpu_profile.rb
57
58
  - lib/foreman_nutanix/shim_client.rb
58
59
  - lib/foreman_nutanix/version.rb
59
60
  - lib/tasks/foreman_nutanix_tasks.rake
60
61
  - locale/foreman_nutanix.pot
61
62
  - package.json
62
63
  - test/README.md
64
+ - test/gpu_profile_test.rb
65
+ - test/nutanix_compute_gpu_test.rb
63
66
  - test/nutanix_compute_test.rb
64
67
  - test/shim_client_test.rb
65
68
  - test/test_helper.rb
@@ -101,6 +104,8 @@ specification_version: 4
101
104
  summary: Nutanix plugin for Foreman
102
105
  test_files:
103
106
  - test/README.md
107
+ - test/gpu_profile_test.rb
108
+ - test/nutanix_compute_gpu_test.rb
104
109
  - test/nutanix_compute_test.rb
105
110
  - test/shim_client_test.rb
106
111
  - test/test_helper.rb