opennebula-cli 7.1.80.pre → 7.2.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/bin/oneacct +1 -1
  3. data/bin/oneacl +1 -1
  4. data/bin/onebackupjob +1 -1
  5. data/bin/onecluster +1 -1
  6. data/bin/onedatastore +1 -1
  7. data/bin/oneflow +1 -1
  8. data/bin/oneflow-template +1 -1
  9. data/bin/oneform +1 -1
  10. data/bin/onegroup +1 -1
  11. data/bin/onehook +1 -1
  12. data/bin/onehost +1 -1
  13. data/bin/oneimage +1 -1
  14. data/bin/oneirb +1 -1
  15. data/bin/onelog +1 -1
  16. data/bin/onemarket +1 -1
  17. data/bin/onemarketapp +1 -1
  18. data/bin/onesecgroup +1 -1
  19. data/bin/oneshowback +1 -1
  20. data/bin/onetemplate +1 -1
  21. data/bin/oneuser +1 -1
  22. data/bin/onevdc +1 -1
  23. data/bin/onevm +2 -2
  24. data/bin/onevmgroup +1 -1
  25. data/bin/onevnet +1 -1
  26. data/bin/onevntemplate +1 -1
  27. data/bin/onevrouter +1 -1
  28. data/bin/onezone +1 -1
  29. data/lib/cli_helper.rb +1 -1
  30. data/lib/command_parser.rb +135 -126
  31. data/lib/load_opennebula_paths.rb +5 -0
  32. data/lib/ods_helper.rb +541 -0
  33. data/lib/one_helper/oneacct_helper.rb +117 -109
  34. data/lib/one_helper/oneacl_helper.rb +1 -1
  35. data/lib/one_helper/onebackupjob_helper.rb +1 -1
  36. data/lib/one_helper/onecluster_helper.rb +1 -1
  37. data/lib/one_helper/onedatastore_helper.rb +87 -80
  38. data/lib/one_helper/oneflow_helper.rb +1 -1
  39. data/lib/one_helper/oneflowtemplate_helper.rb +1 -1
  40. data/lib/one_helper/oneform_helper.rb +1 -1
  41. data/lib/one_helper/onegroup_helper.rb +1 -1
  42. data/lib/one_helper/onehook_helper.rb +1 -1
  43. data/lib/one_helper/onehost_helper.rb +1 -1
  44. data/lib/one_helper/oneimage_helper.rb +1 -1
  45. data/lib/one_helper/onemarket_helper.rb +59 -58
  46. data/lib/one_helper/onemarketapp_helper.rb +1 -1
  47. data/lib/one_helper/onequota_helper.rb +240 -190
  48. data/lib/one_helper/onesecgroup_helper.rb +87 -85
  49. data/lib/one_helper/onetemplate_helper.rb +64 -64
  50. data/lib/one_helper/oneuser_helper.rb +2 -2
  51. data/lib/one_helper/onevdc_helper.rb +45 -45
  52. data/lib/one_helper/onevm_helper.rb +6 -8
  53. data/lib/one_helper/onevmgroup_helper.rb +65 -63
  54. data/lib/one_helper/onevnet_helper.rb +1 -1
  55. data/lib/one_helper/onevntemplate_helper.rb +43 -40
  56. data/lib/one_helper/onevrouter_helper.rb +86 -87
  57. data/lib/one_helper/onezone_helper.rb +98 -101
  58. data/lib/one_helper.rb +90 -71
  59. data/share/schemas/xsd/acct.xsd +3 -104
  60. data/share/schemas/xsd/cluster.xsd +4 -21
  61. data/share/schemas/xsd/datastore.xsd +4 -29
  62. data/share/schemas/xsd/document.xsd +3 -25
  63. data/share/schemas/xsd/group.xsd +2 -14
  64. data/share/schemas/xsd/group_pool.xsd +2 -14
  65. data/share/schemas/xsd/hook.xsd +2 -0
  66. data/share/schemas/xsd/host.xsd +5 -7
  67. data/share/schemas/xsd/image.xsd +2 -25
  68. data/share/schemas/xsd/marketplace.xsd +3 -22
  69. data/share/schemas/xsd/marketplaceapp.xsd +3 -25
  70. data/share/schemas/xsd/opennebula_configuration.xsd +3 -2
  71. data/share/schemas/xsd/requirements.xsd +3 -21
  72. data/share/schemas/xsd/security_group.xsd +6 -43
  73. data/share/schemas/xsd/shared.xsd +3 -3
  74. data/share/schemas/xsd/vdc.xsd +2 -7
  75. data/share/schemas/xsd/vm_group.xsd +3 -25
  76. data/share/schemas/xsd/vm_pool.xsd +2 -0
  77. data/share/schemas/xsd/vmtemplate.xsd +3 -25
  78. data/share/schemas/xsd/vnet.xsd +9 -67
  79. data/share/schemas/xsd/vnet_pool.xsd +8 -57
  80. data/share/schemas/xsd/vntemplate.xsd +3 -25
  81. data/share/schemas/xsd/vrouter.xsd +4 -32
  82. metadata +8 -6
