morpheus-cli 8.0.13 → 8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aad3ce7f814765f24cd6d8c0c54c234d6c745be616536a92b3005e127740f2d0
4
- data.tar.gz: af0212f0f02ee8f85133c36790493e09542112e9e21ee183692cce150a029e29
3
+ metadata.gz: f058297ee2bc6b9cc1dac3f97bb2aad27785687af9283e89b5275b101a126145
4
+ data.tar.gz: 21f00f181e173a464d579d2f6368d12fbe9880530d01fd59cebc40775eb9af78
5
5
  SHA512:
6
- metadata.gz: 648640393d42c0a3dc358a198baf5c513cbf48e8c58202143974f7bbd15e2acc06f7cd3c35305a6404ddabb9ed0ee64c16c4beb84bbda588d7c03df4debca69d
7
- data.tar.gz: 4a1b6e530ebcb825f2c1bc54e04ea29ad0ec249f1b3ddc65d1bfce2144db1f17c1389d2368ee9f4dbb4398f5be911b1509f418f6ee9a28c8c42bd1e4e95b3cc2
6
+ metadata.gz: fea8e30a0ff487c060873ca4ba6a3d4af91c52f819e6956675d653dc57c330d495b2f27b9d821e7314da332573acb8bf6bc17380a283fe1404a80789eebda5b0
7
+ data.tar.gz: 8d5fb8b37a542e13f5e9656e2d355b9224ed51ede6a4e749620b3dbdf059a3449b2b09e64c4d97a0b2458469d99508dbf3029a158c1a234a72736ccdc7b6437f
data/Dockerfile CHANGED
@@ -1,5 +1,5 @@
1
1
  FROM ruby:2.7.5
2
2
 
3
- RUN gem install morpheus-cli -v 8.0.13
3
+ RUN gem install morpheus-cli -v 8.1.0
4
4
 
5
5
  ENTRYPOINT ["morpheus"]
data/bin/morpheus CHANGED
@@ -2,11 +2,14 @@
2
2
  require 'morpheus'
3
3
 
4
4
  # arguments
5
- args = ARGV
5
+ args = ARGV.dup
6
6
 
7
- # input pipe
8
- # append piped data as arguments
9
- if !$stdin.tty?
7
+ # input pipe: only read stdin as arguments when --stdin is explicitly passed.
8
+ # Previously stdin was always consumed when not a TTY, which caused morpheus
9
+ # invoked inside a piped bash script (e.g. base64 -d <<< <script> | bash -l)
10
+ # to consume the remaining script lines as CLI arguments (MORPH-6237).
11
+ # Usage: echo 42 | morpheus instances get --stdin
12
+ if args.delete('--stdin') && !$stdin.tty?
10
13
  pipe_data = $stdin.read
11
14
  if pipe_data
12
15
  args += pipe_data.split
@@ -412,6 +412,14 @@ class Morpheus::APIClient
412
412
  Morpheus::ServersInterface.new(common_interface_options).setopts(@options)
413
413
  end
414
414
 
415
+ def systems
416
+ Morpheus::SystemsInterface.new(common_interface_options).setopts(@options)
417
+ end
418
+
419
+ def system_types
420
+ Morpheus::SystemTypesInterface.new(common_interface_options).setopts(@options)
421
+ end
422
+
415
423
  def server_devices
416
424
  Morpheus::ServerDevicesInterface.new(common_interface_options).setopts(@options)
417
425
  end
@@ -118,6 +118,20 @@ class Morpheus::RolesInterface < Morpheus::APIClient
118
118
  execute(method: :put, url: url, headers: headers, payload: payload.to_json)
119
119
  end
120
120
 
