morpheus-cli 9.0.1 → 9.1.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +1 -1
  3. data/lib/morpheus/api/affinity_groups_interface.rb +46 -0
  4. data/lib/morpheus/api/api_client.rb +17 -0
  5. data/lib/morpheus/api/clouds_interface.rb +42 -0
  6. data/lib/morpheus/api/clusters_interface.rb +55 -1
  7. data/lib/morpheus/api/distributed_workers_interface.rb +41 -0
  8. data/lib/morpheus/api/host_vm_groups_interface.rb +37 -0
  9. data/lib/morpheus/api/servers_interface.rb +8 -0
  10. data/lib/morpheus/api/virtual_switches_interface.rb +9 -0
  11. data/lib/morpheus/cli/commands/affinity_groups_command.rb +554 -0
  12. data/lib/morpheus/cli/commands/backups_command.rb +11 -0
  13. data/lib/morpheus/cli/commands/clouds.rb +421 -29
  14. data/lib/morpheus/cli/commands/clusters.rb +507 -20
  15. data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
  16. data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
  17. data/lib/morpheus/cli/commands/hosts.rb +72 -1
  18. data/lib/morpheus/cli/commands/instances.rb +12 -1
  19. data/lib/morpheus/cli/commands/networks_command.rb +35 -3
  20. data/lib/morpheus/cli/commands/virtual_switches_command.rb +336 -0
  21. data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
  22. data/lib/morpheus/cli/mixins/infrastructure_helper.rb +143 -4
  23. data/lib/morpheus/cli/option_types.rb +36 -1
  24. data/lib/morpheus/cli/version.rb +1 -1
  25. data/test/api/affinity_groups_interface_test.rb +43 -0
  26. data/test/api/clouds_interface_test.rb +67 -0
  27. data/test/api/host_vm_groups_interface_test.rb +35 -0
  28. data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
  29. data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
  30. data/test/cli/affinity_groups_test.rb +57 -0
  31. data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
  32. data/test/cli/affinity_helper_test.rb +367 -0
  33. data/test/cli/backups_test.rb +212 -0
  34. data/test/cli/clusters_uuid_test.rb +131 -0
  35. data/test/cli/distributed_workers_test.rb +68 -0
  36. data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
  37. data/test/cli/host_vm_groups_test.rb +63 -0
  38. data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
  39. metadata +39 -2
