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.
@@ -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.6'.freeze
3
3
  end
@@ -1,3 +1,5 @@
1
+ require 'foreman_nutanix/shim_client'
2
+ require 'foreman_nutanix/gpu_profile'
1
3
  require 'foreman_nutanix/engine'
2
4
 
3
5
  module ForemanNutanix
data/test/README.md ADDED
@@ -0,0 +1,46 @@
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), 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.
15
+
16
+ ## What this covers
17
+
18
+ `ForemanNutanix::ShimClient` — base URL resolution, trailing-slash chomping,
19
+ verb and path construction, JSON body encoding, `success?` / `no_content?` /
20
+ `code` / `body` / `json` on the response value object, and `normalize_uuid`.
21
+ HTTP is faked through the `http:` keyword seam; no network, no webmock.
22
+
23
+ ## What cannot be tested here
24
+
25
+ Everything that needs a booted Foreman:
26
+
27
+ - `ForemanNutanix::Nutanix` — it is a `ComputeResource` subclass, and `cluster`
28
+ is stored in the inherited `url` column, so instantiating it at all needs
29
+ ActiveRecord, the Foreman schema, and the compute-resource STI machinery.
30
+ - `ForemanNutanix::HostManagedExtensions` — a concern mixed into `Host::Managed`.
31
+ - The `.erb` views under `app/views/`.
32
+ - The API controller extensions under `app/controllers/`.
33
+ - Anything touching the database, `Rails.logger`, or `Foreman::Plugin`.
34
+
35
+ `NutanixCompute` and `NutanixAdapter` are plain objects but still call
36
+ `Rails.logger` on nearly every method, so they are not reachable from here
37
+ either without stubbing Rails.
38
+
39
+ ## What a full harness would need
40
+
41
+ 1. A `test/dummy` Rails app generated against a Foreman core checkout.
42
+ 2. That Foreman core pinned to a known version (the plugin requires >= 3.14.0),
43
+ since plugin tests load Foreman's own `test_helper`.
44
+ 3. A database created and migrated in CI before the suite runs.
45
+
46
+ Not built. The standalone suite above is the part that pays for itself today.
@@ -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
@@ -0,0 +1,62 @@
1
+ require 'test_helper'
2
+
3
+ require 'ostruct'
4
+ require_relative '../app/models/foreman_nutanix/nutanix_compute'
5
+
6
+ class NutanixComputeTest < Minitest::Test
7
+ # Regression: ServersCollection#all builds VMs without a :gpus key, because
8
+ # the shim's /list-vms payload has no gpus field. The VM index template calls
9
+ # vm.gpus.empty? on every row, so a nil default raised NoMethodError there.
10
+ def test_gpus_defaults_to_an_empty_array_when_absent
11
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1')
12
+ assert_equal [], vm.gpus
13
+ assert_empty vm.gpus
14
+ end
15
+
16
+ def test_gpus_passes_through_when_supplied
17
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1', gpus: %w[gpu-a gpu-b])
18
+ assert_equal %w[gpu-a gpu-b], vm.gpus
19
+ refute_empty vm.gpus
20
+ end
21
+
22
+ def test_gpus_explicit_nil_still_yields_an_empty_array
23
+ vm = ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1', gpus: nil)
24
+ assert_equal [], vm.gpus
25
+ end
26
+ end
27
+
28
+ class NutanixComputeGpuLabelsTest < Minitest::Test
29
+ def build(gpus)
30
+ ForemanNutanix::NutanixCompute.new('cluster-1', name: 'vm1', gpus: gpus)
31
+ end
32
+
33
+ def test_no_gpus_gives_no_labels
34
+ assert_equal [], build(nil).gpu_labels
35
+ assert_equal [], build([]).gpu_labels
36
+ end
37
+
38
+ def test_vendor_and_name_are_joined
39
+ labels = build([{ 'vendor' => 'NVIDIA', 'name' => 'Tesla T4', 'device_id' => 7864 }]).gpu_labels
40
+ assert_equal ['NVIDIA Tesla T4'], labels
41
+ end
42
+
43
+ def test_falls_back_to_device_id_when_name_is_missing_or_blank
44
+ assert_equal ['NVIDIA 7864'], build([{ 'vendor' => 'NVIDIA', 'device_id' => 7864 }]).gpu_labels
45
+ assert_equal ['NVIDIA 7864'], build([{ 'vendor' => 'NVIDIA', 'name' => ' ', 'device_id' => 7864 }]).gpu_labels
46
+ end
47
+
48
+ def test_name_alone_is_enough
49
+ assert_equal ['Tesla T4'], build([{ 'name' => 'Tesla T4' }]).gpu_labels
50
+ end
51
+
52
+ def test_entirely_empty_gpu_still_gets_a_label
53
+ assert_equal ['GPU'], build([{}]).gpu_labels
54
+ assert_equal ['GPU'], build([{ 'vendor' => nil, 'name' => nil, 'device_id' => nil }]).gpu_labels
55
+ end
56
+
57
+ def test_label_count_always_matches_gpu_count
58
+ labels = build([{ 'name' => 'A' }, {}, { 'vendor' => 'AMD' }]).gpu_labels
59
+ assert_equal 3, labels.size
60
+ refute_includes labels, ''
61
+ end
62
+ end