121
+ def update_cluster_type(account_id, id, options)
122
+ url = build_url(account_id, id) + "/update-cluster-type"
123
+ headers = { :authorization => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
124
+ payload = options
125
+ execute(method: :put, url: url, headers: headers, payload: payload.to_json)
126
+ end
127
+
128
+ def validate(account_id, options, params={})
129
+ url = "#{@base_url}/api/roles/validate"
130
+ headers = { :authorization => "Bearer #{@access_token}", 'Content-Type' => 'application/json' }
131
+ payload = options
132
+ execute(method: :post, url: url, headers: headers, payload: payload.to_json, params: params)
133
+ end
134
+
121
135
  private
122
136
 
123
137
  def build_url(account_id=nil, role_id=nil)
@@ -0,0 +1,13 @@
1
+ require 'morpheus/api/rest_interface'
2
+
3
+ class Morpheus::SystemTypesInterface < Morpheus::RestInterface
4
+
5
+ def base_path
6
+ "/api/infrastructure/system-types"
7
+ end
8
+
9
+ def list_layouts(type_id, params = {})
10
+ execute(method: :get, url: "#{base_path}/#{type_id}/layouts", params: params)
11
+ end
12
+
13
+ end
@@ -0,0 +1,9 @@
1
+ require 'morpheus/api/rest_interface'
2
+
3
+ class Morpheus::SystemsInterface < Morpheus::RestInterface
4
+
5
+ def base_path
6
+ "/api/infrastructure/systems"
7
+ end
8
+
9
+ end
@@ -528,6 +528,8 @@ EOT
528
528
  {
529
529
  "ID" => 'id',
530
530
  "Name" => 'name',
531
+ "Type" => lambda {|it| format_backup_type_tag(it) },
532
+ "Location" => lambda {|it| format_backup_location_tag(it) },
531
533
  "Schedule" => lambda {|it| it['schedule']['name'] rescue '' },
532
534
  "Backup Job" => lambda {|it| it['job']['name'] rescue '' },
533
535
  "Created" => lambda {|it| format_local_dt(it['dateCreated']) },
@@ -539,6 +541,8 @@ EOT
539
541
  {
540
542
  "ID" => 'id',
541
543
  "Name" => 'name',
544
+ "Backup Type" => lambda {|it| format_backup_type_tag(it) },
545
+ "Backup Location" => lambda {|it| format_backup_location_tag(it) },
542
546
  "Location Type" => lambda {|it|
543
547
  if it['locationType'] == "instance"
544
548
  "Instance"
@@ -589,6 +593,33 @@ EOT
589
593
  "#{result['backup']['name']} (#{format_local_dt(result['startDate'])})"
590
594
  end
591
595
 
596
+ # format backup type tag (manual or policy-based)
597
+ def format_backup_type_tag(backup)
598
+ # check if backup has a schdule or job associated with it
599
+ # manual backups typically dont have a schedule while policy-based do
600
+ if backup['cronExpression'] || (backup['schedule'] && backup['schedule']['id'])
601
+ "#{cyan}POLICY#{reset}"
602
+ elsif backup['job'] && backup['job']['id']
603
+ "#{cyan}POLICY#{reset}"
604
+ else
605
+ "#{yellow}MANUAL#{reset}"
606
+ end
607
+ end
608
+
609
+ # format backup location tag (local or remote)
610
+ def format_backup_location_tag(backup)
611
+ # check storage provider or backup provider indicating remote storage
612
+ if backup['storageProvider'] && backup['storageProvider']['id']
613
+ provider_type = backup['storageProvider']['type'] || backup['storageProvider']['providerType'] || ''
614
+ "#{green}REMOTE#{reset} (#{provider_type})"
615
+ elsif backup['backupProvider'] && backup['backupProvider']['id']
616
+ provider_type = backup['backupProvider']['type'] || backup['backupProvider']['providerType'] || ''
617
+ "#{green}REMOTE#{reset} (#{provider_type})"
618
+ else
619
+ "#{blue}LOCAL#{reset}"
620
+ end
621
+ end
622
+
592
623
  # prompt for an instance config (vdiPool.instanceConfig)
593
624
  def prompt_restore_instance_config(options)
594
625
  # use config if user passed one in..
@@ -171,11 +171,17 @@ class Morpheus::Cli::LibraryOptionListsCommand
171
171
  "Credentials" => lambda {|it| it['credential'] ? (it['credential']['type'] == 'local' ? '(Local)' : it['credential']['name']) : nil },
172
172
  "Username" => 'serviceUsername',
173
173
  "Password" => 'servicePassword',
174
+ "Inject System Auth Header" => lambda {|it| format_boolean it['injectExecutionLeaseAuth'] },
175
+ "Use Owner Authorization" => lambda {|it| format_boolean it['useOwnerAuth'] }
174
176
  }
175
177
  option_list_columns.delete("API Type") if option_type_list['type'] != 'api'
176
178
  option_list_columns.delete("Credentials") if !['rest','ldap'].include?(option_type_list['type']) # || !(option_type_list['credential'] && option_type_list['credential']['id'])
177
179
  option_list_columns.delete("Username") if !['rest','ldap'].include?(option_type_list['type']) || !(option_type_list['serviceUsername'])
178
180
  option_list_columns.delete("Password") if !['rest','ldap'].include?(option_type_list['type']) || !(option_type_list['servicePassword'])
181
+ option_list_columns.delete("Inject System Auth Header") if option_type_list['type'] != 'rest'
182
+ option_list_columns.delete("Use Owner Authorization") if option_type_list['type'] != 'rest'
183
+ option_list_columns.delete("Inject System Auth Header") if option_type_list['type'] != 'rest'
184
+ option_list_columns.delete("Use Owner Authorization") if option_type_list['type'] != 'rest'
179
185
  source_headers = []
180
186
  if option_type_list['config'] && option_type_list['config']['sourceHeaders']
181
187
  source_headers = option_type_list['config']['sourceHeaders'].collect do |header|
@@ -291,7 +297,7 @@ class Morpheus::Cli::LibraryOptionListsCommand
291
297
  end
292
298
  end
293
299
  # tweak payload for API
294
- ['ignoreSSLErrors', 'realTime'].each { |k|
300
+ ['ignoreSSLErrors', 'realTime', 'injectExecutionLeaseAuth', 'useOwnerAuth'].each { |k|
295
301
  list_payload[k] = ['on','true'].include?(list_payload[k].to_s) if list_payload.key?(k)
296
302
  }
297
303
  payload.deep_merge!({'optionTypeList' => list_payload})
@@ -348,7 +354,7 @@ class Morpheus::Cli::LibraryOptionListsCommand
348
354
  end
349
355
  end
350
356
  # tweak payload for API
351
- ['ignoreSSLErrors', 'realTime'].each { |k|
357
+ ['ignoreSSLErrors', 'realTime', 'injectExecutionLeaseAuth', 'useOwnerAuth'].each { |k|
352
358
  list_payload[k] = ['on','true'].include?(list_payload[k].to_s) if list_payload.key?(k)
353
359
  }
354
360
  payload.deep_merge!({'optionTypeList' => list_payload})
@@ -437,6 +443,8 @@ class Morpheus::Cli::LibraryOptionListsCommand
437
443
  {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'sourceUrl', 'fieldLabel' => 'Source Url', 'type' => 'text', 'required' => true, 'description' => "A REST URL can be used to fetch list data and is cached in the appliance database.", 'displayOrder' => 6},
438
444
  {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'ignoreSSLErrors', 'fieldLabel' => 'Ignore SSL Errors', 'type' => 'checkbox', 'defaultValue' => false, 'displayOrder' => 7},
439
445
  {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'realTime', 'fieldLabel' => 'Real Time', 'type' => 'checkbox', 'defaultValue' => false, 'displayOrder' => 8},
446
+ {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'injectExecutionLeaseAuth', 'switch' => 'inject-execution-lease-auth', 'fieldLabel' => 'Inject System Auth Header', 'type' => 'checkbox', 'defaultValue' => false, 'description' => 'Injects an authorization header using a system lease token when making the REST call.', 'displayOrder' => 21},
447
+ {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'useOwnerAuth', 'switch' => 'use-owner-auth', 'fieldLabel' => 'Use Owner Authorization', 'type' => 'checkbox', 'defaultValue' => false, 'description' => 'Uses the authorization credentials of the owner of the option list.', 'displayOrder' => 22},
440
448
  {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'sourceMethod', 'fieldLabel' => 'Source Method', 'type' => 'select', 'selectOptions' => [{'name' => 'GET', 'value' => 'GET'}, {'name' => 'POST', 'value' => 'POST'}], 'defaultValue' => 'GET', 'required' => true, 'displayOrder' => 9},
441
449
  {'dependsOnCode' => 'optionTypeList.type:rest|ldap', 'fieldName' => 'credential', 'fieldLabel' => 'Credentials', 'type' => 'select', 'optionSource' => 'credentials', 'description' => 'Credential ID or use "local" to specify username and password', 'displayOrder' => 10, 'defaultValue' => "local", 'required' => true, :for_help_only => true}, # hacky way to render this but not prompt for it
442
450
  {'dependsOnCode' => 'optionTypeList.type:rest', 'fieldName' => 'serviceUsername', 'fieldLabel' => 'Username', 'type' => 'text', 'description' => "A Basic Auth Username for use when type is 'rest'.", 'displayOrder' => 11, "credentialFieldContext" => 'credential', "credentialFieldName" => 'username', "credentialType" => "username-password,oauth2"},
@@ -5,7 +5,7 @@ class Morpheus::Cli::Roles
5
5
  include Morpheus::Cli::AccountsHelper
6
6
  include Morpheus::Cli::ProvisioningHelper
7
7
  include Morpheus::Cli::WhoamiHelper
8
- register_subcommands :list, :get, :add, :update, :remove,
8
+ register_subcommands :list, :get, :add, :update, :remove, :validate,
9
9
  :'list-permissions', :'update-feature-access',
10
10
  :'update-group-access', :'update-global-group-access', :'update-default-group-access',
11
11
  :'update-global-cloud-access', :'update-cloud-access', :'update-default-cloud-access',
@@ -16,7 +16,8 @@ class Morpheus::Cli::Roles
16
16
  :'update-global-vdi-pool-access', :'update-vdi-pool-access', :'update-default-vdi-pool-access',
17
17
  :'update-global-report-type-access', :'update-report-type-access', :'update-default-report-type-access',
18
18
  :'update-global-task-access', :'update-task-access', :'update-default-task-access',
19
- :'update-global-workflow-access', :'update-workflow-access', :'update-default-workflow-access'
19
+ :'update-global-workflow-access', :'update-workflow-access', :'update-default-workflow-access',
20
+ :'update-cluster-type-access', :'update-default-cluster-type-access'
20
21
  set_subcommands_hidden(
21
22
  subcommands.keys.select{|c|
22
23
  c.include?('update-global')
@@ -47,7 +48,7 @@ class Morpheus::Cli::Roles
47
48
  optparse = Morpheus::Cli::OptionParser.new do |opts|
48
49
  opts.banner = subcommand_usage("[search phrase]")
49
50
  opts.on( '--tenant TENANT', "Tenant Filter for list of Roles." ) do |val|
50
- options[:tenant] = val
51
+ options[:account] = val
51
52
  end
52
53
  build_standard_list_options(opts, options)
53
54
  opts.footer = "List roles."
@@ -67,9 +68,6 @@ class Morpheus::Cli::Roles
67
68
  return 0, nil
68
69
  end
69
70
  load_whoami()
70
- if options[:tenant]
71
- params[:tenant] = options[:tenant]
72
- end
73
71
  json_response = @roles_interface.list(account_id, params)
74
72
 
75
73
  render_response(json_response, options, "roles") do
@@ -136,6 +134,9 @@ class Morpheus::Cli::Roles
136
134
  opts.on(nil,'--task-access', "Display Task Access") do
137
135
  options[:include_task_access] = true
138
136
  end
137
+ opts.on(nil,'--cluster-type-access', "Display Cluster Type Access") do
138
+ options[:include_cluster_type_access] = true
139
+ end
139
140
  opts.on('-a','--all', "Display All Access Lists") do
140
141
  options[:include_all_access] = true
141
142
  end
@@ -143,9 +144,7 @@ class Morpheus::Cli::Roles
143
144
  options[:include_default_access] = true
144
145
  end
145
146
  opts.on('--account-id ID', String, "Clarify Owner of Role") do |val|
146
- if has_complete_access
147
- options[:account_id] = val.to_s
148
- end
147
+ options[:account_id] = val.to_s
149
148
  end
150
149
  build_standard_get_options(opts, options)
151
150
  opts.footer = <<-EOT
@@ -246,6 +245,7 @@ EOT
246
245
  "VDI Pools" => lambda {|it| get_access_string(it['globalVdiPoolAccess']) },
247
246
  "Workflows" => lambda {|it| get_access_string(it['globalTaskSetAccess']) },
248
247
  "Tasks" => lambda {|it| get_access_string(it['globalTaskAccess']) },
248
+ "Cluster Types" => lambda {|it| get_access_string(it['globalClusterTypeAccess']) },
249
249
  }
250
250
 
251
251
  if role['roleType'].to_s.downcase == 'account'
@@ -449,7 +449,7 @@ EOT
449
449
  workflow_permissions = role['taskSets'] ? role['taskSets'] : (json_response['taskSetPermissions'] || [])
450
450
  print cyan
451
451
  if options[:include_workflow_access] || options[:include_all_access]
452
- print_h2 "Workflow", options
452
+ print_h2 "Workflow Access", options
453
453
  rows = workflow_permissions.collect do |it|
454
454
  {
455
455
  name: it['name'],
@@ -461,9 +461,30 @@ EOT
461
461
  end
462
462
  print as_pretty_table(rows, [:name, :access], options)
463
463
  elsif workflow_permissions.find {|it| it['access'] && it['access'] != 'default'}
464
- print_h2 "Workflow", options
464
+ print_h2 "Workflow Access", options
465
465
  print cyan,"Use --workflow-access to list custom access","\n"
466
466
  end
467
+
468
+ cluster_type_global_access = json_response['globalClusterTypeAccess']
469
+ cluster_type_permissions = role['clusterTypes'] ? role['clusterTypes'] : (json_response['clusterTypePermissions'] || [])
470
+ print cyan
471
+ if options[:include_cluster_type_access] || options[:include_all_access]
472
+ print_h2 "Cluster Type Access", options
473
+ rows = cluster_type_permissions.collect do |it|
474
+ {
475
+ name: it['name'],
476
+ access: format_access_string(it['access'], ["none","full"]),
477
+ }
478
+ end
479
+ if !options[:include_default_access]
480
+ rows = rows.select {|row| row[:access] && row[:access] != 'default '}
481
+ end
482
+ print as_pretty_table(rows, [:name, :access], options)
483
+ elsif cluster_type_permissions.find {|it| it['access'] && it['access'] != 'default'}
484
+ print_h2 "Cluster Type Access", options
485
+ print cyan,"Use --cluster-type-access to list custom access","\n"
486
+ end
487
+
467
488
  print reset,"\n"
468
489
  return 0, nil
469
490
  end
@@ -471,7 +492,7 @@ EOT
471
492
 
472
493
  def list_permissions(args)
473
494
  options = {}
474
- available_categories = ['feature', 'group', 'cloud', 'instance-type', 'blueprint', 'report-type', 'persona', 'catalog-item-type', 'vdi-pool', 'workflow', 'task']
495
+ available_categories = ['feature', 'group', 'cloud', 'instance-type', 'blueprint', 'report-type', 'persona', 'catalog-item-type', 'vdi-pool', 'workflow', 'task', 'cluster-type']
475
496
  optparse = Morpheus::Cli::OptionParser.new do |opts|
476
497
  opts.banner = subcommand_usage("[role] [category]")
477
498
  build_common_options(opts, options, [:list, :json, :yaml, :csv, :fields, :dry_run, :remote])
@@ -585,7 +606,7 @@ EOT
585
606
  opts.banner = subcommand_usage("[name] [options]")
586
607
  build_option_type_options(opts, options, add_role_option_types)
587
608
  build_role_access_options(opts, options, params)
588
- opts.on('--owner ID', String, "Set the owner/tenant/account for the role by account id. Only master tenants with full permission for Tenant and Role may use this option." ) do |val|
609
+ opts.on('--owner ID', String, "Set the owner/tenant/account for the role by account id. This option requires the admin permission to manage tenants." ) do |val|
589
610
  params['owner'] = val
590
611
  end
591
612
  opts.on(nil, '--include-default-access', "Include default access levels in the response (returns all available resources)") do
@@ -631,24 +652,19 @@ EOT
631
652
  params['authority'] = v_prompt['authority']
632
653
  v_prompt = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'description', 'fieldLabel' => 'Description', 'type' => 'text', 'displayOrder' => 2}], options[:options])
633
654
  params['description'] = v_prompt['description']
634
- v_prompt = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'landingUrl', 'fieldLabel' => 'landingUrl', 'type' => 'text', 'displayOrder' => 3, 'description' => 'An optional override for the default landing page after login for a user.'}], options[:options])
655
+ v_prompt = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'landingUrl', 'fieldLabel' => 'Landing URL', 'type' => 'text', 'displayOrder' => 3, 'description' => 'An optional override for the default landing page after login for a user.'}], options[:options])
635
656
  params['landingUrl'] = v_prompt['landingUrl']
