morpheus-cli 9.0.2 → 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 (33) 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 +13 -0
  5. data/lib/morpheus/api/clouds_interface.rb +42 -0
  6. data/lib/morpheus/api/clusters_interface.rb +7 -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/cli/commands/affinity_groups_command.rb +554 -0
  10. data/lib/morpheus/cli/commands/backups_command.rb +1 -1
  11. data/lib/morpheus/cli/commands/clouds.rb +421 -29
  12. data/lib/morpheus/cli/commands/clusters.rb +37 -39
  13. data/lib/morpheus/cli/commands/distributed_workers_command.rb +282 -0
  14. data/lib/morpheus/cli/commands/host_vm_groups_command.rb +423 -0
  15. data/lib/morpheus/cli/mixins/affinity_helper.rb +118 -0
  16. data/lib/morpheus/cli/mixins/infrastructure_helper.rb +137 -0
  17. data/lib/morpheus/cli/option_types.rb +36 -1
  18. data/lib/morpheus/cli/version.rb +1 -1
  19. data/test/api/affinity_groups_interface_test.rb +43 -0
  20. data/test/api/clouds_interface_test.rb +67 -0
  21. data/test/api/host_vm_groups_interface_test.rb +35 -0
  22. data/test/cli/affinity_groups_cloud_cluster_scope_test.rb +270 -0
  23. data/test/cli/affinity_groups_cluster_scope_test.rb +171 -0
  24. data/test/cli/affinity_groups_test.rb +57 -0
  25. data/test/cli/affinity_groups_violations_scope_test.rb +141 -0
  26. data/test/cli/affinity_helper_test.rb +367 -0
  27. data/test/cli/backups_test.rb +212 -0
  28. data/test/cli/clusters_uuid_test.rb +131 -0
  29. data/test/cli/distributed_workers_test.rb +68 -0
  30. data/test/cli/host_vm_groups_cluster_scope_test.rb +187 -0
  31. data/test/cli/host_vm_groups_test.rb +63 -0
  32. data/test/cli/infrastructure_helper_cluster_resolver_test.rb +250 -0
  33. metadata +37 -2
