foreman_nutanix 0.0.1

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +22 -0
  3. data/README.md +32 -0
  4. data/Rakefile +49 -0
  5. data/app/assets/javascripts/foreman_nutanix/locale/en/foreman_nutanix.js +55 -0
  6. data/app/controllers/concerns/foreman/controller/parameters/compute_resource_extension.rb +17 -0
  7. data/app/controllers/foreman_nutanix/api/v2/apipie_extensions.rb +16 -0
  8. data/app/controllers/foreman_nutanix/api/v2/compute_resources_extensions.rb +29 -0
  9. data/app/controllers/foreman_nutanix/api/v2/hosts_controller_extensions.rb +50 -0
  10. data/app/lib/foreman_nutanix/nutanix_adapter.rb +105 -0
  11. data/app/lib/nutanix_compute/compute_collection.rb +23 -0
  12. data/app/lib/nutanix_extensions/attached_disk.rb +22 -0
  13. data/app/models/concerns/foreman_nutanix/host_managed_extensions.rb +38 -0
  14. data/app/models/foreman_nutanix/nutanix.rb +471 -0
  15. data/app/models/foreman_nutanix/nutanix_compute.rb +370 -0
  16. data/app/views/compute_resources/form/_nutanix.html.erb +9 -0
  17. data/app/views/compute_resources/show/_nutanix.html.erb +238 -0
  18. data/app/views/compute_resources_vms/form/nutanix/_base.html.erb +72 -0
  19. data/app/views/compute_resources_vms/form/nutanix/_volume.html.erb +1 -0
  20. data/app/views/compute_resources_vms/index/_gce.html.erb +41 -0
  21. data/app/views/compute_resources_vms/index/_nutanix.html.erb +41 -0
  22. data/app/views/compute_resources_vms/show/_gce.html.erb +18 -0
  23. data/app/views/compute_resources_vms/show/_nutanix.html.erb +81 -0
  24. data/config/initializers/zeitwerk.rb +1 -0
  25. data/config/routes.rb +2 -0
  26. data/lib/foreman_nutanix/engine.rb +56 -0
  27. data/lib/foreman_nutanix/version.rb +3 -0
  28. data/lib/foreman_nutanix.rb +4 -0
  29. data/lib/tasks/foreman_nutanix_tasks.rake +31 -0
  30. data/locale/foreman_nutanix.pot +131 -0
  31. data/package.json +41 -0
  32. data/webpack/global_index.js +6 -0
  33. data/webpack/global_test_setup.js +11 -0
  34. data/webpack/index.js +7 -0
  35. data/webpack/legacy.js +16 -0
  36. data/webpack/src/Extends/index.js +15 -0
  37. data/webpack/src/Router/routes.js +5 -0
  38. data/webpack/src/reducers.js +7 -0
  39. data/webpack/test_setup.js +17 -0
  40. metadata +100 -0