636
657
 
637
- if params['owner']
638
- if @is_master_account && has_complete_access
639
- params['owner'] = params['owner']
640
- else
641
- print_red_alert "You do not have the necessary authority to use owner option"
642
- return
643
- end
644
- elsif @is_master_account && has_complete_access
658
+ can_manage_accounts = @user_permissions.find { |it| it['code'] == 'admin-accounts' && it['access'] == 'full'}
659
+
660
+ if can_manage_accounts
645
661
  v_prompt = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'owner', 'fieldLabel' => 'Owner', 'type' => 'select', 'selectOptions' => role_owner_options, 'defaultValue' => current_account['id'], 'displayOrder' => 3}], options[:options])
646
662
  params['owner'] = v_prompt['owner']
647
663
  else
648
664
  params['owner'] = current_account['id']
649
665
  end
650
666
 
651
- if @is_master_account && params['owner'] == current_account['id']
667
+ if can_manage_accounts
652
668
  v_prompt = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'roleType', 'fieldLabel' => 'Type', 'type' => 'select', 'selectOptions' => role_type_options, 'defaultValue' => 'user', 'displayOrder' => 4}], options[:options])
653
669
  params['roleType'] = v_prompt['roleType']
654
670
  else
@@ -661,6 +677,12 @@ EOT
661
677
  if options[:group_permissions] && params['roleType'] == 'account'
662
678
  raise_command_error "The --groups option is only available for account roles, not user roles"
663
679
  end
680
+ if params['globalZoneAccess'] && params['roleType'] == 'user'
681
+ raise_command_error "The --default-cloud-access option is only available for account roles, not user roles"
682
+ end
683
+ if params['globalSiteAccess'] && params['roleType'] == 'account'
684
+ raise_command_error "The --default-group-access option is only available for user roles, not account roles"
685
+ end
664
686
 
665
687
  v_prompt = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'baseRole', 'fieldLabel' => 'Copy From Role', 'type' => 'select', 'selectOptions' => base_role_options(params), 'displayOrder' => 5}], options[:options])
666
688
  if v_prompt['baseRole'].to_s != ''
@@ -779,9 +801,9 @@ EOT
779
801
  # merge -O options into normally parsed options
780
802
  params.deep_merge!(passed_options)
781
803
  prompt_option_types = update_role_option_types()
782
- if !@is_master_account
783
- prompt_option_types = prompt_option_types.reject {|it| ['roleType', 'multitenant','multitenantLocked'].include?(it['fieldName']) }
784
- end
804
+ # if !has_complete_access
805
+ # prompt_option_types = prompt_option_types.reject {|it| ['roleType', 'multitenant','multitenantLocked'].include?(it['fieldName']) }
806
+ # end
785
807
  if role['roleType'] != 'user'