@@ -0,0 +1,367 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'json'
4
+ require 'morpheus'
5
+
6
+ # Unit tests for Morpheus::Cli::AffinityHelper
7
+ #
8
+ # These are pure unit tests of the shared affinity enums and formatters.
9
+ # They deliberately do NOT extend MorpheusTest::TestCase because they require
10
+ # no appliance, no remote and no authentication - they must always be runnable.
11
+ #
12
+ # These tests exist to guard a real regression: the affinity type enum was
13
+ # previously duplicated across clouds.rb and clusters.rb and the two drifted,
14
+ # leaving the cloud-scoped command able to offer only 2 of the 4 valid types
15
+ # and silently rendering mandatory _MUST constraints as soft ones.
16
+ class AffinityHelperTest < Test::Unit::TestCase
17
+
18
+ # Bare host for the mixin under test
19
+ class HelperHost
20
+ include Morpheus::Cli::AffinityHelper
21
+ end
22
+
23
+ def setup
24
+ @helper = HelperHost.new
25
+ end
26
+
27
+ ## Enums
28
+
29
+ # The canonical list, per morpheus-ui. If this test fails, the CLI, the UI and
30
+ # the OpenAPI affinity-type schema have drifted apart.
31
+ def test_affinity_types_contains_all_four_values
32
+ expected = %w[KEEP_TOGETHER KEEP_SEPARATE KEEP_TOGETHER_MUST KEEP_SEPARATE_MUST]
33
+ actual = @helper.affinity_type_select_options.collect {|it| it['value'] }
34
+ assert_equal expected.sort, actual.sort,
35
+ "Affinity types drifted from morpheus-ui. Expected exactly: #{expected.sort.inspect}"
36
+ end
37
+
38
+ def test_affinity_types_includes_mandatory_must_variants
39
+ values = @helper.affinity_type_select_options.collect {|it| it['value'] }
40
+ assert values.include?('KEEP_TOGETHER_MUST'), "KEEP_TOGETHER_MUST must be offered"
41
+ assert values.include?('KEEP_SEPARATE_MUST'), "KEEP_SEPARATE_MUST must be offered"
42
+ end
43
+
44
+ # Per HostVmGroup.HostVmGroupType, morpheus-ui rc-9.1.0
45
+ # (morpheus-domains/grails-app/domain/com/morpheus/HostVmGroup.groovy:32).
46
+ def test_host_vm_group_types
47
+ expected = %w[SITE_GROUP HOST_GROUP VM_GROUP]
48
+ actual = @helper.host_vm_group_type_select_options.collect {|it| it['value'] }
49
+ assert_equal expected.sort, actual.sort
50
+ end
51
+
52
+ # Every select option must carry a display name, or the prompt renders blanks.
53
+ def test_all_select_options_have_name_and_value
54
+ [
55
+ @helper.affinity_type_select_options,
56
+ @helper.host_vm_group_type_select_options,
57
+ ].each do |options|
58
+ options.each do |opt|
59
+ assert_not_nil opt['name'], "select option missing 'name': #{opt.inspect}"
60
+ assert_not_nil opt['value'], "select option missing 'value': #{opt.inspect}"
61
+ assert !opt['name'].to_s.empty?, "select option has empty 'name': #{opt.inspect}"
62
+ assert !opt['value'].to_s.empty?, "select option has empty 'value': #{opt.inspect}"
63
+ end
64
+ end
65
+ end
66
+
67
+ # Callers mutate the hashes they get back (adding required/defaultValue),
68
+ # so the frozen constants must never be handed out directly.
69
+ def test_select_options_are_defensive_copies
70
+ first = @helper.affinity_type_select_options
71
+ first[0]['name'] = 'MUTATED'
72
+ second = @helper.affinity_type_select_options
73
+ assert_not_equal 'MUTATED', second[0]['name'],
74
+ "select options leaked a reference to the shared constant"
75
+ end
76
+
77
+ ## Formatting
78
+
79
+ def test_format_affinity_type_renders_all_four
80
+ assert_equal 'Keep Together', @helper.format_affinity_type('KEEP_TOGETHER')
81
+ assert_equal 'Keep Separate', @helper.format_affinity_type('KEEP_SEPARATE')
82
+ assert_equal 'Keep Together (Must)', @helper.format_affinity_type('KEEP_TOGETHER_MUST')
83
+ assert_equal 'Keep Separate (Must)', @helper.format_affinity_type('KEEP_SEPARATE_MUST')
84
+ end
85
+
86
+ # The original bug: a _MUST value rendered as the plain soft label, so a
87
+ # mandatory constraint was displayed as if it were advisory.
88
+ def test_format_affinity_type_distinguishes_must_from_should
89
+ assert_not_equal @helper.format_affinity_type('KEEP_TOGETHER'),
90
+ @helper.format_affinity_type('KEEP_TOGETHER_MUST'),
91
+ "KEEP_TOGETHER_MUST must not render identically to KEEP_TOGETHER"
92
+ assert_not_equal @helper.format_affinity_type('KEEP_SEPARATE'),
93
+ @helper.format_affinity_type('KEEP_SEPARATE_MUST'),
94
+ "KEEP_SEPARATE_MUST must not render identically to KEEP_SEPARATE"
95
+ end
96
+
97
+ # Unknown/future values must pass through rather than render blank.
98
+ def test_format_affinity_type_passes_through_unknown
99
+ assert_equal 'SOME_FUTURE_TYPE', @helper.format_affinity_type('SOME_FUTURE_TYPE')
100
+ end
101
+
102
+ def test_format_affinity_type_handles_nil_and_empty
103
+ assert_equal '', @helper.format_affinity_type(nil)
104
+ assert_equal '', @helper.format_affinity_type('')
105
+ end
106
+
107
+ def test_format_host_vm_group_type
108
+ assert_equal 'Site Group', @helper.format_host_vm_group_type('SITE_GROUP')
109
+ assert_equal 'Host Group', @helper.format_host_vm_group_type('HOST_GROUP')
110
+ assert_equal 'VM Group', @helper.format_host_vm_group_type('VM_GROUP')
111
+ assert_equal 'OTHER', @helper.format_host_vm_group_type('OTHER')
112
+ assert_equal '', @helper.format_host_vm_group_type(nil)
113
+ end
114
+
115
+ # Every enum value must round-trip to a human label, never fall through
116
+ # to the raw passthrough branch.
117
+ def test_every_enum_value_has_a_friendly_label
118
+ @helper.affinity_type_select_options.each do |opt|
119
+ assert_not_equal opt['value'], @helper.format_affinity_type(opt['value']),
120
+ "#{opt['value']} has no friendly label"
121
+ end
122
+ @helper.host_vm_group_type_select_options.each do |opt|
123
+ assert_not_equal opt['value'], @helper.format_host_vm_group_type(opt['value']),
124
+ "#{opt['value']} has no friendly label"
125
+ end
126
+ end
127
+
128
+ ## Error handling
129
+
130
+ # Fake RestClient-style exception carrying a JSON body.
131
+ class FakeResponse
132
+ def initialize(body); @body = body; end
133
+ def to_s; @body; end
134
+ end
135
+ class FakeRestException < StandardError
136
+ attr_reader :response
137
+ def initialize(body); @response = FakeResponse.new(body); end
138
+ end
139
+
140
+ # Errors are written to stderr (print_red_alert), so capture that stream.
141
+ def capture_error_output(body)
142
+ out = StringIO.new
143
+ orig = $stderr
144
+ $stderr = out
145
+ begin
146
+ @helper.handle_affinity_rest_exception(FakeRestException.new(body), {})
147
+ ensure
148
+ $stderr = orig
149
+ end
150
+ out.string
151
+ end
152
+
153
+ # Real payload shape from AffinityGroupService.validateCombinedRulePoolConsistency
154
+ # (morpheus-ui rc-9.1.0). There is no top-level errorCode - the CLI must read
155
+ # msg and the field-keyed errors map.
156
+ def test_cross_pool_error_surfaces_server_message
157
+ body = {
158
+ 'success' => false,
159
+ 'msg' => "VM Group 'VMG-1' and Host Group 'HG-1' belong to different clusters",
160
+ 'errors' => {'hostGroup' => 'affinityGroup.combinedRule.crossClusterHostGroup'}
161
+ }.to_json
162
+ output = capture_error_output(body)
163
+ assert output.include?("belong to different clusters"),
164
+ "cross-pool error must surface the server msg, got: #{output.inspect}"
165
+ assert output.include?('affinityGroup.combinedRule.crossClusterHostGroup'),
166
+ "cross-pool error must surface the field error key, got: #{output.inspect}"
167
+ end
168
+
169
+ # Real payload from VmwareComputeService.combinedRuleGroupSyncGuard.
170
+ def test_unsynced_group_error_surfaces_server_message
171
+ body = {
172
+ 'success' => false,
173
+ 'msg' => "Cannot save combined VM-to-Host rule 'r1' - referenced group(s) have not yet " \
174
+ "synced to vSphere: VM Group 'VMG-local'. Wait for the next cloud refresh.",
175
+ 'errors' => {'affinityGroup' => 'errors.affinity.rule.group.not.synced'}
176
+ }.to_json
177
+ output = capture_error_output(body)
178
+ assert output.include?("have not yet synced to vSphere"),
179
+ "unsynced error must surface the server msg, got: #{output.inspect}"
180
+ assert output.include?('errors.affinity.rule.group.not.synced'),
181
+ "unsynced error must surface the field error key, got: #{output.inspect}"
182
+ end
183
+
184
+ # A body with errors but no msg must still report the field errors.
185
+ def test_field_errors_are_reported_without_a_msg
186
+ body = {
187
+ 'success' => false,
188
+ 'errors' => {'vmGroup' => 'affinityGroup.combinedRule.bothGroupsRequired'}
189
+ }.to_json
190
+ output = capture_error_output(body)
191
+ assert output.include?('affinityGroup.combinedRule.bothGroupsRequired'),
192
+ "field errors must be reported even with no msg, got: #{output.inspect}"
193
+ end
194
+
195
+ # HostVmGroupsController#save renders flat JSON with msg and no errors map
196
+ # for the multiple-clusters / pool-mismatch guards. Must still surface.
197
+ def test_msg_only_error_body_is_surfaced
198
+ body = {
199
+ 'success' => false,
200
+ 'msg' => 'Selected servers span multiple clusters. A Host / VM Group must belong to a single cluster.'
201
+ }.to_json
202
+ output = capture_error_output(body)
203
+ assert output.include?('span multiple clusters'),
204
+ "a body with msg but no errors map must still be surfaced, got: #{output.inspect}"
205
+ end
206
+
207
+ ## Cluster-in-cloud narrowing
208
+
209
+ # formOptions returns groups with a poolId; a cloud-scoped rule belongs to a
210
+ # single cluster, so only groups in the chosen cluster may be offered.
211
+ def test_filter_by_pool_keeps_only_matching_groups
212
+ rows = [
213
+ {'id' => 1, 'name' => 'vmg-a', 'poolId' => 10},
214
+ {'id' => 2, 'name' => 'vmg-b', 'poolId' => 20},
215
+ ]
216
+ assert_equal [1], @helper.filter_by_pool(rows, 10).collect {|r| r['id'] }
217
+ assert_equal [2], @helper.filter_by_pool(rows, 20).collect {|r| r['id'] }
218
+ end
219
+
220
+ # Groups with no poolId are kept -- the server did the narrowing already.
221
+ def test_filter_by_pool_keeps_rows_without_a_pool_id
222
+ rows = [{'id' => 1, 'poolId' => nil}, {'id' => 2, 'poolId' => 99}]
223
+ assert_equal [1], @helper.filter_by_pool(rows, 7).collect {|r| r['id'] }
224
+ end
225
+
226
+ def test_filter_by_pool_is_a_noop_without_a_pool
227
+ rows = [{'id' => 1, 'poolId' => 10}, {'id' => 2, 'poolId' => 20}]
228
+ assert_equal rows, @helper.filter_by_pool(rows, nil)
229
+ end
230
+
231
+ # Pool ids may arrive as String from CLI flags and Integer from JSON.
232
+ def test_filter_by_pool_compares_across_types
233
+ rows = [{'id' => 1, 'poolId' => 10}]
234
+ assert_equal 1, @helper.filter_by_pool(rows, '10').size
235
+ assert_equal 1, @helper.filter_by_pool(rows, 10).size
236
+ end
237
+
238
+ ## Option types
239
+
240
+ def test_affinity_type_option_type_defaults_to_optional
241
+ opt = @helper.affinity_type_option_type
242
+ assert_equal 'affinityType', opt['fieldName']
243
+ assert_equal 'select', opt['type']
244
+ assert_nil opt['required'], "affinityType must be optional unless explicitly required"
245
+ assert_nil opt['defaultValue'], "affinityType must have no default unless explicitly given"
246
+ end
247
+
248
+ def test_affinity_type_option_type_accepts_required_and_default
249
+ opt = @helper.affinity_type_option_type(required: true, default: 'KEEP_TOGETHER')
250
+ assert_equal true, opt['required']
251
+ assert_equal 'KEEP_TOGETHER', opt['defaultValue']
252
+ end
253
+
254
+ # Any default must itself be a member of the enum.
255
+ def test_option_type_defaults_are_valid_enum_members
256
+ values = @helper.affinity_type_select_options.collect {|it| it['value'] }
257
+ %w[KEEP_TOGETHER KEEP_SEPARATE].each do |default|
258
+ opt = @helper.affinity_type_option_type(required: true, default: default)
259
+ assert values.include?(opt['defaultValue']),
260
+ "default #{default} is not a valid affinity type"
261
+ end
262
+ end
263
+
264
+ def test_host_vm_group_type_option_type
265
+ opt = @helper.host_vm_group_type_option_type(required: true, default: 'HOST_GROUP')
266
+ assert_equal 'type', opt['fieldName']
267
+ assert_equal true, opt['required']
268
+ assert_equal 'HOST_GROUP', opt['defaultValue']
269
+ end
270
+
271
+ ## Wiring
272
+
273
+ # affinityType must be editable on update, not create-only.
274
+ # AffinityGroupService.updateAffinityGroup applies params.affinityGroup.affinityType,
275
+ # so callers may switch between MUST and non-MUST behaviour during an edit.
276
+ def test_affinity_type_is_editable_on_update_in_all_commands
277
+ [
278
+ Morpheus::Cli::AffinityGroupsCommand,
279
+ Morpheus::Cli::Clouds,
280
+ Morpheus::Cli::Clusters,
281
+ ].each do |klass|
282
+ cmd = klass.new
283
+ method_name = all_own_methods(klass).find {|m|
284
+ m.to_s =~ /\Aupdate_(cloud_)?affinity_group_option_types\z/
285
+ }
286
+ assert_not_nil method_name, "#{klass} has no update affinity option types method"
287
+ option_types = cmd.send(method_name)
288
+ field_names = option_types.collect {|it| it['fieldName'] }
289
+ assert field_names.include?('affinityType'),
290
+ "#{klass}##{method_name} omits affinityType - it is editable, not create-only"
291
+ end
292
+ end
293
+
294
+ # Guards against a command re-introducing a local copy of the enum.
295
+ def test_commands_offer_all_four_types_on_add_and_update
296
+ expected = %w[KEEP_TOGETHER KEEP_SEPARATE KEEP_TOGETHER_MUST KEEP_SEPARATE_MUST].sort
297
+ [
298
+ [Morpheus::Cli::AffinityGroupsCommand, :add_affinity_group_option_types],
299
+ [Morpheus::Cli::AffinityGroupsCommand, :update_affinity_group_option_types],
300
+ [Morpheus::Cli::Clouds, :add_cloud_affinity_group_option_types],
301
+ [Morpheus::Cli::Clouds, :update_cloud_affinity_group_option_types],
302
+ [Morpheus::Cli::Clusters, :add_affinity_group_option_types],
303
+ [Morpheus::Cli::Clusters, :update_affinity_group_option_types],
304
+ ].each do |klass, method_name|
305
+ option_types = klass.new.send(method_name)
306
+ affinity_field = option_types.find {|it| it['fieldName'] == 'affinityType' }
307
+ assert_not_nil affinity_field, "#{klass}##{method_name} has no affinityType field"
308
+ actual = affinity_field['selectOptions'].collect {|it| it['value'] }.sort
309
+ assert_equal expected, actual,
310
+ "#{klass}##{method_name} does not offer all four affinity types"
311
+ end
312
+ end
313
+
314
+ # The enums and formatters must exist in exactly one place.
315
+ def test_commands_do_not_redefine_shared_helpers
316
+ [
317
+ Morpheus::Cli::AffinityGroupsCommand,
318
+ Morpheus::Cli::HostVmGroupsCommand,
319
+ Morpheus::Cli::Clouds,
320
+ Morpheus::Cli::Clusters,
321
+ ].each do |klass|
322
+ own_methods = all_own_methods(klass)
323
+ [:format_affinity_type, :format_host_vm_group_type, :handle_affinity_rest_exception].each do |shared|
324
+ assert !own_methods.include?(shared),
325
+ "#{klass} redefines #{shared} - use Morpheus::Cli::AffinityHelper instead to prevent drift"
326
+ end
327
+ end
328
+ end
329
+
330
+ def test_affinity_commands_include_the_shared_helper
331
+ [
332
+ Morpheus::Cli::AffinityGroupsCommand,
333
+ Morpheus::Cli::HostVmGroupsCommand,
334
+ Morpheus::Cli::Clouds,
335
+ Morpheus::Cli::Clusters,
336
+ ].each do |klass|
337
+ assert klass.include?(Morpheus::Cli::AffinityHelper),
338
+ "#{klass} does not include Morpheus::Cli::AffinityHelper"
339
+ end
340
+ end
341
+
342
+ # Cloud-scoped affinity groups and host/VM groups both belong to exactly one
343
+ # cluster, so both add forms must offer the cluster picker. Without it the
344
+ # user can pick servers spanning clusters and the server rejects the create.
345
+ def test_cloud_scoped_add_forms_offer_a_cluster_picker
346
+ [
347
+ :add_cloud_affinity_group_option_types,
348
+ :add_cloud_host_vm_group_option_types,
349
+ ].each do |method_name|
350
+ option_types = Morpheus::Cli::Clouds.new.send(method_name)
351
+ pool_field = option_types.find {|it| it['fieldName'] == 'pool.id' }
352
+ assert_not_nil pool_field,
353
+ "Morpheus::Cli::Clouds##{method_name} has no cluster picker (pool.id)"
354
+ assert_equal true, pool_field['required'],
355
+ "#{method_name}: the cluster picker must be required"
356
+ end
357
+ end
358
+
359
+ protected
360
+
361
+ # Option-type builders are private in the command classes, so reflection must
362
+ # consider private methods too.
363
+ def all_own_methods(klass)
364
+ klass.instance_methods(false) + klass.private_instance_methods(false)
365
+ end
366
+
367
+ end
@@ -0,0 +1,212 @@
1
+ require 'test/unit'
2
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __dir__))
3
+ require 'morpheus'
4
+ require 'morpheus/cli/commands/backups_command'
5
+
6
+ # Tests for Morpheus::Cli::BackupsCommand
7
+ #
8
+ # These tests focus on the human-readable backup detail rendering, which is
9
+ # pure formatting logic and does not require a live appliance. The detail view
10
+ # should prefer the nested API object names (backupType.name, backupProvider.name,
11
+ # storageProvider.name) and only fall back to legacy tag labels when absent.
12
+ class BackupsCommandTest < Test::Unit::TestCase
13
+ class RecordingBackupsInterface
14
+ attr_reader :created_payload
15
+
16
+ def initialize(backup_type_code)
17
+ @backup_type_code = backup_type_code
18
+ end
19
+
20
+ def setopts(_options)
21
+ end
22
+
23
+ def create_options(_payload)
24
+ {
25
+ 'containers' => [{'id' => 101, 'name' => 'web'}],
26
+ 'backupTypes' => [{'code' => @backup_type_code, 'name' => 'Test Backup Type'}],
27
+ 'backup' => {},
28
+ 'backupSettings' => {},
29
+ }
30
+ end
31
+
32
+ def create(payload)
33
+ @created_payload = Marshal.load(Marshal.dump(payload))
34
+ {'success' => true, 'backup' => {'id' => 99, 'name' => payload.dig('backup', 'name')}}
35
+ end
36
+ end
37
+
38
+ class RecordingOptionsInterface
39
+ attr_reader :requests
40
+
41
+ def initialize(plugin_option_types)
42
+ @plugin_option_types = plugin_option_types
43
+ @requests = []
44
+ end
45
+
46
+ def options_for_source(source, params)
47
+ @requests << [source, params]
48
+ option_types = source == 'backupOptionTypes' ? @plugin_option_types : []
49
+ {'success' => true, 'data' => {'optionTypes' => option_types}}
50
+ end
51
+ end
52
+
53
+ class OfflineInstancesInterface
54
+ def list(_params)
55
+ {'instances' => [{'id' => 7, 'name' => 'test-instance'}]}
56
+ end
57
+ end
58
+
59
+ class OfflineBackupsCommand < Morpheus::Cli::BackupsCommand
60
+ attr_reader :backups_interface, :options_interface
61
+
62
+ def initialize(backup_type_code, plugin_option_types)
63
+ @backup_type_code = backup_type_code
64
+ @plugin_option_types = plugin_option_types
65
+ end
66
+
67
+ def connect(_options)
68
+ @api_client = Object.new
69
+ @backups_interface = RecordingBackupsInterface.new(@backup_type_code)
70
+ @options_interface = RecordingOptionsInterface.new(@plugin_option_types)
71
+ @instances_interface = OfflineInstancesInterface.new
72
+ @servers_interface = Object.new
73
+ @backup_jobs_interface = Object.new
74
+ end
75
+ end
76
+
77
+ def command
78
+ @command ||= Morpheus::Cli::BackupsCommand.new
79
+ end
80
+
81
+ # snapshot backup with nested backupType/backupProvider/storageProvider names
82
+ def sample_backup
83
+ {
84
+ 'id' => 132614,
85
+ 'name' => 'vmware-snapshot-backup',
86
+ 'backupType' => {'id' => 13, 'code' => 'vmwareSnapshot', 'name' => 'VMware VM Snapshot'},
87
+ 'backupProvider' => {'id' => 543, 'code' => 'morpheus', 'name' => 'Internal Backup'},
88
+ 'storageProvider' => {'id' => 194, 'name' => 'Local Backups'},
89
+ }
90
+ end
91
+
92
+ def test_backup_detail_prefers_nested_names
93
+ columns = command.send(:backup_column_definitions)
94
+ backup = sample_backup
95
+ assert_equal 'VMware VM Snapshot', columns['Backup Type'].call(backup)
96
+ assert_equal 'Internal Backup', columns['Backup Provider'].call(backup)
97
+ assert_equal 'Local Backups', columns['Storage Provider'].call(backup)
98
+ end
99
+
100
+ # when backupType.name is absent, fall back to the legacy POLICY/MANUAL tag
101
+ def test_backup_type_falls_back_to_legacy_tag
102
+ columns = command.send(:backup_column_definitions)
103
+ manual_backup = {'id' => 1, 'name' => 'manual'}
104
+ assert_match(/MANUAL/, columns['Backup Type'].call(manual_backup))
105
+
106
+ policy_backup = {'id' => 2, 'name' => 'policy', 'job' => {'id' => 5}}
107
+ assert_match(/POLICY/, columns['Backup Type'].call(policy_backup))
108
+ end
109
+
110
+ # location tag should render the provider name instead of empty parens
111
+ def test_backup_location_tag_uses_provider_name
112
+ tag = command.send(:format_backup_location_tag, sample_backup)
113
+ assert_match(/REMOTE/, tag)
114
+ assert_match(/Local Backups/, tag)
115
+ refute_match(/\(\)/, tag)
116
+ end
117
+
118
+ def test_add_continues_when_backup_type_has_no_plugin_options
119
+ add_command = OfflineBackupsCommand.new('vmwareSnapshot', [])
120
+
121
+ with_backup_add_prompts('vmwareSnapshot') do
122
+ assert_equal [0, nil], add_command.add(['--quiet'])
123
+ end
124
+
125
+ backup_payload = add_command.backups_interface.created_payload['backup']
126
+ assert_equal 'vmwareSnapshot', backup_payload['backupType']
127
+ assert_equal 'addTo', backup_payload['jobAction']
128
+ assert_equal 44, backup_payload['jobId']
129
+ assert_equal ['backupOptionTypes', {'backupTypeCode' => 'vmwareSnapshot'}], add_command.options_interface.requests.first
130
+ end
131
+
132
+ def test_add_flattens_plugin_domain_options_into_backup_payload
133
+ plugin_option_types = [
134
+ {
135
+ 'fieldName' => 'pluginTarget',
136
+ 'fieldLabel' => 'Plugin Target',
137
+ 'fieldContext' => 'domain',
138
+ 'type' => 'text',
139
+ 'required' => true,
140
+ },
141
+ ]
142
+ add_command = OfflineBackupsCommand.new('pluginBackup', plugin_option_types)
143
+
144
+ with_backup_add_prompts('pluginBackup', {'backup' => {'pluginTarget' => 'archive'}}) do
145
+ assert_equal [0, nil], add_command.add(['--quiet'])
146
+ end
147
+
148
+ backup_payload = add_command.backups_interface.created_payload['backup']
149
+ assert_equal 'archive', backup_payload['pluginTarget']
150
+ assert_false backup_payload.key?('backup')
151
+ end
152
+
153
+ def test_add_continues_when_all_plugin_options_are_skipped
154
+ plugin_option_types = [
155
+ {
156
+ 'fieldName' => 'pluginTarget',
157
+ 'fieldLabel' => 'Plugin Target',
158
+ 'fieldContext' => 'domain',
159
+ 'type' => 'text',
160
+ 'required' => false,
161
+ 'dependsOnCode' => 'missingToggle:on',
162
+ },
163
+ ]
164
+ add_command = OfflineBackupsCommand.new('conditionalPluginBackup', plugin_option_types)
165
+
166
+ with_backup_add_prompts('conditionalPluginBackup') do
167
+ assert_equal [0, nil], add_command.add(['--quiet'])
168
+ end
169
+
170
+ backup_payload = add_command.backups_interface.created_payload['backup']
171
+ assert_equal 'conditionalPluginBackup', backup_payload['backupType']
172
+ assert_equal 44, backup_payload['jobId']
173
+ assert_false backup_payload.key?('pluginTarget')
174
+ end
175
+
176
+ private
177
+
178
+ def with_backup_add_prompts(backup_type_code, plugin_values = {})
179
+ original_prompt = Morpheus::Cli::OptionTypes.method(:prompt)
180
+ Morpheus::Cli::OptionTypes.define_singleton_method(:prompt) do |option_types, options = {}, api_client = nil, *args|
181
+ if option_types.any? {|option_type| option_type['fieldContext'] == 'domain'}
182
+ prompt_options = options.deep_merge(plugin_values).merge(:no_prompt => true)
183
+ original_prompt.call(option_types, prompt_options, api_client, *args)
184
+ else
185
+ field_name = option_types.first && option_types.first['fieldName']
186
+ case field_name
187
+ when 'source'
188
+ {'source' => 'instance'}
189
+ when 'instanceId'
190
+ {'instanceId' => 7}
191
+ when 'name'
192
+ {'name' => 'offline-backup'}
193
+ when 'containerId'
194
+ {'containerId' => 101}
195
+ when 'backupType'
196
+ {'backupType' => backup_type_code}
197
+ when 'jobAction'
198
+ {'jobAction' => 'addTo'}
199
+ when 'jobId'
200
+ {'jobId' => 44}
201
+ when nil
202
+ {}
203
+ else
204
+ raise "Unexpected option prompt for #{field_name}"
205
+ end
206
+ end
207
+ end
208
+ yield
209
+ ensure
210
+ Morpheus::Cli::OptionTypes.define_singleton_method(:prompt, original_prompt)
211
+ end
212
+ end