data/lib/ods_helper.rb ADDED
@@ -0,0 +1,541 @@
1
+ # -------------------------------------------------------------------------- #
2
+ # Copyright 2002-2026, OpenNebula Project, OpenNebula Systems #
3
+ # #
4
+ # Licensed under the Apache License, Version 2.0 (the "License"); you may #
5
+ # not use this file except in compliance with the License. You may obtain #
6
+ # a copy of the License at #
7
+ # #
8
+ # http://www.apache.org/licenses/LICENSE-2.0 #
9
+ # #
10
+ # Unless required by applicable law or agreed to in writing, software #
11
+ # distributed under the License is distributed on an "AS IS" BASIS, #
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
13
+ # See the License for the specific language governing permissions and #
14
+ # limitations under the License. #
15
+ #--------------------------------------------------------------------------- #
16
+ require 'tempfile'
17
+ require 'json'
18
+ require 'yaml'
19
+
20
+ require 'one_helper'
21
+ require 'cloud/CloudClient'
22
+
23
+ # Generic CLI helper for ODS-based services
24
+ class ODSHelper < OpenNebulaHelper::OneHelper
25
+
26
+ # Configuration file name used by the helper
27
+ def self.conf_file
28
+ raise NotImplementedError, "#{name}.conf_file must be implemented"
29
+ end
30
+
31
+ def self.template_tag
32
+ raise NotImplementedError, "#{name}.template_tag must be implemented"
33
+ end
34
+
35
+ # ODS client class used by the helper
36
+ def self.client_class(options = {})
37
+ raise NotImplementedError, "#{name}.client_class must be implemented"
38
+ end
39
+
40
+ def client(options = {})
41
+ self.class.client_class.new(
42
+ :username => options[:username],
43
+ :password => options[:password],
44
+ :endpoint => options[:endpoint] || options[:server],
45
+ :opts => {
46
+ :version => options[:api_version],
47
+ :user_agent => USER_AGENT
48
+ }
49
+ )
50
+ end
51
+
52
+ #------------------------------------------------------
53
+ # Operations wrappers
54
+ #------------------------------------------------------
55
+
56
+ # Generic list flow
57
+ # @param client [ODSClient]
58
+ # @param list_method [Symbol]
59
+ # @param options [Hash]
60
+ # @yield [response] Custom renderer for normal output mode
61
+ # @return [Integer, Array]
62
+ def list_resources(client, list_method, options = {})
63
+ response = client.public_send(list_method, options)
64
+ render_response(response, options) {|data| yield(data) if block_given? }
65
+ end
66
+
67
+ # Generic show flow
68
+ # @param client [ODSClient]
69
+ # @param resource_id [Integer, String]
70
+ # @param get_method [Symbol]
71
+ # @param options [Hash]
72
+ # @yield [response] Custom renderer for normal output mode
73
+ # @return [Integer, Array]
74
+ def show_resource(client, get_method, resource_id, options = {})
75
+ response = client.public_send(get_method, resource_id, options)
76
+ render_response(response, options) {|data| yield(data) if block_given? }
77
+ end
78
+
79
+ # Generic continuous loop for top-like views.
80
+ # @param delay [Integer, Float, nil]
81
+ # @yield Body to execute in each refresh
82
+ # @return [Integer]
83
+ def top_resources(delay = nil)
84
+ delay ||= 5
85
+
86
+ begin
87
+ loop do
88
+ CLIHelper.scr_cls
89
+ CLIHelper.scr_move(0, 0)
90
+
91
+ yield
92
+
93
+ sleep delay
94
+ end
95
+ rescue StandardError => e
96
+ STDERR.puts e.message
97
+ exit(-1)
98
+ end
99
+
100
+ 0
101
+ end
102
+
103
+ # Generic update flow for JSON resources.
104
+ # If no file is provided, current resource body is fetched, dumped into a
105
+ # tempfile, opened in the editor, and then sent back using update_method.
106
+ # @param client [ODSClient]
107
+ # @param resource_id [Integer, String]
108
+ # @param get_method [Symbol]
109
+ # @param update_method [Symbol]
110
+ # @param file_path [String, nil]
111
+ # @return [Integer, Array]
112
+ def update_resource_from_editor(client, resource_id, get_method, update_method, file_path)
113
+ path =
114
+ if file_path
115
+ file_path
116
+ else
117
+ response = client.public_send(get_method, resource_id)
118
+
119
+ if CloudClient.is_error?(response)
120
+ return [response[:err_code], response[:message]]
121
+ end
122
+
123
+ body = response.dig(:TEMPLATE, self.class.template_tag)
124
+ prefix = self.class.client_class.name.split('::').first.downcase
125
+
126
+ self.class.open_json_editor(
127
+ "#{prefix}_#{resource_id}_tmp",
128
+ body
129
+ )
130
+ end
131
+
132
+ response = client.public_send(update_method, resource_id, File.read(path))
133
+
134
+ if CloudClient.is_error?(response)
135
+ [response[:err_code], response[:message]]
136
+ else
137
+ 0
138
+ end
139
+ end
140
+
141
+ #------------------------------------------------------
142
+ # Inputs
143
+ #------------------------------------------------------
144
+
145
+ def ask_required_value(label)
146
+ loop do
147
+ prompt = "> #{label}: "
148
+ print prompt
149
+
150
+ value = STDIN.readline.strip
151
+ return value unless value.to_s.empty?
152
+
153
+ puts ' A value is required.'
154
+ end
155
+ end
156
+
157
+ def ask_required_integer(label)
158
+ loop do
159
+ prompt = "> #{label}: "
160
+ print prompt
161
+
162
+ raw = STDIN.readline.strip
163
+ return raw.to_i if raw.match?(/\A-?\d+\z/)
164
+
165
+ puts " #{label} must be an integer."
166
+ end
167
+ end
168
+
169
+ def select_by_index(items)
170
+ loop do
171
+ print ' Select an option by number: '
172
+ input = STDIN.readline.strip
173
+
174
+ if input =~ /\A\d+\z/
175
+ index = input.to_i
176
+ return items[index] if index >= 0 && index < items.size
177
+ end
178
+
179
+ puts ' Invalid selection, please try again.'
180
+ end
181
+ end
182
+
183
+ # Ask user values for a list of user inputs.
184
+ # @param user_inputs [Array<Hash>, nil]
185
+ # @return [Hash, nil]
186
+ def get_user_values(user_inputs)
187
+ return if user_inputs.nil? || user_inputs.empty?
188
+
189
+ ask_user_inputs(user_inputs)
190
+ end
191
+
192
+ # Prompt interactively for input values
193
+ # @param inputs [Array<Hash>]
194
+ # @return [Hash]
195
+ def ask_user_inputs(inputs)
196
+ puts 'There are some parameters that require user input.'
197
+
198
+ answers = {}
199
+
200
+ inputs.each do |input|
201
+ name = input[:name]
202
+ description = input[:description] || ''
203
+ type = normalize_input_type(input[:type])
204
+ default = input[:default]
205
+ match = input[:match]
206
+
207
+ puts " * (#{name}) #{description} [type: #{input[:type]}]"
208
+
209
+ header = ' '
210
+ header += "Press enter for default (#{default}). " if default
211
+
212
+ answer = case type
213
+ when 'string'
214
+ ask_string_input(header, default, match)
215
+ when 'number'
216
+ ask_number_input(header, default, match)
217
+ when 'list'
218
+ ask_list_input(header, default, match)
219
+ when 'map'
220
+ ask_map_input(header, default)
221
+ else
222
+ STDERR.puts "Unknown input type '#{input[:type]}' for '#{name}'"
223
+ exit(-1)
224
+ end
225
+
226
+ answers[name] = answer
227
+ end
228
+
229
+ answers
230
+ end
231
+
232
+ # Read and parse JSON input from a file or STDIN.
233
+ #
234
+ # If a file path is provided, the content is read from that file.
235
+ # Otherwise, STDIN is used. If no input is available, nil is returned.
236
+ #
237
+ # @param file [String, nil] path to the JSON input file
238
+ # @return [Hash, Array, nil]
239
+ def self.read_json_input(file = nil)
240
+ content = nil
241
+
242
+ if file
243
+ begin
244
+ content = File.read(file)
245
+ rescue Errno::ENOENT
246
+ STDERR.puts "File not found: #{file}"
247
+ exit(-1)
248
+ end
249
+ else
250
+ stdin = OpenNebulaHelper.read_stdin
251
+ content = stdin unless stdin.empty?
252
+ end
253
+
254
+ return if content.nil? || content.strip.empty?
255
+
256
+ JSON.parse(content, :symbolize_names => true)
257
+ rescue JSON::ParserError => e
258
+ STDERR.puts "Invalid JSON - #{e.message}"
259
+ exit(-1)
260
+ end
261
+
262
+ # Open the editor with JSON content and return the edited file path.
263
+ # @param prefix [String]
264
+ # @param content [Object]
265
+ # @return [String]
266
+ def self.open_json_editor(prefix, content)
267
+ tmp = Tempfile.new(prefix)
268
+ path = tmp.path
269
+
270
+ tmp.write(JSON.pretty_generate(content))
271
+ tmp.flush
272
+
273
+ editor_path = ENV['EDITOR'] || OpenNebulaHelper::EDITOR_PATH
274
+ system("#{editor_path} #{path}")
275
+
276
+ unless $CHILD_STATUS.exitstatus.zero?
277
+ STDERR.puts 'Editor not defined'
278
+ exit(-1)
279
+ end
280
+
281
+ tmp.close
282
+
283
+ path
284
+ end
285
+
286
+ def self.update_from_editor(client, resource_id, get_method)
287
+ response = client.public_send(get_method, resource_id)
288
+ return [response[:err_code], response[:message]] if CloudClient.is_error?(response)
289
+
290
+ body = response.dig(:TEMPLATE, template_tag)
291
+ prefix = client_class.name.split('::').first.downcase
292
+ path = open_json_editor("#{prefix}_#{resource_id}_tmp", body)
293
+
294
+ read_json_input(path)
295
+ end
296
+
297
+ # Prompt for string input
298
+ # @param header [String]
299
+ # @param default [Object]
300
+ # @param match [Hash, nil]
301
+ # @return [String]
302
+ def ask_string_input(header, default, match)
303
+ if match&.dig(:type) == 'list'
304
+ options = match[:values] || []
305
+
306
+ options.each_with_index {|opt, i| puts " #{i}: #{opt}" }
307
+ puts
308
+
309
+ loop do
310
+ print "#{header}Please type the selection number: "
311
+ raw = STDIN.readline.strip
312
+
313
+ if raw.empty?
314
+ answer = default
315
+ return answer if options.include?(answer)
316
+ else
317
+ index = Integer(raw, :exception => false)
318
+ answer = options[index] if index && index >= 0
319
+ return answer if answer
320
+ end
321
+
322
+ puts ' Invalid selection, please try again.'
323
+ end
324
+ else
325
+ print header
326
+ answer = STDIN.readline.strip
327
+ answer = OpenNebulaHelper.editor_input if answer == '<<EDITOR>>'
328
+ answer = default if answer.empty?
329
+ answer
330
+ end
331
+ end
332
+
333
+ # Prompt for numeric input
334
+ # @param header [String]
335
+ # @param default [Object]
336
+ # @param match [Hash, nil]
337
+ # @return [Integer, Float]
338
+ def ask_number_input(header, default, match)
339
+ min = match&.dig(:values, :min)
340
+ max = match&.dig(:values, :max)
341
+
342
+ begin
343
+ range_msg = min && max ? " (#{min} to #{max})" : ''
344
+ print "#{header}Enter a number#{range_msg}: "
345
+
346
+ raw = STDIN.readline.strip
347
+ raw = default.to_s if raw.empty?
348
+
349
+ if raw.match?(/\A-?\d+\z/)
350
+ answer = raw.to_i
351
+ elsif raw.match?(/\A-?\d+\.\d+\z/)
352
+ answer = raw.to_f
353
+ else
354
+ puts 'Not a valid number'
355
+ raise ArgumentError
356
+ end
357
+
358
+ raise ArgumentError if min && answer < min
359
+ raise ArgumentError if max && answer > max
360
+
361
+ answer
362
+ rescue StandardError
363
+ puts ' Invalid number, please try again.'
364
+ retry
365
+ end
366
+ end
367
+
368
+ # Prompt for list input
369
+ # @param header [String]
370
+ # @param default [Object]
371
+ # @param match [Hash, nil]
372
+ # @return [Array]
373
+ def ask_list_input(header, default, match)
374
+ loop do
375
+ print "#{header}Enter comma-separated values: "
376
+ raw = STDIN.readline.strip
377
+
378
+ if raw.empty?
379
+ if default.is_a?(Array)
380
+ return default
381
+ else
382
+ puts ' No default available.'
383
+ next
384
+ end
385
+ end
386
+
387
+ answer = raw.split(',').map(&:strip).reject(&:empty?)
388
+
389
+ if match&.dig(:type) == 'list'
390
+ invalid = answer - Array(match[:values])
391
+
392
+ if invalid.any?
393
+ puts " Invalid values: #{invalid.join(', ')}"
394
+ puts " Allowed: #{Array(match[:values]).join(', ')}"
395
+ next
396
+ end
397
+ end
398
+
399
+ return answer
400
+ end
401
+ end
402
+
403
+ # Prompt for map input
404
+ # @param header [String]
405
+ # @param default [Object]
406
+ # @return [Hash]
407
+ def ask_map_input(header, default)
408
+ loop do
409
+ print "#{header}Enter KEY=VALUE pairs separated by commas: "
410
+ raw = STDIN.readline.strip
411
+
412
+ if raw.empty?
413
+ if default.is_a?(Hash)
414
+ return default
415
+ else
416
+ puts ' No default available.'
417
+ next
418
+ end
419
+ end
420
+
421
+ begin
422
+ answer = {}
423
+
424
+ raw.split(',').each do |pair|
425
+ key, value = pair.split('=', 2)
426
+
427
+ raise ArgumentError if key.nil? || value.nil?
428
+ raise ArgumentError if key.strip.empty? || value.strip.empty?
429
+
430
+ answer[key.strip] = value.strip
431
+ end
432
+
433
+ return answer
434
+ rescue StandardError
435
+ puts ' Invalid map format. Expected KEY=VALUE,...'
436
+ end
437
+ end
438
+ end
439
+
440
+ # Normalize typed user input definitions
441
+ # @param type [String]
442
+ # @return [String]
443
+ def normalize_input_type(type)
444
+ case type
445
+ when /\Amap\(/
446
+ 'map'
447
+ when /\Alist\(/
448
+ 'list'
449
+ else
450
+ type
451
+ end
452
+ end
453
+
454
+ #------------------------------------------------------
455
+ # Utilities
456
+ #------------------------------------------------------
457
+
458
+ # Parse a JSON string or KEY=VALUE string to a hash
459
+ def self.parse_values_option(raw)
460
+ value = raw.to_s.strip
461
+ return [0, {}] if value.empty?
462
+
463
+ begin
464
+ if value.start_with?('{')
465
+ parsed = JSON.parse(value)
466
+ return [0, parsed.transform_keys(&:to_s)] if parsed.is_a?(Hash)
467
+ end
468
+ rescue JSON::ParserError
469
+ nil
470
+ end
471
+
472
+ parsed = {}
473
+
474
+ begin
475
+ value.split(',').each do |pair|
476
+ key, item = pair.split('=', 2)
477
+
478
+ raise ArgumentError if key.nil? || item.nil?
479
+
480
+ key = key.strip
481
+ item = item.strip
482
+
483
+ raise ArgumentError if key.empty?
484
+
485
+ parsed[key] = item
486
+ end
487
+ rescue ArgumentError
488
+ return [-1, 'Invalid --values format. Use KEY=VALUE[,KEY=VALUE...] or a JSON object']
489
+ end
490
+
491
+ [0, parsed]
492
+ end
493
+
494
+ # Checks whether a helper hook returned a CLI error tuple.
495
+ # @param value [Object]
496
+ # @return [Boolean]
497
+ def is_error?(value)
498
+ value.is_a?(Array) && value.size == 2 && value[0].is_a?(Integer)
499
+ end
500
+
501
+ # Render a response in JSON, YAML or custom formatted output
502
+ # @param response [Object]
503
+ # @param options [Hash]
504
+ # @yield [response] Custom rendering block for table/plain output
505
+ # @return [Integer, Array]
506
+ def render_response(response, options = {})
507
+ if CloudClient.is_error?(response)
508
+ [response[:err_code], response[:message]]
509
+ elsif options[:json]
510
+ [0, JSON.pretty_generate(response)]
511
+ elsif options[:yaml]
512
+ [0, response.to_yaml(:indent => 4)]
513
+ else
514
+ yield(response) if block_given?
515
+ 0
516
+ end
517
+ end
518
+
519
+ def format_template(template, indent = 6)
520
+ return 'N/A' unless template
521
+
522
+ template.map do |k, v|
523
+ value =
524
+ if v.is_a?(Hash)
525
+ v.map {|k2, v2| ' ' * indent + "#{k}: #{k2}=#{v2}" }
526
+ elsif v.is_a?(Array)
527
+ v.map do |elem|
528
+ if elem.is_a?(Hash)
529
+ elem.map {|k2, v2| ' ' * indent + "#{k}: #{k2}=#{v2}" }
530
+ else
531
+ ' ' * indent + "#{k}: #{elem}"
532
+ end
533
+ end.flatten
534
+ else
535
+ ' ' * indent + "#{k}: #{v}"
536
+ end
537
+ value.is_a?(Array) ? value.join("\n") : value
538
+ end.join("\n")
539
+ end
540
+
541
+ end