786
808
  prompt_option_types = prompt_option_types.reject {|it| ['multitenant','multitenantLocked'].include?(it['fieldName']) }
787
809
  end
@@ -792,6 +814,12 @@ EOT
792
814
  if options[:group_permissions] && role['roleType'] == 'account'
793
815
  raise_command_error "The --groups option is only available for account roles, not user roles"
794
816
  end
817
+ if params['globalZoneAccess'] && role['roleType'] == 'user'
818
+ raise_command_error "The --default-cloud-access option is only available for account roles, not user roles"
819
+ end
820
+ if params['globalSiteAccess'] && role['roleType'] == 'account'
821
+ raise_command_error "The --default-group-access option is only available for user roles, not account roles"
822
+ end
795
823
  # bulk role permissions
796
824
  parse_role_access_options(options, params)
797
825
 
@@ -872,6 +900,109 @@ EOT
872
900
  end
873
901
  end
874
902
 
903
+ def validate(args)
904
+ options = {}
905
+ params = {}
906
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
907
+ opts.banner = subcommand_usage("[role] [options]")
908
+ build_option_type_options(opts, options, add_role_option_types)
909
+ build_role_access_options(opts, options, params)
910
+ build_common_options(opts, options, [:options, :payload, :json, :dry_run, :remote])
911
+ opts.footer = <<-EOT
912
+ Validate role permissions without creating or updating a role.
913
+ [role] is optional. This is the name (authority) or id of a role.
914
+ This is useful for testing permission configurations before applying them.
915
+ All the role permissions and access values can be validated.
916
+ Use --feature-access "CODE=ACCESS,CODE=ACCESS" to validate access levels for specific feature permissions.
917
+ Example: morpheus roles validate --authority "Test Role" --feature-access "admin=full,activity=read"
918
+ Example: morpheus roles validate "Existing Role" --feature-access "activity=full"
919
+ EOT
920
+ end
921
+ optparse.parse!(args)
922
+
923
+ # allow 0-1 arguments
924
+ verify_args!(args:args, optparse:optparse, max:1)
925
+
926
+ connect(options)
927
+ begin
928
+ account = find_account_from_options(options)
929
+ account_id = account ? account['id'] : nil
930
+
931
+ # load existing role if arg passed
932
+ role = nil
933
+ if args[0]
934
+ role = find_role_by_name_or_id(account_id, args[0])
935
+ exit 1 if role.nil?
936
+ end
937
+
938
+ passed_options = options[:options] ? options[:options].reject {|k,v| k.is_a?(Symbol) } : {}
939
+ payload = nil
940
+
941
+ if options[:payload]
942
+ payload = options[:payload]
943
+ payload.deep_merge!({'role' => passed_options}) unless passed_options.empty?
944
+ else
945
+ # merge -O options into normally parsed options
946
+ params.deep_merge!(passed_options)
947
+
948
+ # Parse role access options
949
+ parse_role_access_options(options, params)
950
+
951
+ # Validate role type constraints
952
+ role_type = role ? role['roleType'] : params['roleType']
953
+ if role_type
954
+ if params['globalZoneAccess'] && role_type == 'user'
955
+ raise_command_error "The --default-cloud-access option is only available for account roles, not user roles"
956
+ end
957
+ if params['globalSiteAccess'] && role_type == 'account'
958
+ raise_command_error "The --default-group-access option is only available for user roles, not account roles"
959
+ end
960
+ end
961
+
962
+ if params.empty? && passed_options.empty? && role.nil?
963
+ raise_command_error "Specify at least one role configuration option to validate.\n#{optparse}"
964
+ end
965
+
966
+ payload = {"role" => params}
967
+ end
968
+
969
+ if role
970
+ payload['role']['id'] = role['id']
971
+ end
972
+
973
+ query_params = parse_query_options(options)
974
+ @roles_interface.setopts(options)
975
+
976
+ if options[:dry_run]
977
+ print_dry_run @roles_interface.dry.validate(account_id, payload, query_params)
978
+ return 0, nil
979
+ end
980
+
981
+ json_response = @roles_interface.validate(account_id, payload, query_params)
982
+
983
+ render_response(json_response, options) do
984
+ if json_response['success'] && json_response['valid']
985
+ print_green_success json_response['msg'] || "Role permissions are valid"
986
+ else
987
+ print_red_alert "Validation failed: #{json_response['msg'] || 'Invalid role permissions'}"
988
+ if json_response['errors'] && !json_response['errors'].empty?
989
+ print_h2 "Validation Errors", options
990
+ json_response['errors'].each do |key, msg|
991
+ print red, " #{key}: #{msg}", reset, "\n"
992
+ end
993
+ end
994
+ end
995
+ end
996
+
997
+ # Return exit code based on validation result
998
+ return json_response['success'] && json_response['valid'] ? 0 : 1
999
+
1000
+ rescue RestClient::Exception => e
1001
+ print_rest_exception(e, options)
1002
+ return 1
1003
+ end
1004
+ end
1005
+
875
1006
  def update_feature_access(args)
876
1007
  options = {}
877
1008
  allowed_access_values = ["full", "full_decrypted", "group", "listfiles", "managerules", "no", "none", "provision", "read", "rolemappings", "user", "view", "yes"]
@@ -979,6 +1110,10 @@ EOT
979
1110
  role = find_role_by_name_or_id(account_id, name)
980
1111
  exit 1 if role.nil?
981
1112
 
1113
+ if role['roleType'] == 'account'
1114
+ raise_command_error "The default-group-access command is only available for user roles, not account roles"
1115
+ end
1116
+
982
1117
  params = {permissionCode: 'ComputeSite', access: access_value}
983
1118
  @roles_interface.setopts(options)
984
1119
  if options[:dry_run]
@@ -1132,6 +1267,10 @@ EOT
1132
1267
  role = find_role_by_name_or_id(account_id, name)
1133
1268
  exit 1 if role.nil?
1134
1269
 
1270
+ if role['roleType'] == 'user'
1271
+ raise_command_error "The default-cloud-access command is only available for account roles, not user roles"
1272
+ end
1273
+
1135
1274
  params = {permissionCode: 'ComputeZone', access: access_value}
1136
1275
  @roles_interface.setopts(options)
1137
1276
  if options[:dry_run]
@@ -2475,6 +2614,153 @@ Update default workflow access for a role.
2475
2614
  end
2476
2615
  end
2477
2616
 