@@ -0,0 +1,471 @@
1
+ module ForemanNutanix
2
+ class Nutanix < ComputeResource
3
+ validates :cluster, presence: true
4
+
5
+ def self.model_name
6
+ ComputeResource.model_name
7
+ end
8
+
9
+ def self.provider_friendly_name
10
+ 'Nutanix'
11
+ end
12
+
13
+ def self.available?
14
+ true
15
+ end
16
+
17
+ def capabilities
18
+ %i[build power]
19
+ end
20
+
21
+ # Foreman checks this for power management support
22
+ def supports_power?
23
+ Rails.logger.info '=== NUTANIX: supports_power? called ==='
24
+ true
25
+ end
26
+
27
+ def cluster=(cluster)
28
+ self.url = cluster
29
+ end
30
+
31
+ def cluster
32
+ url
33
+ end
34
+
35
+ def cluster_details
36
+ available_clusters.find { |cluster| cluster.ext_id == self.cluster }
37
+ end
38
+
39
+ def to_label
40
+ "#{name} (#{provider_friendly_name})"
41
+ end
42
+
43
+ def shim_server_url
44
+ ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
45
+ end
46
+
47
+ def provided_attributes
48
+ super.merge({ mac: :mac })
49
+ end
50
+
51
+ # Test connection to the compute resource
52
+ def test_connection(_options = {})
53
+ Rails.logger.info "=== NUTANIX: Testing connection to cluster #{cluster} ==="
54
+ true
55
+ end
56
+
57
+ # Available clusters for selection
58
+ 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)
63
+
64
+ data.map do |cluster|
65
+ cluster[:name] = "#{cluster['name']} (#{cluster['arch']})"
66
+ OpenStruct.new(cluster)
67
+ end
68
+ rescue StandardError => e
69
+ Rails.logger.error "=== NUTANIX: Error fetching clusters: #{e.message} ==="
70
+ []
71
+ end
72
+
73
+ # Available networks for VMs
74
+ def available_networks
75
+ Rails.logger.info '=== NUTANIX: Fetching available networks from shim server ==='
76
+ base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
77
+ uri = URI("#{base.chomp('/')}/api/v1/networking/list-networks")
78
+ response = Net::HTTP.get_response(uri)
79
+ data = JSON.parse(response.body)
80
+
81
+ data.map do |network|
82
+ OpenStruct.new({
83
+ id: network['ext_id'],
84
+ ext_id: network['ext_id'],
85
+ name: network['name'],
86
+ subnet_type: network['subnet_type'],
87
+ cluster_name: network['cluster_name'],
88
+ ipv4_subnet: network['ipv4_subnet'],
89
+ ipv4_gateway: network['ipv4_gateway'],
90
+ })
91
+ end
92
+ rescue StandardError => e
93
+ Rails.logger.error "=== NUTANIX: Error fetching networks: #{e.message} ==="
94
+ []
95
+ end
96
+
97
+ # Networks method (alias for available_networks)
98
+ def networks(opts = {})
99
+ Rails.logger.info "=== NUTANIX: NETWORKS called with opts: #{opts} ==="
100
+ available_networks
101
+ end
102
+
103
+ # Available storage containers for VMs
104
+ def available_storage_containers
105
+ Rails.logger.info '=== NUTANIX: Fetching available storage containers from shim server ==='
106
+ base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
107
+ uri = URI("#{base.chomp('/')}/api/v1/clustermgmt/list-storage-containers")
108
+ response = Net::HTTP.get_response(uri)
109
+ data = JSON.parse(response.body)
110
+
111
+ # Filter storage containers by the cluster associated with this compute resource
112
+ cluster_ext_id = cluster
113
+ Rails.logger.info "=== NUTANIX: Storage containers - total: #{data.count}, cluster_ext_id: #{cluster_ext_id} ==="
114
+ filtered_data = data.select { |container| container['cluster_ext_id'] == cluster_ext_id }
115
+ Rails.logger.info "=== NUTANIX: Storage containers - filtered: #{filtered_data.count} ==="
116
+
117
+ result = filtered_data.map do |container|
118
+ OpenStruct.new({
119
+ id: container['ext_id'],
120
+ ext_id: container['ext_id'],
121
+ name: container['name'],
122
+ cluster_name: container['cluster_name'],
123
+ max_capacity_bytes: container['max_capacity_bytes'],
124
+ replication_factor: container['replication_factor'],
125
+ is_compression_enabled: container['is_compression_enabled'],
126
+ })
127
+ end
128
+ Rails.logger.info "=== NUTANIX: Storage containers returning: #{result.map { |c| { id: c.id, name: c.name } }} ==="
129
+ result
130
+ rescue StandardError => e
131
+ Rails.logger.error "=== NUTANIX: Error fetching storage containers: #{e.message} ==="
132
+ []
133
+ end
134
+
135
+ # Cluster resource statistics (CPU, memory, storage usage)
136
+ def cluster_resource_stats
137
+ Rails.logger.info '=== NUTANIX: Fetching cluster resource stats from shim server ==='
138
+ base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
139
+ cluster_id = cluster
140
+ return nil unless cluster_id
141
+
142
+ uri = URI("#{base.chomp('/')}/api/v1/clustermgmt/clusters/#{cluster_id}/stats")
143
+ response = Net::HTTP.get_response(uri)
144
+ data = JSON.parse(response.body)
145
+
146
+ OpenStruct.new(data)
147
+ rescue StandardError => e
148
+ Rails.logger.error "=== NUTANIX: Error fetching cluster stats: #{e.message} ==="
149
+ nil
150
+ end
151
+
152
+ # Available machine types/flavors
153
+ def available_flavors
154
+ Rails.logger.info '=== NUTANIX: Returning available flavors ==='
155
+ [OpenStruct.new({ id: 'small', name: 'Small (2 CPU, 4GB RAM)' })]
156
+ end
157
+ alias_method :machine_types, :available_flavors
158
+
159
+ # Available images
160
+ def available_images(_opts = {})
161
+ Rails.logger.info '=== NUTANIX: Fetching available images from shim server ==='
162
+ base = ENV['NUTANIX_SHIM_SERVER_ADDR'] || 'http://localhost:8000'
163
+ uri = URI("#{base.chomp('/')}/api/v1/vmm/list-images")
164
+ response = Net::HTTP.get_response(uri)
165
+ data = JSON.parse(response.body)
166
+
167
+ # Filter images by cluster if cluster_location_ext_ids is available
168
+ cluster_ext_id = cluster
169
+ filtered_data = data.select do |image|
170
+ # Include image if it's available on this cluster
171
+ cluster_locations = image['cluster_location_ext_ids'] || []
172
+ cluster_locations.include?(cluster_ext_id)
173
+ end
174
+
175
+ filtered_data.map do |image|
176
+ # Convert size to GB for display
177
+ size_gb = image['size_bytes'] ? (image['size_bytes'].to_f / 1024**3).round(2) : 0
178
+ display_name = "#{image['name']} (#{size_gb} GB)"
179
+
180
+ OpenStruct.new({
181
+ id: image['ext_id'],
182
+ ext_id: image['ext_id'],
183
+ name: display_name,
184
+ description: image['description'],
185
+ size_bytes: image['size_bytes'],
186
+ type: image['type'],
187
+ })
188
+ end
189
+ rescue StandardError => e
190
+ Rails.logger.error "=== NUTANIX: Error fetching images: #{e.message} ==="
191
+ []
192
+ end
193
+
194
+ # Core provisioning method - this is what Foreman calls to create a VM
195
+ def create_vm(args = {})
196
+ Rails.logger.info "=== NUTANIX: CREATE_VM CALLED with args: #{args} ==="
197
+ Rails.logger.info "=== NUTANIX: CREATE_VM args class: #{args.class}, keys: #{begin
198
+ args.keys
199
+ rescue StandardError
200
+ 'N/A'
201
+ end} ==="
202
+ Rails.logger.info "=== NUTANIX: CREATE_VM network_id: #{args[:network_id] || args['network_id']}, storage_container: #{args[:storage_container] || args['storage_container']} ==="
203
+
204
+ vm = new_vm(args)
205
+ Rails.logger.info '=== NUTANIX: CREATE_VM calling vm.save ==='
206
+ vm.save
207
+
208
+ Rails.logger.info "=== NUTANIX: CREATE_VM returning VM: #{vm} ==="
209
+ result_vm = find_vm_by_uuid(vm.identity)
210
+
211
+ # Auto-exit build mode since we're creating bare VMs without OS installation
212
+ # This prevents the "Cancel Build" button from appearing
213
+ if args[:provision_method] == 'image' || true # Always exit build mode for now
214
+ Rails.logger.info '=== NUTANIX: Auto-exiting build mode for bare VM provisioning ==='
215
+ # NOTE: The host object will be available in the orchestration queue
216
+ # and will automatically exit build mode after VM creation completes
217
+ end
218
+
219
+ result_vm
220
+ rescue StandardError => e
221
+ Rails.logger.error "=== NUTANIX: CREATE_VM ERROR: #{e.message} ==="
222
+ raise e
223
+ end
224
+
225
+ # Called by Foreman after host orchestration completes
226
+ # This is where we exit build mode for bare VM provisioning
227
+ def setHostForOrchestration(host)
228
+ Rails.logger.info "=== NUTANIX: setHostForOrchestration called for host: #{host.name} ==="
229
+ super if defined?(super)
230
+
231
+ # Auto-exit build mode for bare VM provisioning
232
+ # Since we're not installing an OS, the host will never callback naturally
233
+ if host && host.build?
234
+ Rails.logger.info "=== NUTANIX: Auto-exiting build mode for host #{host.name} ==="
235
+ host.build = false
236
+ host.save!
237
+ end
238
+ rescue StandardError => e
239
+ Rails.logger.error "=== NUTANIX: Error in setHostForOrchestration: #{e.message} ==="
240
+ # Don't fail the whole provisioning if this fails
241
+ end
242
+
243
+ # New VM instance (not persisted)
244
+ def new_vm(attr = {})
245
+ Rails.logger.info "=== NUTANIX: NEW_VM CALLED with attr: #{attr} ==="
246
+ Rails.logger.info "=== NUTANIX: NEW_VM attr keys: #{attr.keys} ==="
247
+ Rails.logger.info "=== NUTANIX: NEW_VM storage_container value: #{attr['storage_container'] || attr[:storage_container]} ==="
248
+ vm_attrs = vm_instance_defaults.merge(attr.to_hash.deep_symbolize_keys)
249
+ vm_attrs = normalize_vm_attrs(vm_attrs)
250
+ Rails.logger.info "=== NUTANIX: NEW_VM merged attrs: #{vm_attrs} ==="
251
+ Rails.logger.info "=== NUTANIX: NEW_VM merged keys: #{vm_attrs.keys} ==="
252
+
253
+ # Use the Foreman pattern - client.servers.new returns our VM model
254
+ client.servers.new(vm_attrs)
255
+ end
256
+
257
+ # Default attributes for new VMs
258
+ # TODO: This is almost certainly wrong, namely 'zone' is not relevent.
259
+ def vm_instance_defaults
260
+ Rails.logger.info '=== NUTANIX: VM_INSTANCE_DEFAULTS called ==='
261
+ {
262
+ zone: 'default-zone',
263
+ machine_type: 'small',
264
+ cpus: 2,
265
+ memory: 4,
266
+ }
267
+ end
268
+
269
+ # Normalize VM attributes from form
270
+ def normalize_vm_attrs(vm_attrs)
271
+ Rails.logger.info "=== NUTANIX: NORMALIZE_VM_ATTRS called with: #{vm_attrs} ==="
272
+ normalized = vm_attrs.dup
273
+
274
+ # Convert string numbers to integers
275
+ normalized[:cpus] = normalized[:cpus].to_i if normalized[:cpus]
276
+ normalized[:memory] = normalized[:memory].to_i if normalized[:memory]
277
+
278
+ normalized
279
+ end
280
+
281
+ # Find existing VM by UUID
282
+ def find_vm_by_uuid(uuid)
283
+ Rails.logger.info "=== NUTANIX: FIND_VM_BY_UUID CALLED with uuid: #{uuid} ==="
284
+ return nil if uuid.nil? || uuid.to_s.strip.empty?
285
+
286
+ client.servers.get(uuid)
287
+ end
288
+
289
+ # Foreman might call ready? on the compute resource
290
+ def ready?
291
+ Rails.logger.info '=== NUTANIX: Nutanix::ready? called ==='
292
+ true
293
+ end
294
+
295
+ # Start VM - called by Foreman for power on
296
+ def start_vm(uuid)
297
+ Rails.logger.info "=== NUTANIX: START_VM CALLED with uuid: #{uuid} ==="
298
+ actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
299
+
300
+ base = shim_server_url
301
+ uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
302
+
303
+ http = Net::HTTP.new(uri.host, uri.port)
304
+ http.use_ssl = uri.scheme == 'https'
305
+
306
+ request = Net::HTTP::Post.new(uri.path)
307
+ request['Content-Type'] = 'application/json'
308
+ request.body = { action: 'POWER_ON' }.to_json
309
+
310
+ response = http.request(request)
311
+ response.is_a?(Net::HTTPSuccess)
312
+ rescue StandardError => e
313
+ Rails.logger.error "=== NUTANIX: START_VM ERROR: #{e.message} ==="
314
+ raise e
315
+ end
316
+
317
+ # Stop VM - called by Foreman for power off
318
+ def stop_vm(uuid)
319
+ Rails.logger.info "=== NUTANIX: STOP_VM CALLED with uuid: #{uuid} ==="
320
+ actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
321
+
322
+ base = shim_server_url
323
+ uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
324
+
325
+ http = Net::HTTP.new(uri.host, uri.port)
326
+ http.use_ssl = uri.scheme == 'https'
327
+
328
+ request = Net::HTTP::Post.new(uri.path)
329
+ request['Content-Type'] = 'application/json'
330
+ request.body = { action: 'POWER_OFF' }.to_json
331
+
332
+ response = http.request(request)
333
+ response.is_a?(Net::HTTPSuccess)
334
+ rescue StandardError => e
335
+ Rails.logger.error "=== NUTANIX: STOP_VM ERROR: #{e.message} ==="
336
+ raise e
337
+ end
338
+
339
+ # Get VM power state - called by Foreman to check power status
340
+ def vm_power_state(vm)
341
+ Rails.logger.info "=== NUTANIX: VM_POWER_STATE CALLED for vm: #{vm} ==="
342
+ uuid = vm.respond_to?(:identity) ? vm.identity : vm.to_s
343
+ actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
344
+
345
+ base = shim_server_url
346
+ uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}/power-state")
347
+ response = Net::HTTP.get_response(uri)
348
+
349
+ if response.is_a?(Net::HTTPSuccess)
350
+ data = JSON.parse(response.body)
351
+ state = data['power_state']
352
+ Rails.logger.info "=== NUTANIX: VM_POWER_STATE returning: #{state} ==="
353
+ # Return hash that Foreman expects
354
+ { state: (state == 'ON') ? 'running' : 'off' }
355
+ else
356
+ { state: 'unknown' }
357
+ end
358
+ rescue StandardError => e
359
+ Rails.logger.error "=== NUTANIX: VM_POWER_STATE ERROR: #{e.message} ==="
360
+ { state: 'unknown' }
361
+ end
362
+
363
+ # Power operations - called by Foreman's power_status API
364
+ def power(uuid, action)
365
+ Rails.logger.info "=== NUTANIX: POWER CALLED with uuid: #{uuid}, action: #{action} ==="
366
+ case action.to_s
367
+ when 'start', 'on'
368
+ start_vm(uuid)
369
+ when 'stop', 'off'
370
+ stop_vm(uuid)
371
+ when 'state', 'status'
372
+ vm = find_vm_by_uuid(uuid)
373
+ vm&.state || 'unknown'
374
+ else
375
+ Rails.logger.warn "=== NUTANIX: Unknown power action: #{action} ==="
376
+ false
377
+ end
378
+ end
379
+
380
+ # Destroy VM
381
+ def destroy_vm(uuid)
382
+ Rails.logger.info "=== NUTANIX: DESTROY_VM CALLED with uuid: #{uuid} ==="
383
+
384
+ return true if uuid.nil? || uuid.to_s.strip.empty?
385
+
386
+ # Extract the actual UUID if it has a prefix
387
+ actual_uuid = uuid.to_s.include?(':') ? uuid.to_s.split(':').last : uuid.to_s
388
+
389
+ # Call the shim server to delete the VM
390
+ base = shim_server_url
391
+ uri = URI("#{base.chomp('/')}/api/v1/vmm/vms/#{actual_uuid}")
392
+
393
+ http = Net::HTTP.new(uri.host, uri.port)
394
+ http.use_ssl = uri.scheme == 'https'
395
+
396
+ request = Net::HTTP::Delete.new(uri.path)
397
+ response = http.request(request)
398
+
399
+ if response.is_a?(Net::HTTPNoContent) || response.is_a?(Net::HTTPSuccess)
400
+ Rails.logger.info "=== NUTANIX: VM #{actual_uuid} deleted successfully ==="
401
+ true
402
+ else
403
+ error_message = "Failed to delete VM: #{response.code} - #{response.body}"
404
+ Rails.logger.error "=== NUTANIX: #{error_message} ==="
405
+ raise StandardError, error_message
406
+ end
407
+ rescue ActiveRecord::RecordNotFound
408
+ true
409
+ rescue StandardError => e
410
+ Rails.logger.error "=== NUTANIX: Error in destroy_vm: #{e.message} ==="
411
+ raise e
412
+ end
413
+
414
+ # Console access
415
+ # TODO: Untested, probably doesn't work
416
+ def console(uuid)
417
+ Rails.logger.info "=== NUTANIX: CONSOLE CALLED with uuid: #{uuid} ==="
418
+ vm = find_vm_by_uuid(uuid)
419
+ {
420
+ 'output' => 'Mock console output', 'timestamp' => Time.now.utc,
421
+ :type => 'log', :name => vm.name
422
+ }
423
+ end
424
+
425
+ # Associate host with VM
426
+ def associated_host(vm)
427
+ Rails.logger.info "=== NUTANIX: ASSOCIATED_HOST CALLED for vm: #{vm.name} ==="
428
+ associate_by('ip', [vm.vm_ip_address, vm.private_ip_address])
429
+ end
430
+
431
+ # User data support
432
+ def user_data_supported?
433
+ true
434
+ end
435
+
436
+ # New volume creation
437
+ # TODO: Not sure we can create new volumes in Nutanix?
438
+ def new_volume(attrs = {})
439
+ Rails.logger.info "=== NUTANIX: NEW_VOLUME CALLED with attrs: #{attrs} ==="
440
+ OpenStruct.new(attrs)
441
+ end
442
+
443
+ # List all VMs
444
+ def vms(attrs = {})
445
+ Rails.logger.info "=== NUTANIX: VMS CALLED with attrs: #{attrs} ==="
446
+ client.servers(attrs)
447
+ rescue StandardError => e
448
+ Rails.logger.error "=== NUTANIX: VMS ERROR: #{e.message} ==="
449
+ raise e
450
+ end
451
+
452
+ # Host attributes for VM creation
453
+ def host_create_attrs(host)
454
+ Rails.logger.info "=== NUTANIX: HOST_CREATE_ATTRS CALLED for host: #{host.name} ==="
455
+ super
456
+ end
457
+
458
+ # Validate host before provisioning
459
+ def validate_host(host)
460
+ Rails.logger.info "=== NUTANIX: VALIDATE_HOST CALLED for host: #{host.name} ==="
461
+ super
462
+ end
463
+
464
+ private
465
+
466
+ def client
467
+ Rails.logger.info "=== NUTANIX: Creating client for cluster #{cluster} ==="
468
+ @client ||= NutanixAdapter.new(cluster)
469
+ end
470
+ end
471
+ end