@@ -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
@@ -0,0 +1,131 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'morpheus'
4
+
5
+ # Unit tests for MORPH-15042: cluster uuid support in the clusters command.
6
+ # - clusters get UUID resolves a single cluster via the path endpoint
7
+ # (/api/clusters/{uuid}), not the name query filter.
8
+ # - clusters list --uuid UUID [--uuid UUID ...] filters by one or more uuids.
9
+ #
10
+ # These follow the established pure-unit pattern (a fake ClustersInterface
11
+ # recording calls; Test::Unit::TestCase, no appliance/auth required). See
12
+ # test/cli/affinity_groups_cluster_scope_test.rb for the pattern this mirrors.
13
+ class ClustersUuidTest < Test::Unit::TestCase
14
+ # Records every call so tests can assert exactly what the command requested.
15
+ class FakeClustersInterface
16
+ attr_reader :get_calls, :list_calls
17
+
18
+ def initialize(clusters: [], get_result: nil)
19
+ @clusters = clusters
20
+ @get_result = get_result
21
+ @get_calls = []
22
+ @list_calls = []
23
+ @dry = false
24
+ end
25
+
26
+ def setopts(_options)
27
+ self
28
+ end
29
+
30
+ def dry
31
+ @dry = true
32
+ self
33
+ end
34
+
35
+ def get(arg)
36
+ @get_calls << arg
37
+ @get_result || {'cluster' => {'id' => 42, 'name' => 'k8s-1'}}
38
+ end
39
+
40
+ def list(params = {})
41
+ @list_calls << params
42
+ return {method: :get, url: '/api/clusters', params: params} if @dry
43
+ {'clusters' => @clusters.select {|c| c['name'] == params[:name] }}
44
+ end
45
+ end
46
+
47
+ def setup
48
+ @uuid = 'a1b2c3d4-e5f6-4711-8899-0011223344ff'
49
+ end
50
+
51
+ ## find_cluster_by_name_or_id dispatch
52
+
53
+ def test_uuid_resolves_via_path_get_not_name_list
54
+ command, clusters = build_command
55
+ cluster = command.send(:find_cluster_by_name_or_id, @uuid)
56
+ assert_equal 42, cluster['id']
57
+ # The uuid is handed to get as a String path segment, never a name list.
58
+ assert_equal [@uuid], clusters.get_calls
59
+ assert_empty clusters.list_calls
60
+ end
61
+
62
+ def test_numeric_id_still_resolves_via_path_get
63
+ command, clusters = build_command
64
+ command.send(:find_cluster_by_name_or_id, '7')
65
+ assert_equal [7], clusters.get_calls
66
+ assert_empty clusters.list_calls
67
+ end
68
+
69
+ def test_name_still_resolves_via_list_by_name
70
+ managed = {'id' => 9, 'name' => 'prod'}
71
+ command, clusters = build_command([managed])
72
+ cluster = command.send(:find_cluster_by_name_or_id, 'prod')
73
+ assert_equal 9, cluster['id']
74
+ assert_equal [{name: 'prod'}], clusters.list_calls
75
+ assert_empty clusters.get_calls
76
+ end
77
+
78
+ def test_cluster_uuid_predicate
79
+ command, = build_command
80
+ assert command.send(:cluster_uuid?, @uuid)
81
+ assert command.send(:cluster_uuid?, @uuid.upcase)
82
+ assert !command.send(:cluster_uuid?, '7')
83
+ assert !command.send(:cluster_uuid?, 'prod')
84
+ assert !command.send(:cluster_uuid?, 'a1b2c3d4-e5f6-4711-8899-0011223344')
85
+ end
86
+
87
+ ## clusters list --uuid
88
+
89
+ def test_list_with_single_uuid_carries_uuid_param
90
+ command, = build_command
91
+ output = capture_stdout { command.list(['--uuid', @uuid, '--dry-run']) }
92
+ assert_match(/#{Regexp.escape(@uuid)}/, output)
93
+ end
94
+
95
+ def test_list_with_multiple_uuids_carries_all_uuids
96
+ command, = build_command
97
+ other = 'ffeeddcc-bbaa-4998-8776-655443322110'
98
+ output = capture_stdout { command.list(['--uuid', @uuid, '--uuid', other, '--dry-run']) }
99
+ assert_match(/#{Regexp.escape(@uuid)}/, output)
100
+ assert_match(/#{Regexp.escape(other)}/, output)
101
+ end
102
+
103
+ def test_list_without_uuid_has_no_uuid_param
104
+ command, = build_command
105
+ output = capture_stdout { command.list(['--dry-run']) }
106
+ assert_no_match(/uuid/i, output)
107
+ end
108
+
109
+ private
110
+
111
+ def build_command(managed_clusters = [], get_result: nil)
112
+ clusters = FakeClustersInterface.new(clusters: managed_clusters, get_result: get_result)
113
+ command = Morpheus::Cli::Clusters.new
114
+ command.instance_variable_set(:@clusters_interface, clusters)
115
+ command.define_singleton_method(:connect) do |_options|
116
+ @clusters_interface = clusters
117
+ end
118
+ [command, clusters]
119
+ end
120
+
121
+ def capture_stdout
122
+ terminal = Morpheus::Terminal.instance
123
+ original = terminal.stdout
124
+ buffer = StringIO.new
125
+ terminal.set_stdout(buffer)
126
+ yield
127
+ buffer.string
128
+ ensure
129
+ terminal.set_stdout(original)
130
+ end
131
+ end
@@ -0,0 +1,68 @@
1
+ require 'morpheus_test'
2
+
3
+ # Tests for Morpheus::Cli::DistributedWorkers
4
+ class MorpheusTest::DistributedWorkersTest < MorpheusTest::TestCase
5
+
6
+ def test_distributed_workers_list
7
+ assert_execute %(distributed-workers list)
8
+ end
9
+
10
+ def test_distributed_workers_crud
11
+ worker_name = "cli-test-dw-#{rand(1000000)}"
12
+ created_id = nil
13
+ begin
14
+ # add and capture output to assert the apiKey is surfaced once
15
+ add_output = capture_stdout do
16
+ assert_execute %(distributed-workers add "#{worker_name}" --description "cli test worker" --enabled -N)
17
+ end
18
+ created_id = client.distributed_workers.list({name: worker_name})['distributedWorkers'].first['id']
19
+ assert_not_nil created_id, "Expected distributed worker to be created"
20
+
21
+ api_key = client.distributed_workers.list({name: worker_name})['distributedWorkers'].first['apiKey']
22
+ assert_nil api_key, "Expected apiKey to be omitted from list responses"
23
+ assert_match(/API Key/i, add_output, "Expected add output to surface the API Key")
24
+
25
+ # appears in list
26
+ assert_match(/#{Regexp.escape(worker_name)}/, capture_stdout { assert_execute %(distributed-workers list --json) })
27
+
28
+ # get by id and by name, apiKey must not be shown
29
+ get_output = capture_stdout do
30
+ assert_execute %(distributed-workers get "#{created_id}")
31
+ assert_execute %(distributed-workers get "#{escape_arg worker_name}")
32
+ end
33
+ refute_match(/API Key/i, get_output, "Expected get output to omit the API Key")
34
+
35
+ # update the description
36
+ assert_execute %(distributed-workers update "#{created_id}" --description "updated description")
37
+ updated = client.distributed_workers.get(created_id)['distributedWorker']
38
+ assert_equal "updated description", updated['description']
39
+
40
+ # remove and confirm it is gone
41
+ assert_execute %(distributed-workers remove "#{created_id}" -y)
42
+ assert_empty client.distributed_workers.list({name: worker_name})['distributedWorkers'],
43
+ "Expected distributed worker to be removed"
44
+ created_id = nil
45
+ ensure
46
+ if created_id
47
+ begin
48
+ client.distributed_workers.destroy(created_id)
49
+ rescue => e
50
+ puts "Failed to clean up distributed worker #{created_id}: #{e.message}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ protected
57
+
58
+ def capture_stdout
59
+ original = terminal.stdout
60
+ buffer = StringIO.new
61
+ terminal.set_stdout(buffer)
62
+ yield
63
+ buffer.string
64
+ ensure
65
+ terminal.set_stdout(original)
66
+ end
67
+
68
+ end
@@ -0,0 +1,187 @@
1
+ require 'test/unit'
2
+ require 'stringio'
3
+ require 'morpheus'
4
+
5
+ # Command-level offline exercise of the MORPH-17279 --cluster scope for
6
+ # HostVmGroupsCommand#list. Parallels affinity_groups_cluster_scope_test.rb:
7
+ # a bare --cluster name must resolve either to a managed cluster (POST/GET under
8
+ # /api/clusters/{id}/...) or, failing that, fall back to a cloud-owned vSphere
9
+ # cluster (ComputeZonePool type=Cluster) and scope the zone request with poolId.
10
+ # A name that matches neither must fail loudly, not exit 1 silently.
11
+ class HostVmGroupsClusterScopeTest < Test::Unit::TestCase
12
+ class FakeCloudsInterface
13
+ attr_reader :calls
14
+
15
+ def initialize(cloud)
16
+ @cloud = cloud
17
+ @calls = []
18
+ @dry = false
19
+ end
20
+
21
+ def setopts(_options)
22
+ self
23
+ end
24
+
25
+ def dry
26
+ @dry = true
27
+ self
28
+ end
29
+
30
+ def get(id, params = {})
31
+ @calls << [:get, id, params]
32
+ {'zone' => @cloud}
33
+ end
34
+
35
+ def list(params = {})
36
+ @calls << [:list, params]
37
+ {'zones' => [@cloud]}
38
+ end
39
+
40
+ def list_host_vm_groups(id, params = {})
41
+ @calls << [:list_host_vm_groups, id, params]
42
+ return {'hostVmGroups' => []} unless @dry
43
+
44
+ {method: :get, url: "/api/zones/#{id}/host-vm-groups", params: params}
45
+ end
46
+ end
47
+
48
+ class FakeResourcePoolsInterface
49
+ attr_reader :calls
50
+
51
+ def initialize(pools)
52
+ @pools = pools
53
+ @calls = []
54
+ end
55
+
56
+ def get(cloud_id, id, params = {})
57
+ @calls << [:get, cloud_id, id, params]
58
+ {'resourcePool' => @pools.find {|p| p['id'].to_i == id.to_i }}
59
+ end
60
+
61
+ def list(cloud_id, params = {})
62
+ @calls << [:list, cloud_id, params]
63
+ {'resourcePools' => @pools}
64
+ end
65
+
66
+ def list_without_cloud(params = {})
67
+ @calls << [:list_without_cloud, params]
68
+ {'resourcePools' => @pools}
69
+ end
70
+ end
71
+
72
+ class FakeClustersInterface
73
+ attr_reader :calls
74
+
75
+ def initialize(clusters = [])
76
+ @clusters = clusters
77
+ @calls = []
78
+ @dry = false
79
+ end
80
+
81
+ def setopts(_options)
82
+ self
83
+ end
84
+
85
+ def dry
86
+ @dry = true
87
+ self
88
+ end
89
+
90
+ def list(params = {})
91
+ @calls << [:list, params]
92
+ {'clusters' => @clusters.select {|cluster| cluster['name'] == params[:name]}}
93
+ end
94
+
95
+ def list_host_vm_groups(id, params = {})
96
+ @calls << [:list_host_vm_groups, id, params]
97
+ return {'hostVmGroups' => []} unless @dry
98
+
99
+ {method: :get, url: "/api/clusters/#{id}/host-vm-groups", params: params}
100
+ end
101
+ end
102
+
103
+ def setup
104
+ @cloud = {'id' => 59, 'name' => 'vCenter Cloud'}
105
+ @pool = {
106
+ 'id' => 501,
107
+ 'name' => 'QA',
108
+ 'type' => 'Cluster',
109
+ 'zone' => @cloud
110
+ }
111
+ end
112
+
113
+ def test_cluster_name_falls_back_to_unique_vsphere_cluster
114
+ command, pools, clusters = build_command
115
+
116
+ output = capture_stdout { command.list(['--cluster', 'QA', '--dry-run']) }
117
+
118
+ assert_match(%r{/api/zones/59/host-vm-groups}, output)
119
+ assert_match(/poolId=501/, output)
120
+ assert_equal [[:list, {name: 'QA'}]], clusters.calls
121
+ assert_equal [
122
+ [:list_without_cloud, {name: 'QA', type: 'Cluster', max: 1000}]
123
+ ], pools.calls
124
+ end
125
+
126
+ def test_managed_cluster_takes_precedence
127
+ managed_cluster = {'id' => 8, 'name' => 'QA'}
128
+ command, pools, clusters = build_command([managed_cluster])
129
+
130
+ output = capture_stdout { command.list(['--cluster', 'QA', '--dry-run']) }
131
+
132
+ assert_match(%r{/api/clusters/8/host-vm-groups}, output)
133
+ assert_equal [
134
+ [:list, {name: 'QA'}],
135
+ [:list_host_vm_groups, 8, {}]
136
+ ], clusters.calls
137
+ assert_empty pools.calls
138
+ end
139
+
140
+ def test_unknown_cluster_name_fails_loudly
141
+ command, _pools, _clusters = build_command([], [])
142
+
143
+ result = nil
144
+ err = capture_stderr do
145
+ capture_stdout { result = command.list(['--cluster', 'nope', '--dry-run']) }
146
+ end
147
+
148
+ assert_equal 1, (result.is_a?(Array) ? result[0] : result)
149
+ assert_match(/No managed or vSphere cluster found by name 'nope'/i, err)
150
+ end
151
+
152
+ private
153
+
154
+ def build_command(managed_clusters = [], pools = nil)
155
+ pools = [@pool] if pools.nil?
156
+ clouds = FakeCloudsInterface.new(@cloud)
157
+ pools_interface = FakeResourcePoolsInterface.new(pools)
158
+ clusters = FakeClustersInterface.new(managed_clusters)
159
+ command = Morpheus::Cli::HostVmGroupsCommand.new
160
+ command.define_singleton_method(:connect) do |_options|
161
+ @clouds_interface = clouds
162
+ @clusters_interface = clusters
163
+ @cloud_resource_pools_interface = pools_interface
164
+ end
165
+ [command, pools_interface, clusters]
166
+ end
167
+
168
+ def capture_stdout
169
+ terminal = Morpheus::Terminal.instance
170
+ original = terminal.stdout
171
+ buffer = StringIO.new
172
+ terminal.set_stdout(buffer)
173
+ yield
174
+ buffer.string
175
+ ensure
176
+ terminal.set_stdout(original)
177
+ end
178
+
179
+ def capture_stderr
180
+ original = $stderr
181
+ $stderr = StringIO.new
182
+ yield
183
+ $stderr.string
184
+ ensure
185
+ $stderr = original
186
+ end
187
+ end
@@ -0,0 +1,63 @@
1
+ require 'morpheus_test'
2
+
3
+ # Tests for Morpheus::Cli::HostVmGroupsCommand and the cloud-scoped
4
+ # host-vm-group subcommands added to Morpheus::Cli::Clouds for MORPH-16326.
5
+ #
6
+ # Scope note: these cover only client-side behaviour -- help output and argument
7
+ # validation -- both of which resolve before any API call is made.
8
+ #
9
+ # Tests that would exercise list/get/add/update/remove against the appliance are
10
+ # deliberately omitted until the /api/host-vm-groups request and response
11
+ # contract is confirmed against the MORPH-14136 / MORPH-16074 server work.
12
+ class MorpheusTest::HostVmGroupsTest < MorpheusTest::TestCase
13
+
14
+ def test_host_vm_groups_help
15
+ assert_execute %(host-vm-groups --help)
16
+ assert_execute %(host-vm-groups list --help)
17
+ assert_execute %(host-vm-groups get --help)
18
+ assert_execute %(host-vm-groups add --help)
19
+ assert_execute %(host-vm-groups update --help)
20
+ assert_execute %(host-vm-groups remove --help)
21
+ end
22
+
23
+ # The cloud-scoped subcommands must be registered on the clouds command.
24
+ def test_clouds_host_vm_group_subcommands_help
25
+ assert_execute %(clouds list-host-vm-groups --help)
26
+ assert_execute %(clouds get-host-vm-group --help)
27
+ assert_execute %(clouds add-host-vm-group --help)
28
+ assert_execute %(clouds update-host-vm-group --help)
29
+ assert_execute %(clouds remove-host-vm-group --help)
30
+ end
31
+
32
+ ## Argument validation (fails during option parsing, before any request)
33
+
34
+ def test_host_vm_groups_unknown_subcommand
35
+ assert_error %(host-vm-groups nonsense-subcommand)
36
+ end
37
+
38
+ def test_host_vm_groups_get_requires_an_id
39
+ assert_error %(host-vm-groups get)
40
+ end
41
+
42
+ def test_host_vm_groups_remove_requires_an_id
43
+ assert_error %(host-vm-groups remove -y)
44
+ end
45
+
46
+ def test_clouds_list_host_vm_groups_requires_a_cloud
47
+ assert_error %(clouds list-host-vm-groups)
48
+ end
49
+
50
+ def test_clouds_get_host_vm_group_requires_both_args
51
+ assert_error %(clouds get-host-vm-group 1)
52
+ end
53
+
54
+ # No unscoped list/create route at /api/host-vm-groups.
55
+ def test_host_vm_groups_list_requires_a_scope
56
+ assert_error %(host-vm-groups list)
57
+ end
58
+
59
+ def test_host_vm_groups_add_requires_a_scope
60
+ assert_error %(host-vm-groups add --name cli-test --dry-run)
61
+ end
62
+
63
+ end