2617
+ def update_default_cluster_type_access(args)
2618
+ options = {}
2619
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
2620
+ opts.banner = subcommand_usage("[role] [access]")
2621
+ build_common_options(opts, options, [:json, :dry_run, :remote])
2622
+ opts.footer = <<-EOT
2623
+ Update default cluster type access for a role.
2624
+ [role] is required. This is the id of a role.
2625
+ [access] is required. This is the access level to assign: full or none.
2626
+ EOT
2627
+ end
2628
+ optparse.parse!(args)
2629
+ verify_args!(args:args, optparse:optparse, count: 2)
2630
+ name = args[0]
2631
+ access_value = args[1].to_s.downcase
2632
+ if !['full', 'none', 'custom'].include?(access_value)
2633
+ raise_command_error("invalid access value: #{args[1]}", args, optparse)
2634
+ end
2635
+
2636
+ connect(options)
2637
+ begin
2638
+ account = find_account_from_options(options)
2639
+ account_id = account ? account['id'] : nil
2640
+ role = find_role_by_name_or_id(account_id, name)
2641
+ exit 1 if role.nil?
2642
+ params = {permissionCode: 'ServerGroupType', access: access_value}
2643
+ @roles_interface.setopts(options)
2644
+ if options[:dry_run]
2645
+ print_dry_run @roles_interface.dry.update_permission(account_id, role['id'], params)
2646
+ return
2647
+ end
2648
+ json_response = @roles_interface.update_permission(account_id, role['id'], params)
2649
+
2650
+ if options[:json]
2651
+ print JSON.pretty_generate(json_response)
2652
+ print "\n"
2653
+ else
2654
+ print_green_success "Role #{role['authority']} default cluster type access updated"
2655
+ end
2656
+ rescue RestClient::Exception => e
2657
+ print_rest_exception(e, options)
2658
+ exit 1
2659
+ end
2660
+ end
2661
+
2662
+ def update_cluster_type_access(args)
2663
+ options = {}
2664
+ cluster_type_id = nil
2665
+ access_value = nil
2666
+ do_all = false
2667
+ allowed_access_values = ['full', 'none', 'default']
2668
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
2669
+ opts.banner = subcommand_usage("[role] [cluster-type] [access]")
2670
+ opts.on( '--cluster-type ID', String, "Cluster Type ID, code or Name" ) do |val|
2671
+ cluster_type_id = val
2672
+ end
2673
+ opts.on( nil, '--all', "Update all cluster types at once." ) do
2674
+ do_all = true
2675
+ end
2676
+ opts.on( '--access VALUE', String, "Access value [#{allowed_access_values.join('|')}]" ) do |val|
2677
+ access_value = val
2678
+ end
2679
+ build_common_options(opts, options, [:json, :dry_run, :remote])
2680
+ opts.footer = "Update role access for a cluster type or all cluster types.\n" +
2681
+ "[role] is required. This is the name or id of a role.\n" +
2682
+ "--cluster-type or --all is required. This is the name, code or id of a cluster type.\n" +
2683
+ "--access is required. This is the new access value: #{ored_list(allowed_access_values)}"
2684
+ end
2685
+ optparse.parse!(args)
2686
+
2687
+ name = args[0]
2688
+ if do_all
2689
+ verify_args!(args:args, optparse:optparse, min:1, max:2)
2690
+ access_value = args[1] if args[1]
2691
+ else
2692
+ verify_args!(args:args, optparse:optparse, min:1, max:3)
2693
+ cluster_type_id = args[1] if args[1]
2694
+ access_value = args[2] if args[2]
2695
+ end
2696
+ if !cluster_type_id && !do_all
2697
+ raise_command_error("missing required argument: [cluster-type] or --all", args, optparse)
2698
+ end
2699
+ if !access_value
2700
+ raise_command_error("missing required argument: [access]", args, optparse)
2701
+ end
2702
+ access_value = access_value.to_s.downcase
2703
+ if !allowed_access_values.include?(access_value)
2704
+ raise_command_error("invalid access value: #{access_value}", args, optparse)
2705
+ puts optparse
2706
+ return 1
2707
+ end
2708
+
2709
+ connect(options)
2710
+ begin
2711
+ account = find_account_from_options(options)
2712
+ account_id = account ? account['id'] : nil
2713
+ role = find_role_by_name_or_id(account_id, name)
2714
+ return 1 if role.nil?
2715
+
2716
+ role_json = @roles_interface.get(account_id, role['id'], {'includeDefaultAccess' => true})
2717
+ cluster_type_permissions = role_json['clusterTypePermissions'] || role_json['clusterTypes'] || []
2718
+
2719
+ # hacky, but support name or code lookup via the list returned in the show payload
2720
+ cluster_type = nil
2721
+ if !do_all
2722
+ if cluster_type_id.to_s =~ /\A\d{1,}\Z/
2723
+ cluster_type = cluster_type_permissions.find {|b| b['id'] == cluster_type_id.to_i }
2724
+ else
2725
+ cluster_type = cluster_type_permissions.find {|b| b['name'] == cluster_type_id }
2726
+ end
2727
+ if cluster_type.nil?
2728
+ print_red_alert "Cluster Type not found: '#{cluster_type_id}'"
2729
+ return 1
2730
+ end
2731
+ end
2732
+
2733
+ params = {}
2734
+ if do_all
2735
+ params['allClusterTypes'] = true
2736
+ else
2737
+ params['clusterTypeId'] = cluster_type['id']
2738
+ end
2739
+ params['access'] = access_value == 'default' ? nil : access_value
2740
+ @roles_interface.setopts(options)
2741
+ if options[:dry_run]
2742
+ print_dry_run @roles_interface.dry.update_cluster_type(account_id, role['id'], params)
2743
+ return
2744
+ end
2745
+ json_response = @roles_interface.update_cluster_type(account_id, role['id'], params)
2746
+
2747
+ if options[:json]
2748
+ print JSON.pretty_generate(json_response)
2749
+ print "\n"
2750
+ else
2751
+ if do_all
2752
+ print_green_success "Role #{role['authority']} access updated for all cluster types"
2753
+ else
2754
+ print_green_success "Role #{role['authority']} access updated for cluster type #{cluster_type['name']}"
2755
+ end
2756
+ end
2757
+ return 0
2758
+ rescue RestClient::Exception => e
2759
+ print_rest_exception(e, options)
2760
+ exit 1
2761
+ end
2762
+ end
2763
+
2478
2764
  private
2479
2765
 
2480
2766
  def add_role_option_types
@@ -2511,21 +2797,10 @@ Update default workflow access for a role.
2511
2797
  end
2512
2798
 
2513
2799
  def base_role_options(role_payload)
2514
- params = {"tenantId" => role_payload['owner'], "userId" => current_user['id'], "roleType" => role_payload['roleType'] }
2800
+ params = {"tenantId" => role_payload['owner'], "roleType" => role_payload['roleType'] }
2515
2801
  @options_interface.options_for_source("copyFromRole", params)['data']
2516
2802
  end
2517
2803
 
2518
- def has_complete_access
2519
- has_access = false
2520
- if @is_master_account
2521
- admin_accounts = @user_permissions.select { |it| it['code'] == 'admin-accounts' && it['access'] == 'full'}
2522
- admin_roles = @user_permissions.select { |it| it['code'] == 'admin-roles' && it['access'] == 'full' }
2523
- if admin_accounts != nil && admin_roles != nil
2524
- has_access = true
2525
- end
2526
- end
2527
- has_access
2528
- end
2529
2804
 
2530
2805
  def parse_access_csv(output, val)
2531
2806
  output ||= {}
@@ -2664,6 +2939,13 @@ Update default workflow access for a role.
2664
2939
  options[:workflow_permissions] ||= {}
2665
2940
  parse_access_csv(options[:workflow_permissions], val)
2666
2941
  end
2942
+ opts.on('--default-cluster-type-access ACCESS', String, "Set the default cluster type access: [none|full]" ) do |val|
2943
+ params['globalTaskSetAccess'] = val.to_s.downcase
2944
+ end
2945
+ opts.on('--cluster-types CODE=ACCESS', String, "Set cluster type to a custom access by cluster type code. Example: kubernetes-cluster=none,mvm-cluster=full" ) do |val|
2946
+ options[:cluster_type_permissions] ||= {}
2947
+ parse_access_csv(options[:cluster_type_permissions], val)
2948
+ end
2667
2949
  opts.on('--reset-permissions', "Reset all feature permission access to none. This can be used in conjunction with --permissions to recreate the feature permission access for the role." ) do
2668
2950
  options[:reset_permissions] = true
2669
2951
  end
@@ -2814,6 +3096,19 @@ Update default workflow access for a role.
2814
3096
  end
2815
3097
  params['taskSets'] = perms_array
2816
3098
  end
3099
+ if options[:cluster_type_permissions]
3100
+ perms_array = []
3101
+ options[:cluster_type_permissions].each do |k,v|
3102
+ cluster_type_code = k
3103
+ access_value = v.to_s.empty? ? "none" : v.to_s
3104
+ if cluster_type_code =~ /\A\d{1,}\Z/
3105
+ perms_array << {"id" => cluster_type_code.to_i, "access" => access_value}
3106
+ else
3107
+ perms_array << {"code" => cluster_type_code, "access" => access_value}
3108
+ end
3109
+ end
3110
+ params['clusterTypes'] = perms_array
3111
+ end
2817
3112
  if options[:reset_permissions]
2818
3113
  params["resetPermissions"] = true
2819
3114
  end
@@ -168,6 +168,7 @@ class Morpheus::Cli::ServicePlanCommand
168
168
  description_cols['Core Count'] = lambda {|it| it['maxCores']}
169
169
  description_cols['Custom Cores'] = lambda {|it| format_boolean(it['customCores'])}
170
170
  description_cols['Cores Per Socket'] = lambda {|it| it['coresPerSocket']} if provision_type['hasConfigurableCpuSockets'] && service_plan['customCores']
171
+ description_cols['Custom CPU'] = lambda {|it| format_boolean(it['customCpu'])}
171
172
 
172
173
  ranges = (service_plan['config'] ? service_plan['config']['ranges'] : nil) || {}
173
174
 
@@ -279,6 +280,9 @@ class Morpheus::Cli::ServicePlanCommand
279
280
  opts.on('--custom-cores [on|off]', String, "Can be used to enable / disable customizable cores. Default is on") do |val|
280
281
  params['customCores'] = val.to_s == 'on' || val.to_s == 'true' || val.to_s == '1' || val.to_s == ''
281
282
  end
283
+ opts.on('--custom-cpu [on|off]', String, "Can be used to enable / disable customizable CPUs. Default is on") do |val|
284
+ params['customCpu'] = val.to_s == 'on' || val.to_s == 'true' || val.to_s == '1' || val.to_s == ''
285
+ end
282
286
  opts.on('--custom-storage [on|off]', String, "Can be used to enable / disable customizable storage. Default is on") do |val|
283
287
  params['customMaxStorage'] = val.to_s == 'on' || val.to_s == 'true' || val.to_s == '1' || val.to_s == ''
284
288
  end
@@ -551,6 +555,9 @@ class Morpheus::Cli::ServicePlanCommand
551
555
  opts.on('--custom-cores [on|off]', String, "Can be used to enable / disable customizable cores. Default is on") do |val|
552
556
  params['customCores'] = val.to_s == 'on' || val.to_s == 'true' || val.to_s == '1' || val.to_s == ''
553
557
  end
558
+ opts.on('--custom-cpu [on|off]', String, "Can be used to enable / disable customizable CPUs. Default is on") do |val|
559
+ params['customCpu'] = val.to_s == 'on' || val.to_s == 'true' || val.to_s == '1' || val.to_s == ''
560
+ end
554
561
  opts.on('--custom-storage [on|off]', String, "Can be used to enable / disable customizable storage. Default is on") do |val|
555
562
  params['customMaxStorage'] = val.to_s == 'on' || val.to_s == 'true' || val.to_s == '1' || val.to_s == ''
556
563
  end
@@ -513,8 +513,12 @@ class Morpheus::Cli::StorageProvidersCommand
513
513
 
514
514
  def remove(args)
515
515
  options = {}
516
+ params = {:removeResources => 'on'}
516
517
  optparse = Morpheus::Cli::OptionParser.new do |opts|
517
518
  opts.banner = subcommand_usage("[storage-bucket]")
519
+ opts.on('--remove-resources [on|off]', ['on','off'], "Remove From Server. Default is on.") do |val|
520
+ params[:removeResources] = val.nil? ? 'on' : val
521
+ end
518
522
  build_common_options(opts, options, [:auto_confirm, :json, :dry_run, :remote])
519
523
  opts.footer = "Delete a storage bucket." + "\n" +
520
524
  "[storage-bucket] is required. This is the name or id of a storage bucket."
@@ -537,10 +541,10 @@ class Morpheus::Cli::StorageProvidersCommand
537
541
  end
538
542
  @storage_providers_interface.setopts(options)
539
543
  if options[:dry_run]
540
- print_dry_run @storage_providers_interface.dry.destroy(storage_provider['id'])
544
+ print_dry_run @storage_providers_interface.dry.destroy(storage_provider['id'], params)
541
545
  return 0
542
546
  end
543
- json_response = @storage_providers_interface.destroy(storage_provider['id'])
547
+ json_response = @storage_providers_interface.destroy(storage_provider['id'], params)
544
548
  if options[:json]
545
549
  print JSON.pretty_generate(json_response)
546
550
  print "\n"
@@ -0,0 +1,270 @@
1
+ require 'morpheus/cli/cli_command'
2
+
3
+ class Morpheus::Cli::Systems
4
+ include Morpheus::Cli::CliCommand
5
+ include Morpheus::Cli::RestCommand
6
+
7
+ set_command_name :systems
8
+ set_command_description "View and manage systems."
9
+ register_subcommands :list, :get, :add, :update, :remove
10
+
11
+ set_command_hidden
12
+
13
+ protected
14
+
15
+ # Systems API uses lowercase keys in payloads.
16
+ def system_object_key
17
+ 'system'
18
+ end
19
+
20
+ def system_list_key
21
+ 'systems'
22
+ end
23
+
24
+ def system_list_column_definitions(options)
25
+ {
26
+ "ID" => 'id',
27
+ "Name" => 'name',
28
+ "Type" => lambda {|it| it['type'] ? it['type']['name'] : '' },
29
+ "Layout" => lambda {|it| it['layout'] ? it['layout']['name'] : '' },
30
+ "Status" => 'status',
31
+ "Enabled" => lambda {|it| format_boolean(it['enabled']) },
32
+ "Date Created" => lambda {|it| format_local_dt(it['dateCreated']) }
33
+ }
34
+ end
35
+
36
+ def system_column_definitions(options)
37
+ {
38
+ "ID" => 'id',
39
+ "Name" => 'name',
40
+ "Description" => 'description',
41
+ "Status" => 'status',
42
+ "Status Message" => 'statusMessage',
43
+ "Enabled" => lambda {|it| format_boolean(it['enabled']) },
44
+ "External ID" => 'externalId',
45
+ "Date Created" => lambda {|it| format_local_dt(it['dateCreated']) },
46
+ "Last Updated" => lambda {|it| format_local_dt(it['lastUpdated']) }
47
+ }
48
+ end
49
+
50
+ def render_response_for_get(json_response, options)
51
+ render_response(json_response, options, rest_object_key) do
52
+ record = json_response[rest_object_key] || json_response
53
+ print_h1 rest_label, [], options
54
+ print cyan
55
+ print_description_list(rest_column_definitions(options), record, options)
56
+ print reset,"\n"
57
+ end
58
+ end
59
+
60
+ def add(args)
61
+ options = {}
62
+ params = {}
63
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
64
+ opts.banner = subcommand_usage("[name]")
65
+ opts.on('--name NAME', String, "System Name") do |val|
66
+ params['name'] = val.to_s
67
+ end
68
+ opts.on('--description [TEXT]', String, "Description") do |val|
69
+ params['description'] = val.to_s
70
+ end
71
+ opts.on('--type TYPE', String, "System Type ID or name") do |val|
72
+ params['type'] = val
73
+ end
74
+ opts.on('--layout LAYOUT', String, "System Layout ID or name") do |val|
75
+ params['layout'] = val
76
+ end
77
+ build_standard_add_options(opts, options)
78
+ opts.footer = "Create a new system.\n[name] is optional and can be passed as the first argument."
79
+ end
80
+ optparse.parse!(args)
81
+ connect(options)
82
+
83
+ payload = nil
84
+ if options[:payload]
85
+ payload = options[:payload]
86
+ payload[rest_object_key] ||= {}
87
+ payload[rest_object_key].deep_merge!(params) unless params.empty?
88
+ payload[rest_object_key]['name'] ||= args[0] if args[0]
89
+ else
90
+ system_payload = {}
91
+
92
+ # Name
93
+ system_payload['name'] = params['name'] || args[0]
94
+ if !system_payload['name'] && !options[:no_prompt]
95
+ system_payload['name'] = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'name', 'type' => 'text', 'fieldLabel' => 'Name', 'required' => true}], options[:options], @api_client, {})['name']
96
+ end
97
+ raise_command_error "Name is required.\n#{optparse}" if system_payload['name'].to_s.empty?
98
+
99
+ # Description
100
+ if !params['description'] && !options[:no_prompt]
101
+ system_payload['description'] = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'description', 'type' => 'text', 'fieldLabel' => 'Description', 'required' => false}], options[:options], @api_client, {})['description']
102
+ else
103
+ system_payload['description'] = params['description']
104
+ end
105
+
106
+ # Type
107
+ available_types = system_types_for_dropdown
108
+ type_val = params['type']
109
+ if type_val
110
+ type_id = type_val =~ /\A\d+\Z/ ? type_val.to_i : available_types.find { |t| t['name'] == type_val || t['code'] == type_val }&.dig('id')
111
+ raise_command_error "System type not found: #{type_val}" unless type_id
112
+ system_payload['type'] = {'id' => type_id}
113
+ elsif !options[:no_prompt]
114
+ if available_types.empty?
115
+ raise_command_error "No system types found."
116
+ else
117
+ print cyan, "Available System Types\n", reset
118
+ available_types.each do |t|
119
+ print " #{t['id']}) #{t['name']}#{t['code'] ? " (#{t['code']})" : ''}\n"
120
+ end
121
+ selected = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'type', 'type' => 'text', 'fieldLabel' => 'System Type ID', 'required' => true}], options[:options], @api_client, {})['type']
122
+ type_id = available_types.find { |t| t['id'].to_s == selected.to_s }&.dig('id')
123
+ raise_command_error "Invalid system type id: #{selected}" unless type_id
124
+ system_payload['type'] = {'id' => type_id}
125
+ end
126
+ end
127
+
128
+ # Layout
129
+ available_layouts = system_layouts_for_dropdown(system_payload.dig('type', 'id'))
130
+ layout_val = params['layout']
131
+ if layout_val
132
+ layout_id = layout_val =~ /\A\d+\Z/ ? layout_val.to_i : available_layouts.find { |l| l['name'] == layout_val || l['code'] == layout_val }&.dig('id')
133
+ raise_command_error "System layout not found: #{layout_val}" unless layout_id
134
+ system_payload['layout'] = {'id' => layout_id}
135
+ elsif !options[:no_prompt]
136
+ if available_layouts.empty?
137
+ raise_command_error "No system layouts found for selected type."
138
+ else
139
+ print cyan, "Available System Layouts\n", reset
140
+ available_layouts.each do |l|
141
+ print " #{l['id']}) #{l['name']}#{l['code'] ? " (#{l['code']})" : ''}\n"
142
+ end
143
+ selected = Morpheus::Cli::OptionTypes.prompt([{'fieldName' => 'layout', 'type' => 'text', 'fieldLabel' => 'System Layout ID', 'required' => true}], options[:options], @api_client, {})['layout']
144
+ layout_id = available_layouts.find { |l| l['id'].to_s == selected.to_s }&.dig('id')
145
+ raise_command_error "Invalid system layout id: #{selected}" unless layout_id
146
+ system_payload['layout'] = {'id' => layout_id}
147
+ end
148
+ end
149
+
150
+ payload = {rest_object_key => system_payload}
151
+ end
152
+
153
+ if options[:dry_run]
154
+ print_dry_run rest_interface.dry.create(payload)
155
+ return
156
+ end
157
+
158
+ rest_interface.setopts(options)
159
+ json_response = rest_interface.create(payload)
160
+ render_response(json_response, options, rest_object_key) do
161
+ system_id = json_response['id'] || json_response.dig(rest_object_key, 'id')
162
+ print_green_success "System created"
163
+ get([system_id.to_s] + (options[:remote] ? ['-r', options[:remote]] : [])) if system_id
164
+ end
165
+ end
166
+
167
+ def remove(args)
168
+ options = {}
169
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
170
+ opts.banner = subcommand_usage("[system]")
171
+ build_standard_remove_options(opts, options)
172
+ opts.footer = <<-EOT
173
+ Delete an existing system.
174
+ [system] is required. This is the name or id of a system.
175
+ EOT
176
+ end
177
+ optparse.parse!(args)
178
+ verify_args!(args: args, optparse: optparse, count: 1)
179
+ connect(options)
180
+
181
+ system = nil
182
+ if args[0].to_s =~ /\A\d{1,}\Z/
183
+ json_response = rest_interface.get(args[0].to_i)
184
+ system = json_response[rest_object_key] || json_response
185
+ else
186
+ system = find_by_name(rest_key, args[0])
187
+ end
188
+ return 1, "System not found for '#{args[0]}'" if system.nil?
189
+
190
+ unless options[:yes] || Morpheus::Cli::OptionTypes.confirm("Are you sure you want to delete the system #{system['name']}?")
191
+ return 9, "aborted"
192
+ end
193
+
194
+ if options[:dry_run]
195
+ print_dry_run rest_interface.dry.destroy(system['id'])
196
+ return
197
+ end
198
+
199
+ rest_interface.setopts(options)
200
+ json_response = rest_interface.destroy(system['id'])
201
+ render_response(json_response, options) do
202
+ print_green_success "System #{system['name']} removed"
203
+ end
204
+ end
205
+
206
+ def update(args)
207
+ options = {}
208
+ params = {}
209
+ optparse = Morpheus::Cli::OptionParser.new do |opts|
210
+ opts.banner = subcommand_usage("[system] --name --description")
211
+ opts.on("--name NAME", String, "Updates System Name") do |val|
212
+ params['name'] = val.to_s
213
+ end
214
+ opts.on("--description [TEXT]", String, "Updates System Description") do |val|
215
+ params['description'] = val.to_s
216
+ end
217
+ build_standard_update_options(opts, options, [:find_by_name])
218
+ opts.footer = <<-EOT
219
+ Update an existing system.
220
+ [system] is required. This is the name or id of a system.
221
+ EOT
222
+ end
223
+ optparse.parse!(args)
224
+ verify_args!(args: args, optparse: optparse, count: 1)
225
+ connect(options)
226
+
227
+ system = nil
228
+ if args[0].to_s =~ /\A\d{1,}\Z/
229
+ json_response = rest_interface.get(args[0].to_i)
230
+ system = json_response[rest_object_key] || json_response
231
+ else
232
+ system = find_by_name(rest_key, args[0])
233
+ end
234
+ return 1, "System not found for '#{args[0]}'" if system.nil?
235
+
236
+ passed_options = parse_passed_options(options)
237
+ params.deep_merge!(passed_options) unless passed_options.empty?
238
+ params.booleanize!
239
+
240
+ payload = parse_payload(options) || {rest_object_key => params}
241
+ if payload[rest_object_key].nil? || payload[rest_object_key].empty?
242
+ raise_command_error "Specify at least one option to update.\n#{optparse}"
243
+ end
244
+
245
+ if options[:dry_run]
246
+ print_dry_run rest_interface.dry.update(system['id'], payload)
247
+ return
248
+ end
249
+
250
+ rest_interface.setopts(options)
251
+ json_response = rest_interface.update(system['id'], payload)
252
+ render_response(json_response, options, rest_object_key) do
253
+ print_green_success "Updated system #{system['id']}"
254
+ get([system['id']] + (options[:remote] ? ['-r', options[:remote]] : []))
255
+ end
256
+ end
257
+
258
+ def system_types_for_dropdown
259
+ result = @api_client.system_types.list({'max' => 100})
260
+ items = result ? (result['systemTypes'] || result[:systemTypes] || result['types'] || result[:types] || []) : []
261
+ items.map { |t| {'id' => t['id'] || t[:id], 'name' => t['name'] || t[:name], 'value' => (t['id'] || t[:id]).to_s, 'code' => t['code'] || t[:code]} }
262
+ end
263
+
264
+ def system_layouts_for_dropdown(type_id = nil)
265
+ return [] if type_id.nil?
266
+ result = @api_client.system_types.list_layouts(type_id, {'max' => 100})
267
+ items = result ? (result['systemTypeLayouts'] || result[:systemTypeLayouts] || result['layouts'] || result[:layouts] || []) : []
268
+ items.map { |l| {'id' => l['id'] || l[:id], 'name' => l['name'] || l[:name], 'value' => (l['id'] || l[:id]).to_s, 'code' => l['code'] || l[:code]} }
269
+ end
270
+ end
@@ -3,6 +3,7 @@ require 'morpheus/cli/cli_command'
3
3
  class Morpheus::Cli::TenantsCommand
4
4
  include Morpheus::Cli::CliCommand
5
5
  include Morpheus::Cli::AccountsHelper
6
+ include Morpheus::Cli::WhoamiHelper
6
7
  set_command_name :tenants
7
8
  set_command_description "View and manage tenants (accounts)."
8
9
  register_subcommands :list, :count, :get, :add, :update, :remove
@@ -21,6 +22,7 @@ class Morpheus::Cli::TenantsCommand
21
22
  @account_users_interface = @api_client.account_users
22
23
  @accounts_interface = @api_client.accounts
23
24
  @roles_interface = @api_client.roles
25
+ @whoami_interface = @api_client.whoami
24
26
  end
25
27
 
26
28
  def handle(args)
@@ -286,18 +288,25 @@ EOT
286
288
  [
287
289
  {'fieldName' => 'name', 'fieldLabel' => 'Name', 'type' => 'text', 'required' => true, 'displayOrder' => 1},
288
290
  {'fieldName' => 'description', 'fieldLabel' => 'Description', 'type' => 'text', 'displayOrder' => 2},
289
- {'fieldContext' => 'role', 'fieldName' => 'id', 'fieldLabel' => 'Base Role', 'type' => 'select', 'optionSource' => lambda { |api_client, api_params|
290
- @roles_interface.list(nil, {roleType:'account'})['roles'].collect {|it|
291
+ {'fieldContext' => 'parentAccount', 'fieldName' => 'id', 'fieldLabel' => 'Parent Tenant', 'type' => 'select', 'optionSource' => lambda { |api_client, api_params|
292
+ @accounts_interface.list({max:10000})['accounts'].collect {|it|
291
293
  {"name" => (it["authority"] || it["name"]), "value" => it["id"]}
292
294
  }
293
295
  }, 'displayOrder' => 3},
296
+ {'fieldContext' => 'role', 'fieldName' => 'id', 'fieldLabel' => 'Base Role', 'type' => 'select', 'optionSource' => lambda { |api_client, api_params|
297
+ tenant_id = (api_params['parentAccount']['id'] rescue nil)
298
+ params = {max: 10000, roleType:'account', tenantId: tenant_id}
299
+ @roles_interface.list(nil, params)['roles'].collect {|it|
300
+ {"name" => (it["authority"] || it["name"]), "value" => it["id"]}
301
+ }
302
+ }, 'displayOrder' => 4},
294
303
  {'fieldName' => 'currency', 'fieldLabel' => 'Currency', 'type' => 'text', 'defaultValue' => 'USD', 'displayOrder' => 4}
295
304
  ]
296
305
  end
297
306
 
298
307
  def update_account_option_types
299
308
  list = add_account_option_types()
300
- # list = list.reject {|it| ["interval"].include? it['fieldName'] }
309
+ list = list.reject {|it| it['fieldContext'] == 'parentAccount' }
301
310
  list.each {|it| it.delete('required') }
302
311
  list.each {|it| it.delete('defaultValue') }
303
312
  list
@@ -45,6 +45,7 @@ module Morpheus::Cli::AccountsHelper
45
45
  "# Instances" => 'stats.instanceCount',
46
46
  "# Users" => 'stats.userCount',
47
47
  "Role" => lambda {|it| it['role']['authority'] rescue nil },
48
+ "Parent Tenant" => lambda {|it| it['parent']['name'] rescue nil },
48
49
  "Master" => lambda {|it| format_boolean(it['master']) },
49
50
  "Currency" => 'currency',
50
51
  "Status" => lambda {|it|
@@ -117,6 +117,8 @@ module Morpheus::Cli::BackupsHelper
117
117
  {
118
118
  "ID" => 'id',
119
119
  "Backup" => lambda {|it| it['backup']['name'] rescue '' },
120
+ "Type" => lambda {|it| format_backup_result_type_tag(it) },
121
+ "Location" => lambda {|it| format_backup_result_location_tag(it) },
120
122
  "Status" => lambda {|it| format_backup_result_status(it) },
121
123
  #"Duration" => lambda {|it| format_duration(it['startDate'], it['endDate']) },
122
124
  "Duration" => lambda {|it| format_duration_milliseconds(it['durationMillis']) if it['durationMillis'].to_i > 0 },
@@ -168,5 +170,31 @@ module Morpheus::Cli::BackupsHelper
168
170
  def format_backup_restore_status(backup_restore, return_color=cyan)
169
171
  format_backup_result_status(backup_restore, return_color)
170
172
  end
173
+
174
+ # format backup result type tag based on associated backup data
175
+ def format_backup_result_type_tag(backup_result)
176
+ backup = backup_result['backup'] || {}
177
+ if backup['cronExpression'] || (backup['schedule'] && backup['schedule']['id']) || (backup['job'] && backup['job']['id'])
178
+ "#{cyan}POLICY#{reset}"
179
+ else
180
+ "#{yellow}MANUAL#{reset}"
181
+ end
182
+ end
183
+
184
+ # format backup result location tag based on storage provider
185
+ def format_backup_result_location_tag(backup_result)
186
+ backup = backup_result['backup'] || {}
187
+ if backup_result['storageProvider'] && backup_result['storageProvider']['id']
188
+ "#{green}REMOTE#{reset}"
189
+ elsif backup['storageProvider'] && backup['storageProvider']['id']
190
+ "#{green}REMOTE#{reset}"
191
+ elsif backup_result['backupProvider'] && backup_result['backupProvider']['id']
192
+ "#{green}REMOTE#{reset}"
193
+ elsif backup['backupProvider'] && backup['backupProvider']['id']
194
+ "#{green}REMOTE#{reset}"
195
+ else
196
+ "#{blue}LOCAL#{reset}"
197
+ end
198
+ end
171
199
 
172
- end
200
+ end
@@ -1,6 +1,6 @@
1
1
 
2
2
  module Morpheus
3
3
  module Cli
4
- VERSION = "8.0.13"
4
+ VERSION = "8.1.0"
5
5
  end
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: morpheus-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.13
4
+ version: 8.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Estes
@@ -11,7 +11,7 @@ authors:
11
11
  autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
- date: 2026-01-27 00:00:00.000000000 Z
14
+ date: 2026-03-11 00:00:00.000000000 Z
15
15
  dependencies:
16
16
  - !ruby/object:Gem::Dependency
17
17
  name: tins
@@ -381,6 +381,8 @@ files:
381
381
  - lib/morpheus/api/storage_volumes_interface.rb
382
382
  - lib/morpheus/api/subnet_types_interface.rb
383
383
  - lib/morpheus/api/subnets_interface.rb
384
+ - lib/morpheus/api/system_types_interface.rb
385
+ - lib/morpheus/api/systems_interface.rb
384
386
  - lib/morpheus/api/task_sets_interface.rb
385
387
  - lib/morpheus/api/tasks_interface.rb
386
388
  - lib/morpheus/api/usage_interface.rb
@@ -570,6 +572,7 @@ files:
570
572
  - lib/morpheus/cli/commands/storage_volume_types.rb
571
573
  - lib/morpheus/cli/commands/storage_volumes.rb
572
574
  - lib/morpheus/cli/commands/subnets_command.rb
575
+ - lib/morpheus/cli/commands/systems.rb
573
576
  - lib/morpheus/cli/commands/tasks.rb
574
577
  - lib/morpheus/cli/commands/tee_command.rb
575
578
  - lib/morpheus/cli/commands/tenants_command.rb