aspera-cli 4.26.1 → 4.27.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 (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -4,6 +4,7 @@
4
4
 
5
5
  require 'aspera/cli/plugins/oauth'
6
6
  require 'aspera/cli/extended_value'
7
+ require 'aspera/transfer/result'
7
8
  require 'aspera/cli/special_values'
8
9
  require 'aspera/cli/wizard'
9
10
  require 'aspera/api/faspex'
@@ -18,11 +19,11 @@ module Aspera
18
19
  module Cli
19
20
  module Plugins
20
21
  class Faspex5 < Oauth
21
- class << self
22
- def application_name
23
- 'Faspex'
24
- end
22
+ application_name 'Faspex v5'
23
+ # Commands that may carry a query_schema annotation
24
+ QUERY_SCHEMA_COMMANDS = %i[list delete].freeze
25
25
 
26
+ class << self
26
27
  # @return [Hash,NilClass]
27
28
  def detect(address_or_url)
28
29
  # add scheme if missing
@@ -92,11 +93,12 @@ module Aspera
92
93
  }
93
94
  end
94
95
 
96
+ option :box, description: "Package inbox, either shared inbox name or one of: #{Api::Faspex::API_LIST_MAILBOX_TYPES.join(', ')} or #{SpecialValues::ALL}", default: 'inbox_all'
97
+ option :shared_folder, description: 'Send package with files from shared folder'
98
+ option :group_type, description: 'Type of shared box', allowed: %i[shared_inboxes workgroups], default: :shared_inboxes
99
+
95
100
  def initialize(**_)
96
101
  super
97
- options.declare(:box, "Package inbox, either shared inbox name or one of: #{Api::Faspex::API_LIST_MAILBOX_TYPES.join(', ')} or #{SpecialValues::ALL}", default: 'inbox_all')
98
- options.declare(:shared_folder, 'Send package with files from shared folder')
99
- options.declare(:group_type, 'Type of shared box', allowed: %i[shared_inboxes workgroups], default: :shared_inboxes)
100
102
  options.parse_options!
101
103
  # [Aspera::Api::Faspex]
102
104
  @api_v5 = nil
@@ -136,24 +138,24 @@ module Aspera
136
138
  # user asked to not follow
137
139
  return status if status_list.nil?
138
140
  if status['upload_status'].eql?('submitted')
139
- config.progress_bar&.event(:sessions_init, session_id: nil, info: status['upload_status'])
141
+ progress_bar&.event(:sessions_init, session_id: nil, info: status['upload_status'])
140
142
  elsif !total_sent
141
- config.progress_bar&.event(:session_start, session_id: id)
142
- config.progress_bar&.event(:session_size, session_id: id, info: status['bytes_total'].to_i)
143
+ progress_bar&.event(:session_start, session_id: id)
144
+ progress_bar&.event(:session_size, session_id: id, info: status['bytes_total'].to_i)
143
145
  total_sent = true
144
146
  else
145
- config.progress_bar&.event(:transfer, session_id: id, info: status['bytes_written'].to_i)
147
+ progress_bar&.event(:transfer, session_id: id, info: status['bytes_written'].to_i)
146
148
  end
147
149
  if status_list.include?(status['upload_status'])
148
- config.progress_bar&.event(:session_end, session_id: id)
149
- config.progress_bar&.event(:end)
150
+ progress_bar&.event(:session_end, session_id: id)
151
+ progress_bar&.event(:end)
150
152
  return status
151
153
  end
152
154
  sleep(1.0)
153
155
  end
154
156
  end
155
157
 
156
- # @param [String] job identifier
158
+ # @param job_id [String] job identifier
157
159
  # @return [Hash] result of API call for job status
158
160
  def wait_for_job(job_id)
159
161
  result = nil
@@ -213,10 +215,10 @@ module Aspera
213
215
  packages = []
214
216
  case package_ids
215
217
  when SpecialValues::INIT
216
- Aspera.assert(skip_ids_persistency){'Only with option once_only'}
218
+ Aspera.assert(skip_ids_persistency, 'Only with option once_only')
217
219
  skip_ids_persistency.data.clear.concat(list_packages_with_filter.first.map{ |p| p['id']})
218
220
  skip_ids_persistency.save
219
- return Main.result_status("Initialized skip for #{skip_ids_persistency.data.count} package(s)")
221
+ return Result::Status.new("Initialized skip for #{skip_ids_persistency.data.count} package(s)")
220
222
  when SpecialValues::ALL
221
223
  # TODO: if packages have same name, they will overwrite ?
222
224
  packages = list_packages_with_filter(query: {'status' => 'completed'}).first
@@ -259,18 +261,17 @@ module Aspera
259
261
  # delete flag for Connect Client
260
262
  transfer_spec.delete('authentication')
261
263
  statuses = transfer.start(transfer_spec)
262
- result_transfer.push({'package' => pkg_id, Main::STATUS_FIELD => statuses})
264
+ result_transfer.push({'package' => pkg_id, Runner::STATUS_FIELD => statuses})
263
265
  # skip only if all sessions completed
264
- if TransferAgent.session_status(statuses).eql?(:success) && skip_ids_persistency
266
+ if statuses.is_a?(Transfer::Result::Success) && skip_ids_persistency
265
267
  skip_ids_persistency.data.push(pkg_id)
266
268
  skip_ids_persistency.save
267
269
  end
268
270
  end
269
- return Main.result_transfer_multiple(result_transfer)
271
+ return Runner.result_transfer_multiple(result_transfer)
270
272
  end
271
273
 
272
- def package_send
273
- parameters = value_create_modify(command: :send, schema: Schema::Registry.req_body(Schema::Registry::FASPEX, 'packages.post'))
274
+ def package_send(parameters)
274
275
  # autofill recipient for public url
275
276
  if @api_v5.pub_link_context&.key?('recipient_type') && !parameters.key?('recipients')
276
277
  parameters['recipients'] = [{
@@ -296,10 +297,10 @@ module Aspera
296
297
  )
297
298
  # well, we asked a TS for connect, but we actually want a generic one
298
299
  transfer_spec.delete('authentication')
299
- return Main.result_transfer(transfer.start(transfer_spec))
300
+ return Runner.result_transfer(transfer.start(transfer_spec))
300
301
  else
301
302
  # send from remote shared folder
302
- if (m = Manager.percent_selector(shared_folder))
303
+ if (m = Parser.percent_selector(shared_folder))
303
304
  shared_folder = @api_v5.lookup_entity_by_field(
304
305
  entity: 'shared_folders',
305
306
  field: m[:field],
@@ -314,14 +315,15 @@ module Aspera
314
315
  formatter.display_status("Package #{package['id']}")
315
316
  result = wait_package_status(package['id'])
316
317
  end
317
- return Main.result_single_object(result)
318
+ return Result::SingleObject.new(result)
318
319
  end
319
320
  end
320
321
 
321
322
  # Browse a folder
322
323
  # @param browse_endpoint [String] the endpoint to browse
323
- def browse_folder(browse_endpoint, base_query = {})
324
- folders_to_process = [options.get_next_argument('folder path', default: '/')]
324
+ # @param folder_path [String] starting path (default: '/')
325
+ def browse_folder(browse_endpoint, base_query = {}, folder_path: '/')
326
+ folders_to_process = [folder_path]
325
327
  query = base_query.merge(query_read_delete(default: {}))
326
328
  filters = query.delete('filters'){{}}
327
329
  Aspera.assert_type(filters, Hash)
@@ -371,357 +373,603 @@ module Aspera
371
373
  query.delete('iteration_token')
372
374
  end
373
375
  RestParameters.instance.spinner_cb.call(action: :success)
374
- return Main.result_object_list(all_items, total: total_count)
376
+ return Result::ObjectList.new(all_items, total: total_count)
375
377
  end
376
378
 
377
- def package_action
378
- command = options.get_next_command(%i[show browse status delete receive send list])
379
- package_id =
380
- if %i[receive show browse status delete].include?(command)
381
- @api_v5.pub_link_context&.key?('package_id') ? @api_v5.pub_link_context['package_id'] : options.instance_identifier
382
- end
383
- case command
384
- when :show
385
- return Main.result_single_object(@api_v5.read("packages/#{package_id}"))
386
- when :browse
387
- return browse_folder("packages/#{package_id}/files/#{Api::Faspex.box_type(options.get_option(:box))}", recipient_query(package_id))
388
- when :status
389
- status_list = options.get_next_argument('list of states, or nothing', mandatory: false, validation: Array)
390
- status = wait_package_status(package_id, status_list: status_list)
391
- return Main.result_single_object(status)
392
- when :delete
393
- ids = package_id
394
- ids = [ids] unless ids.is_a?(Array)
395
- Aspera.assert_array_all(ids, String){'Package id(s)'}
396
- # API returns 204, empty on success
397
- @api_v5.call(
398
- operation: 'DELETE',
399
- subpath: 'packages',
400
- content_type: Mime::JSON,
401
- body: {ids: ids},
402
- headers: {'Accept' => Mime::JSON}
403
- )
404
- return Main.result_status('Package(s) deleted')
405
- when :receive
406
- return package_receive(package_id)
407
- when :send
408
- return package_send
409
- when :list
410
- list, total = list_packages_with_filter
411
- fields = %w[id title status sender.name recipients.0.name release_date total_bytes total_files]
412
- fields.delete('recipients.0.name') if %w[inbox inbox_history].include?(options.get_option(:box))
413
- fields.delete('sender.name') if %w[outbox outbox_history].include?(options.get_option(:box))
414
- return Main.result_object_list(list, total: total, fields: fields)
415
- end
379
+ # Per-resource configuration for admin CRUD sub-trees.
380
+ # Keys mirror entity_execute kwargs; extra_commands lists additional leaf commands.
381
+ # @return [Hash{Symbol => Hash}]
382
+ RESOURCE_CONFIG = {
383
+ accounts: {
384
+ display_fields: ->{Formatter.all_but('user_profile_data_attributes')},
385
+ extra_commands: [:reset_password],
386
+ instance_arg_commands: {reset_password: {arguments: [{name: :contact_id, type: :identifier, lookup: :lookup_accounts_id}]}},
387
+ query_component: Schema::Registry::FASPEX
388
+ },
389
+ alternate_addresses: {entity: 'configuration/alternate_addresses', query_component: Schema::Registry::FASPEX},
390
+ contacts: {query_component: Schema::Registry::FASPEX},
391
+ distribution_lists: {entity: 'account/distribution_lists', delete_style: 'ids', query_component: Schema::Registry::FASPEX},
392
+ email_notifications: {id_as_arg: 'type', query_component: Schema::Registry::FASPEX},
393
+ file_processing: {
394
+ commands: %i[next modify],
395
+ schema: ->{Schema::Registry.req_body(Schema::Registry::FASPEX, 'file_processing.put')},
396
+ is_singleton: true
397
+ },
398
+ jobs: {display_fields: %w[id job_name job_type status], query_component: Schema::Registry::FASPEX},
399
+ metadata_profiles: {entity: 'configuration/metadata_profiles', items_key: 'profiles', query_component: Schema::Registry::FASPEX},
400
+ nodes: {
401
+ extra_commands: %i[browse],
402
+ instance_arg_commands: {
403
+ browse: {
404
+ arguments: [{name: :node_id, type: :identifier, lookup: :lookup_node_id},
405
+ {name: :folder_path, type: String, mandatory: false, default: '/'}]
406
+ }
407
+ },
408
+ query_component: Schema::Registry::FASPEX
409
+ },
410
+ oauth_clients: {
411
+ display_fields: ->{Formatter.all_but('public_key')},
412
+ api: ->{Api::Faspex.new(root: Api::Faspex::PATH_AUTH, **Oauth.kwargs_from_options(options))},
413
+ list_query: {'expand': true, 'no_api_path': true, 'client_types[]': 'public'},
414
+ query_component: Schema::Registry::FASPEX
415
+ },
416
+ registrations: {query_component: Schema::Registry::FASPEX},
417
+ saml_configs: {query_component: Schema::Registry::FASPEX},
418
+ shared_inboxes: {res_id_query: {'all': true}, query_component: Schema::Registry::FASPEX},
419
+ webhooks: {query_component: Schema::Registry::FASPEX},
420
+ workgroups: {res_id_query: {'all': true}, query_component: Schema::Registry::FASPEX}
421
+ }.freeze
422
+ private_constant :RESOURCE_CONFIG
423
+
424
+ # Resolve a RESOURCE_CONFIG value that may be a Proc (evaluated in instance context).
425
+ def resource_config_value(cfg, key)
426
+ v = cfg[key]
427
+ v.is_a?(Proc) ? instance_exec(&v) : v
416
428
  end
417
429
 
418
- def execute_resource(res_sym)
419
- exec_args = {
420
- api: @api_v5,
421
- entity: res_sym.to_s
422
- }
423
- res_id_query = :default
424
- available_commands = Operations::ALL
425
- case res_sym
426
- when :metadata_profiles
427
- exec_args[:entity] = 'configuration/metadata_profiles'
428
- exec_args[:items_key] = 'profiles'
429
- when :alternate_addresses
430
- exec_args[:entity] = 'configuration/alternate_addresses'
431
- when :distribution_lists
432
- exec_args[:entity] = 'account/distribution_lists'
433
- exec_args[:delete_style] = 'ids'
434
- when :email_notifications
435
- exec_args.delete(:items_key)
436
- exec_args[:id_as_arg] = 'type'
437
- when :accounts
438
- exec_args[:display_fields] = Formatter.all_but('user_profile_data_attributes')
439
- available_commands += [:reset_password]
440
- when :oauth_clients
441
- exec_args[:display_fields] = Formatter.all_but('public_key')
442
- exec_args[:api] = Api::Faspex.new(root: Api::Faspex::PATH_AUTH, **Oauth.kwargs_from_options(options))
443
- exec_args[:list_query] = {'expand': true, 'no_api_path': true, 'client_types[]': 'public'}
444
- when :shared_inboxes, :workgroups
445
- available_commands += %i[members saml_groups invite_external_collaborator]
446
- res_id_query = {'all': true}
447
- when :nodes
448
- available_commands += %i[shared_folders browse]
449
- when :jobs
450
- exec_args[:display_fields] = %w[id job_name job_type status]
430
+ # Build exec_args hash for entity_execute from RESOURCE_CONFIG for the given resource.
431
+ def res_exec_args(res_sym)
432
+ cfg = RESOURCE_CONFIG.fetch(res_sym, {})
433
+ {
434
+ api: resource_config_value(cfg, :api) || @api_v5,
435
+ entity: resource_config_value(cfg, :entity) || res_sym.to_s,
436
+ items_key: resource_config_value(cfg, :items_key),
437
+ delete_style: resource_config_value(cfg, :delete_style),
438
+ id_as_arg: resource_config_value(cfg, :id_as_arg) || false,
439
+ display_fields: resource_config_value(cfg, :display_fields),
440
+ list_query: resource_config_value(cfg, :list_query),
441
+ is_singleton: resource_config_value(cfg, :is_singleton) || false,
442
+ schema: resource_config_value(cfg, :schema),
443
+ query_component: resource_config_value(cfg, :query_component)
444
+ }.compact
445
+ end
446
+
447
+ # Lookup methods for arguments: + lookup: on specific nodes
448
+
449
+ # admin > nodes — lookup node id by field/value
450
+ def lookup_node_id(field, value, **)
451
+ @api_v5.lookup_entity_by_field(entity: 'nodes', field: field, value: value)['id']
452
+ end
453
+
454
+ # admin > shared_inboxes — lookup id by field/value
455
+ def lookup_shared_inboxes_id(field, value, **)
456
+ @api_v5.lookup_entity_by_field(entity: 'shared_inboxes', field: field, value: value, query: {'all': true})['id']
457
+ end
458
+
459
+ # admin > workgroups — lookup id by field/value
460
+ def lookup_workgroups_id(field, value, **)
461
+ @api_v5.lookup_entity_by_field(entity: 'workgroups', field: field, value: value, query: {'all': true})['id']
462
+ end
463
+
464
+ # admin > accounts — lookup id by field/value (used for reset_password arguments:)
465
+ def lookup_accounts_id(field, value, **)
466
+ res_lookup_id(:accounts, field, value)
467
+ end
468
+
469
+ # Lookup id for a RESOURCE_CONFIG resource by field/value.
470
+ def res_lookup_id(res_sym, field, value)
471
+ cfg = RESOURCE_CONFIG.fetch(res_sym, {})
472
+ entity = resource_config_value(cfg, :entity) || res_sym.to_s
473
+ items_key = resource_config_value(cfg, :items_key)
474
+ res_id_query = resource_config_value(cfg, :res_id_query) || :default
475
+ @api_v5.lookup_entity_by_field(entity: entity, value: value, field: field, items_key: items_key, query: res_id_query)['id']
476
+ end
477
+
478
+ # --- DSL ---
479
+
480
+ # Commands that need @api_v5 carry setup: :setup_api_v5.
481
+ # :health and :postprocessing work without authentication, so they have no setup.
482
+ command :health, description: 'Check Faspex 5 health'
483
+ command :version, description: 'Show Faspex 5 version', setup: :setup_api_v5, action: ->{Result::SingleObject.new(@api_v5.read('version'))}
484
+ command :bearer_token, description: 'Show OAuth bearer token', setup: :setup_api_v5, action: ->{Result::Text.new(@api_v5.oauth.authorization)}
485
+ command :packages, description: 'Manage packages', setup: :setup_api_v5
486
+ commands_under(:packages) do
487
+ command :list, description: 'List packages'
488
+ command :send, description: 'Send a package', transfer_paths: :send,
489
+ arguments: [{name: :data, type: Hash, schema: Schema::Registry.req_body(Schema::Registry::FASPEX, 'packages.post')}],
490
+ action: ->(data:, **){package_send(data)}
491
+ command :show, description: 'Show a package', setup: :setup_package_id,
492
+ action: ->(package_id:, **){Result::SingleObject.new(@api_v5.read("packages/#{package_id}"))}
493
+ command :browse, description: 'Browse package files', setup: :setup_package_id,
494
+ arguments: [{name: :folder_path, type: String, mandatory: false, default: '/'}],
495
+ action: ->(folder_path:, package_id:, **){browse_folder("packages/#{package_id}/files/#{Api::Faspex.box_type(options.get_option(:box))}", recipient_query(package_id), folder_path: folder_path)}
496
+ command(
497
+ :status, description: 'Wait for package status', setup: :setup_package_id,
498
+ arguments: [{name: :status_list, type: Array, mandatory: false, default: nil}],
499
+ action: ->(status_list:, package_id:, **){Result::SingleObject.new(wait_package_status(package_id, status_list: status_list))}
500
+ )
501
+ command :delete, description: 'Delete package(s)', setup: :setup_package_id
502
+ command :receive, description: 'Receive a package', setup: :setup_package_id, transfer_paths: :receive,
503
+ action: ->(package_id:, **){package_receive(package_id)}
504
+ command(:file_processing, description: 'Show file processing status', setup: :setup_package_id, action: lambda do |package_id:, **|
505
+ result, count = @api_v5.list_entities_limit_offset_total_count(entity: "packages/#{package_id}/file_statuses", items_key: 'files')
506
+ Result::ObjectList.new(result, total: count)
507
+ end)
508
+ end
509
+ command :admin, description: 'Administer Faspex 5', setup: :setup_api_v5
510
+ command :user, description: 'Manage current user', setup: :setup_api_v5
511
+ command :shared_folders, description: 'Browse shared folders', setup: :setup_api_v5
512
+ command :gateway, description: 'Start Faspex 4 gateway emulation', setup: :setup_api_v5,
513
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}]
514
+ command :postprocessing, description: 'Start Faspex 4 post-processing server',
515
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}}]
516
+ command :invitations, description: 'Manage invitations', setup: :setup_api_v5
517
+
518
+ commands_under(:invitations) do
519
+ command :create, description: 'Create an invitation',
520
+ arguments: [{name: :input_data, type: Hash, bulk: true}]
521
+ command :resend, description: 'Resend an invitation',
522
+ arguments: [{name: :invitation_id, type: :identifier}]
523
+ Operations::ALL.reject{ |op| op == :create}.each do |op|
524
+ command(op, description: "#{op.capitalize} invitation(s)")
451
525
  end
452
- res_command = options.get_next_command(available_commands)
453
- return Main.result_value_list(Api::Faspex::EMAIL_NOTIF_LIST, name: 'email_id') if res_command.eql?(:list) && res_sym.eql?(:email_notifications)
454
- case res_command
455
- when :create, :modify, :delete, :show
456
- return entity_execute(command: res_command, **exec_args) do |field, value|
457
- @api_v5.lookup_entity_by_field(entity: exec_args[:entity], value: value, field: field, items_key: exec_args[:items_key], query: res_id_query)['id']
458
- end
459
- when :list
460
- data, total = exec_args[:api].list_entities_limit_offset_total_count(entity: exec_args[:entity], items_key: exec_args[:items_key], query: query_read_delete(default: exec_args[:list_query]))
461
- return Main.result_object_list(data, total: total, fields: exec_args[:display_fields])
462
- when :shared_folders
463
- # nodes
464
- node_id = options.instance_identifier do |field, value|
465
- @api_v5.lookup_entity_by_field(entity: 'nodes', field: field, value: value)['id']
526
+ end
527
+
528
+ commands_under(:user) do
529
+ command :account, description: 'Show account information', action: ->{Result::SingleObject.new(@api_v5.read('account', query_read_delete))}
530
+ command :profile, description: 'Manage user profile'
531
+ end
532
+
533
+ commands_under(%i[user profile]) do
534
+ command :show, description: 'Show user profile', action: ->{Result::SingleObject.new(@api_v5.read('account/preferences'))}
535
+ command(
536
+ :modify, description: 'Modify user profile',
537
+ arguments: [{name: :properties, type: Hash}],
538
+ action: lambda do |properties:, **|
539
+ @api_v5.update('account/preferences', properties)
540
+ Result::Status.new('modified')
466
541
  end
467
- shfld_entity = "nodes/#{node_id}/shared_folders"
468
- sh_command = options.get_next_command(Operations::ALL + [:user])
469
- case sh_command
470
- when *Operations::ALL
471
- return entity_execute(
472
- api: @api_v5,
473
- entity: shfld_entity,
474
- command: sh_command
475
- ) do |field, value|
476
- @api_v5.lookup_entity_by_field(entity: shfld_entity, field: field, value: value)['id']
477
- end
478
- when :user
479
- sh_id = options.instance_identifier do |field, value|
480
- @api_v5.lookup_entity_by_field(entity: shfld_entity, field: field, value: value)['id']
481
- end
482
- user_path = "#{shfld_entity}/#{sh_id}/custom_access_users"
483
- return entity_execute(api: @api_v5, entity: user_path, items_key: 'users') do |field, value|
484
- @api_v5.lookup_entity_by_field(entity: user_path, items_key: 'users', field: field, value: value)['id']
485
- end
542
+ )
543
+ end
486
544
 
545
+ commands_under(:shared_folders) do
546
+ command :list, description: 'List shared folders', action: ->{Result::ObjectList.new(@api_v5.read('shared_folders')['shared_folders'])}
547
+ command :browse, description: 'Browse a shared folder',
548
+ arguments: [{name: :shared_folder_id, type: :identifier, lookup: :lookup_shared_folder_id},
549
+ {name: :folder_path, type: String, mandatory: false, default: '/'}]
550
+ end
551
+
552
+ # admin sub-tree: fixed commands + all ADMIN_RESOURCES with their sub-commands
553
+ commands_under(:admin) do
554
+ command :configuration, description: 'Manage Faspex 5 configuration'
555
+ command :smtp, description: 'Manage SMTP configuration'
556
+ command :events, description: 'List events'
557
+ command :clean_deleted, description: 'Clean deleted packages',
558
+ arguments: [{name: :input_data, type: Hash, mandatory: false, default: {}}]
559
+ Api::Faspex::ADMIN_RESOURCES.each do |res|
560
+ cfg = RESOURCE_CONFIG.fetch(res, {})
561
+ extra = cfg[:extra_commands] || []
562
+ cmds = cfg[:commands] || (Operations::ALL + extra)
563
+ ia_cmds = cfg[:instance_arg_commands] || {}
564
+ is_singleton = cfg[:is_singleton] || false
565
+ schema_val = cfg[:schema]
566
+ command(res, description: "Manage #{res.to_s.tr('_', ' ')}")
567
+ commands_under([:admin, res]) do
568
+ cmds.each do |c|
569
+ ia = ia_cmds[c] || {}
570
+ extra_args =
571
+ if !is_singleton && c.eql?(:create)
572
+ [{name: :input_data, type: Hash, bulk: true, schema: schema_val}]
573
+ elsif c.eql?(:modify)
574
+ [{name: :input_data, type: Hash, schema: schema_val}]
575
+ else
576
+ []
577
+ end
578
+ # Merge arguments: from ia (e.g. identifier spec) with extra_args for this operation
579
+ merged_args = Array(ia[:arguments]) + extra_args
580
+ spec_kwargs = merged_args.empty? ? ia.except(:arguments) : ia.except(:arguments).merge(arguments: merged_args)
581
+ # Attach query_schema (full path) to :list/:delete CommandSpec so --help shows the tip.
582
+ # cfg[:query_component] is always a String constant so direct read is safe here (no Proc).
583
+ if QUERY_SCHEMA_COMMANDS.include?(c) && cfg[:query_component]
584
+ entity_path = cfg[:entity] || res.to_s
585
+ spec_kwargs = spec_kwargs.merge(
586
+ query_schema: Schema::Registry.query_params(cfg[:query_component], entity_path)
587
+ )
588
+ end
589
+ command(c, description: c.to_s.tr('_', ' ').capitalize, **spec_kwargs)
590
+ end
487
591
  end
488
- when :browse
489
- # nodes
490
- node_id = options.instance_identifier do |field, value|
491
- @api_v5.lookup_entity_by_field(entity: 'nodes', value: value, field: field)['id']
592
+ end
593
+ end
594
+
595
+ commands_under(%i[admin nodes]) do
596
+ command :shared_folders, description: 'Manage shared folders',
597
+ arguments: [{name: :node_id, type: :identifier, lookup: :lookup_node_id}],
598
+ setup: :setup_admin_nodes_shared_folders
599
+ end
600
+
601
+ # admin > nodes > shared_folders sub-tree
602
+ commands_under(%i[admin nodes shared_folders]) do
603
+ Operations::ALL.each{ |c| command(c, description: c.to_s.tr('_', ' ').capitalize)}
604
+ command :user, description: 'Custom access users',
605
+ arguments: [{name: :sf_id, type: :identifier, lookup: :lookup_sf_id}],
606
+ setup: :setup_admin_nodes_shared_folders_user
607
+ end
608
+
609
+ # admin > nodes > shared_folders > user sub-tree (custom access users)
610
+ commands_under(%i[admin nodes shared_folders user]) do
611
+ Operations::ALL.each{ |c| command(c, description: c.to_s.tr('_', ' ').capitalize)}
612
+ end
613
+
614
+ MEMBER_SUBS = %i[members saml_groups].freeze
615
+ CRUD_NO_SHOW = %i[create list modify delete].freeze
616
+ CRUD_NO_LIST = %i[create modify delete show].freeze
617
+
618
+ # admin > shared_inboxes|workgroups > members|saml_groups|invite_external_collaborator:
619
+ # res_id consumed via arguments:(:identifier) + lookup:, builds res_instance_path for all children
620
+ %i[shared_inboxes workgroups].each do |res|
621
+ MEMBER_SUBS.each do |sub|
622
+ commands_under([:admin, res]) do
623
+ command sub, description: sub.to_s.tr('_', ' ').capitalize,
624
+ arguments: [{name: :res_id, type: :identifier, lookup: :"lookup_#{res}_id"}],
625
+ setup: :"setup_admin_#{res}_instance"
492
626
  end
493
- return browse_folder("nodes/#{node_id}/browse")
494
- when :invite_external_collaborator
495
- # :shared_inboxes, :workgroups
496
- shared_inbox_id = options.instance_identifier{ |field, value| @api_v5.lookup_entity_by_field(entity: res_sym.to_s, field: field, value: value, query: res_id_query)['id']}
497
- creation_payload = value_create_modify(command: res_command)
498
- result = @api_v5.create("#{res_sym}/#{shared_inbox_id}/external_collaborator", creation_payload)
499
- formatter.display_status(result['message'])
500
- result = @api_v5.lookup_entity_by_field(
501
- entity: "#{res_sym}/#{shared_inbox_id}/members",
502
- items_key: 'members',
503
- value: creation_payload['email_address'],
504
- query: {}
505
- )
506
- return Main.result_single_object(result)
507
- when :members, :saml_groups
508
- # res_command := :shared_inboxes, :workgroups
509
- res_id = options.instance_identifier{ |field, value| @api_v5.lookup_entity_by_field(entity: res_sym.to_s, field: field, value: value, query: res_id_query)['id']}
510
- res_path = "#{res_sym}/#{res_id}/#{res_command}"
511
- list_key = res_command.to_s
512
- list_key = 'groups' if res_command.eql?(:saml_groups)
513
- sub_command = options.get_next_command(%i[create list modify delete])
514
- if sub_command.eql?(:create) && res_command.eql?(:members)
515
- # first arg is one user name or list of users
516
- users = options.get_next_argument('user id, %name:, or Array')
517
- users = [users] unless users.is_a?(Array)
518
- users = users.map do |user|
519
- if (m = Manager.percent_selector(user))
520
- @api_v5.lookup_entity_by_field(
521
- entity: 'accounts',
522
- field: m[:field],
523
- value: m[:value],
524
- query: Rest.php_style({type: ACCOUNT_TYPES})
525
- )['id']
627
+ commands_under([:admin, res, sub]) do
628
+ CRUD_NO_SHOW.each do |c|
629
+ args = if c.eql?(:create) && sub.eql?(:members)
630
+ {arguments: [{name: :users, bulk: true}, {name: :access, mandatory: false, default: :standard}]}
526
631
  else
527
- # it's the user id (not member id...)
528
- user
632
+ {}
529
633
  end
634
+ command(c, description: c.to_s.capitalize, **args)
530
635
  end
531
- access = options.get_next_argument('level', mandatory: false, accept_list: SHARED_INBOX_MEMBER_LEVELS, default: :standard)
532
- options.unshift_next_argument({user: users.map{ |u| {id: u, access: access}}})
533
636
  end
534
- # TODO: test SAML group
535
- return entity_execute(
536
- api: @api_v5,
537
- entity: res_path,
538
- command: sub_command,
539
- items_key: list_key
540
- ) do |field, value|
541
- @api_v5.lookup_entity_by_field(
542
- entity: res_path,
543
- field: field,
544
- value: value,
545
- query: Rest.php_style({type: %w[user]})
546
- )['user_id']
547
- end
548
- when :reset_password
549
- # :accounts
550
- contact_id = options.instance_identifier{ |field, value| @api_v5.lookup_entity_by_field(entity: 'accounts', field: field, value: value, query: res_id_query)['id']}
551
- @api_v5.create("accounts/#{contact_id}/reset_password", {})
552
- return Main.result_status('password reset, user shall check email')
553
637
  end
554
- Aspera.error_unreachable_line
555
- end
556
-
557
- def execute_admin
558
- command = options.get_next_command(%i[configuration smtp events clean_deleted].concat(Api::Faspex::ADMIN_RESOURCES).freeze)
559
- case command
560
- when *Api::Faspex::ADMIN_RESOURCES
561
- return execute_resource(command)
562
- when :clean_deleted
563
- delete_data = value_create_modify(command: command, default: {})
564
- delete_data = @api_v5.read('configuration').slice('days_before_deleting_package_records') if delete_data.empty?
565
- res = @api_v5.create('internal/packages/clean_deleted', delete_data)
566
- return Main.result_single_object(res)
567
- when :events
568
- event_type = options.get_next_command(%i[application webhook])
569
- case event_type
570
- when :application
571
- list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'application_events', query: query_read_delete)
572
- return Main.result_object_list(list, total: total, fields: %w[event_type created_at application user.name])
573
- when :webhook
574
- list, total = @api_v5.list_entities_limit_offset_total_count(
575
- entity: 'all_webhooks_events',
576
- query: query_read_delete,
577
- items_key: 'events'
638
+ commands_under([:admin, res]) do
639
+ command :invite_external_collaborator, description: 'Invite external collaborator',
640
+ arguments: [{name: :res_id, type: :identifier, lookup: :"lookup_#{res}_id"},
641
+ {name: :input_data, type: Hash}]
642
+ end
643
+ end
644
+
645
+ commands_under(%i[admin configuration]) do
646
+ command :show, description: 'Show configuration', action: ->{Result::SingleObject.new(@api_v5.read('configuration'))}
647
+ command(
648
+ :modify, description: 'Modify configuration',
649
+ arguments: [{name: :input_data, type: Hash}],
650
+ action: ->(input_data:, **){Result::SingleObject.new(@api_v5.update('configuration', input_data))}
651
+ )
652
+ end
653
+
654
+ commands_under(%i[admin smtp]) do
655
+ command :show, description: 'Show SMTP configuration', action: ->{Result::SingleObject.new(@api_v5.read('configuration/smtp'))}
656
+ command(
657
+ :create, description: 'Create SMTP configuration',
658
+ arguments: [{name: :input_data, type: Hash}],
659
+ action: ->(input_data:, **){Result::SingleObject.new(@api_v5.create('configuration/smtp', input_data))}
660
+ )
661
+ command(
662
+ :modify, description: 'Modify SMTP configuration',
663
+ arguments: [{name: :input_data, type: Hash}],
664
+ action: ->(input_data:, **){Result::SingleObject.new(@api_v5.update('configuration/smtp', input_data))}
665
+ )
666
+ command(:delete, description: 'Delete SMTP configuration', action: lambda do
667
+ @api_v5.delete('configuration/smtp')
668
+ Result::Status.new('SMTP configuration deleted')
669
+ end)
670
+ command :test, description: 'Test SMTP configuration',
671
+ arguments: [{name: :test_data, type: nil}]
672
+ end
673
+
674
+ commands_under(%i[admin events]) do
675
+ command(:application, description: 'List application events', action: lambda do
676
+ list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'application_events', query: query_read_delete)
677
+ Result::ObjectList.new(list, total: total, fields: %w[event_type created_at application user.name])
678
+ end)
679
+ command(:webhook, description: 'List webhook events', action: lambda do
680
+ list, total = @api_v5.list_entities_limit_offset_total_count(entity: 'all_webhooks_events', query: query_read_delete, items_key: 'events')
681
+ Result::ObjectList.new(list, total: total)
682
+ end)
683
+ end
684
+
685
+ # admin > clean_deleted handler (leaf, no sub-commands)
686
+ define_action_method(%i[admin clean_deleted]) do |input_data: {}, **|
687
+ input_data = @api_v5.read('configuration').slice('days_before_deleting_package_records') if input_data.empty?
688
+ Result::SingleObject.new(@api_v5.create('internal/packages/clean_deleted', input_data))
689
+ end
690
+
691
+ # admin > <resource> > list
692
+ Api::Faspex::ADMIN_RESOURCES.each do |res|
693
+ define_action_method([:admin, res, :list]) do
694
+ args = res_exec_args(res)
695
+ # Special case: email_notifications list returns a fixed value list
696
+ next Result::ValueList.new(Api::Faspex::EMAIL_NOTIF_LIST, name: 'email_id') if res.eql?(:email_notifications)
697
+ data, total = args[:api].list_entities_limit_offset_total_count(
698
+ entity: args[:entity], items_key: args[:items_key],
699
+ query: query_read_delete(
700
+ default: args[:list_query],
701
+ schema: args[:query_component] ? Schema::Registry.query_params(args[:query_component], args[:entity]) : nil
578
702
  )
579
- return Main.result_object_list(list, total: total)
580
- end
581
- when :configuration
582
- conf_path = 'configuration'
583
- conf_cmd = options.get_next_command(%i[show modify])
584
- case conf_cmd
585
- when :show
586
- return Main.result_single_object(@api_v5.read(conf_path))
587
- when :modify
588
- return Main.result_single_object(@api_v5.update(conf_path, value_create_modify(command: conf_cmd)))
589
- end
590
- when :smtp
591
- # only one SMTP config
592
- smtp_path = 'configuration/smtp'
593
- smtp_cmd = options.get_next_command(%i[show create modify delete test])
594
- case smtp_cmd
595
- when :show
596
- return Main.result_single_object(@api_v5.read(smtp_path))
597
- when :create
598
- return Main.result_single_object(@api_v5.create(smtp_path, value_create_modify(command: smtp_cmd)))
599
- when :modify
600
- return Main.result_single_object(@api_v5.update(smtp_path, value_create_modify(command: smtp_cmd)))
601
- when :delete
602
- @api_v5.delete(smtp_path)
603
- return Main.result_status('SMTP configuration deleted')
604
- when :test
605
- test_data = options.get_next_argument('Email or test data, see API')
606
- test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
607
- creation = @api_v5.create(File.join(smtp_path, 'test'), test_data)
608
- result = wait_for_job(creation['job_id'])
609
- result['serialized_args'] = JSON.parse(result['serialized_args']) rescue result['serialized_args']
610
- return Main.result_single_object(result)
703
+ )
704
+ Result::ObjectList.new(data, total: total, fields: args[:display_fields])
705
+ end
706
+ end
707
+
708
+ # admin > <resource> > create / modify / delete / show
709
+ Api::Faspex::ADMIN_RESOURCES.each do |res|
710
+ CRUD_NO_LIST.each do |op|
711
+ define_action_method([:admin, res, op]) do |input_data: nil, res_id: nil, **|
712
+ args = res_exec_args(res)
713
+ is_bulk = options.get_option(:bulk)
714
+ items = input_data
715
+ items = [items] if items && !items.is_a?(Array)
716
+ entity_execute(command: op, input_data: items, res_id: res_id, is_bulk: is_bulk, bfail: options.get_option(:bfail), **args){ |f, v| res_lookup_id(res, f, v)}
611
717
  end
612
718
  end
613
719
  end
614
720
 
615
- ACTIONS = %i[health version user bearer_token packages shared_folders admin gateway postprocessing invitations].freeze
721
+ def action_admin_smtp_test(test_data:, **)
722
+ test_data = {test_email_recipient: test_data} if test_data.is_a?(String)
723
+ creation = @api_v5.create('configuration/smtp/test', test_data)
724
+ result = wait_for_job(creation['job_id'])
725
+ begin
726
+ result['serialized_args'] = JSON.parse(result['serialized_args'])
727
+ rescue JSON::ParserError
728
+ # keep as string if not valid JSON
729
+ end
730
+ Result::SingleObject.new(result)
731
+ end
732
+
733
+ # admin > accounts > reset_password
734
+ def action_admin_accounts_reset_password(contact_id:, **)
735
+ @api_v5.create("accounts/#{contact_id}/reset_password", {})
736
+ Result::Status.new('password reset, user shall check email')
737
+ end
616
738
 
617
- def execute_action
618
- command = options.get_next_command(ACTIONS)
619
- unless %i{postprocessing health}.include?(command)
620
- # create an API object with the same options, but with a different subpath
621
- @api_v5 = Api::Faspex.new(**Oauth.kwargs_from_options(options))
622
- # in case user wants to use HTTPGW tell transfer agent how to get address
623
- transfer.httpgw_url_cb = lambda{@api_v5.read('account')['gateway_url']}
739
+ # admin > file_processing > next
740
+ def action_admin_file_processing_next
741
+ args = res_exec_args(:file_processing)
742
+ result, count = @api_v5.list_entities_limit_offset_total_count(entity: args[:entity], operation: 'POST', items_key: 'files')
743
+ Result::ObjectList.new(result, total: count)
744
+ end
745
+
746
+ # admin > nodes > browse
747
+ def action_admin_nodes_browse(folder_path:, node_id:, **)
748
+ browse_folder("nodes/#{node_id}/browse", {}, folder_path: folder_path)
749
+ end
750
+
751
+ # admin > nodes > shared_folders — node_id: already in ctx via arguments: on the :shared_folders command
752
+ def setup_admin_nodes_shared_folders(node_id:, **)
753
+ {sf_entity: "nodes/#{node_id}/shared_folders"}
754
+ end
755
+
756
+ # Lookup shared folder id by field/value within a node's shared_folders entity.
757
+ # Used as lookup: on the :user command under admin > nodes > shared_folders.
758
+ # sf_entity is available in ctx because setup_admin_nodes_shared_folders ran first (Phase A of parent).
759
+ def lookup_sf_id(field, value, sf_entity:, **)
760
+ @api_v5.lookup_entity_by_field(entity: sf_entity, items_key: 'shared_folders', field: field, value: value)['id']
761
+ end
762
+
763
+ # admin > nodes > shared_folders > create/modify/delete/show/list
764
+ Operations::ALL.each do |op|
765
+ define_action_method([:admin, :nodes, :shared_folders, op]) do |sf_entity:, **|
766
+ entity_execute(api: @api_v5, entity: sf_entity, items_key: 'shared_folders', command: op){ |f, v| @api_v5.lookup_entity_by_field(entity: sf_entity, items_key: 'shared_folders', field: f, value: v)['id']}
624
767
  end
625
- case command
626
- when :version
627
- return Main.result_single_object(@api_v5.read('version'))
628
- when :health
629
- nagios = Nagios.new
630
- begin
631
- data, http = Rest.new(base_url: options.get_option(:url, mandatory: true))
632
- .read('health', ret: :both)
633
- data.each do |k, v|
634
- nagios.add_ok(k, v.to_s)
635
- end
636
- nagios.add_ok('version', http['X-IBM-Aspera']) if http['X-IBM-Aspera']
637
- rescue StandardError => e
638
- nagios.add_critical('core', e.to_s)
768
+ end
769
+
770
+ # admin > nodes > shared_folders > user — sf_id: already in ctx via arguments: on the :user command
771
+ def setup_admin_nodes_shared_folders_user(sf_entity:, sf_id:, **)
772
+ {user_path: "#{sf_entity}/#{sf_id}/custom_access_users"}
773
+ end
774
+
775
+ # admin > nodes > shared_folders > user > create/modify/delete/show/list (custom access users)
776
+ Operations::ALL.each do |op|
777
+ define_action_method([:admin, :nodes, :shared_folders, :user, op]) do |user_path:, **|
778
+ entity_execute(api: @api_v5, entity: user_path, items_key: 'users', command: op){ |f, v| @api_v5.lookup_entity_by_field(entity: user_path, items_key: 'users', field: f, value: v)['id']}
779
+ end
780
+ end
781
+
782
+ # admin > shared_inboxes|workgroups — res_id: already in ctx via arguments: on the :members|:saml_groups command
783
+ %i[shared_inboxes workgroups].each do |res|
784
+ define_method(:"setup_admin_#{res}_instance") do |res_id:, **|
785
+ {res_instance_path: "#{res}/#{res_id}"}
786
+ end
787
+ end
788
+
789
+ # Resolve user ids for shared_inbox/workgroup members/create, handling percent selectors.
790
+ # @param users [Array] raw user ids or percent-selector strings
791
+ # @return [Array<String>] resolved user ids
792
+ def resolve_member_user_ids(users)
793
+ users = [users] unless users.is_a?(Array)
794
+ users.map do |user|
795
+ if (m = Parser.percent_selector(user))
796
+ @api_v5.lookup_entity_by_field(entity: 'accounts', field: m[:field], value: m[:value], query: Rest.php_style({type: ACCOUNT_TYPES}))['id']
797
+ else
798
+ user
639
799
  end
640
- Main.result_object_list(nagios.status_list)
641
- when :user
642
- case options.get_next_command(%i[account profile])
643
- when :account
644
- return Main.result_single_object(@api_v5.read('account', query_read_delete))
645
- when :profile
646
- case options.get_next_command(%i[show modify])
647
- when :show
648
- return Main.result_single_object(@api_v5.read('account/preferences'))
649
- when :modify
650
- @api_v5.update('account/preferences', options.get_next_argument('modified parameters', validation: Hash))
651
- return Main.result_status('modified')
800
+ end
801
+ end
802
+
803
+ # admin > shared_inboxes|workgroups > members|saml_groups > create/list/modify/delete
804
+ %i[shared_inboxes workgroups].each do |res|
805
+ MEMBER_SUBS.each do |sub|
806
+ CRUD_NO_SHOW.each do |op|
807
+ if op.eql?(:create) && sub.eql?(:members)
808
+ define_action_method([:admin, res, sub, op]) do |users:, access:, res_instance_path:, **|
809
+ res_path = "#{res_instance_path}/#{sub}"
810
+ list_key = sub.eql?(:saml_groups) ? 'groups' : sub.to_s
811
+ resolved = resolve_member_user_ids(users)
812
+ input_data = [{user: resolved.map{ |u| {id: u, access: access}}}]
813
+ entity_execute(api: @api_v5, entity: res_path, command: op, input_data: input_data, items_key: list_key) do |f, v|
814
+ @api_v5.lookup_entity_by_field(entity: res_path, field: f, value: v, query: Rest.php_style({type: %w[user]}))['user_id']
815
+ end
816
+ end
817
+ else
818
+ define_action_method([:admin, res, sub, op]) do |res_instance_path:, **|
819
+ res_path = "#{res_instance_path}/#{sub}"
820
+ list_key = sub.eql?(:saml_groups) ? 'groups' : sub.to_s
821
+ entity_execute(api: @api_v5, entity: res_path, command: op, items_key: list_key) do |f, v|
822
+ @api_v5.lookup_entity_by_field(entity: res_path, field: f, value: v, query: Rest.php_style({type: %w[user]}))['user_id']
823
+ end
824
+ end
652
825
  end
653
826
  end
654
- when :bearer_token
655
- return Main.result_text(@api_v5.oauth.authorization)
656
- when :packages
657
- return package_action
658
- when :shared_folders
659
- all_shared_folders = @api_v5.read('shared_folders')['shared_folders']
660
- case options.get_next_command(%i[list browse])
661
- when :list
662
- return Main.result_object_list(all_shared_folders)
663
- when :browse
664
- shared_folder_id = options.instance_identifier do |field, value|
665
- matches = all_shared_folders.select{ |i| i[field].eql?(value)}
666
- raise "no match for #{field} = #{value}" if matches.empty?
667
- raise "multiple matches for #{field} = #{value}" if matches.length > 1
668
- matches.first['id']
669
- end
670
- node = all_shared_folders.find{ |i| i['id'].eql?(shared_folder_id)}
671
- raise "No such shared folder id #{shared_folder_id}" if node.nil?
672
- return browse_folder("nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse")
827
+ end
828
+ end
829
+
830
+ # admin > shared_inboxes|workgroups > invite_external_collaborator
831
+ %i[shared_inboxes workgroups].each do |res|
832
+ define_action_method([:admin, res, :invite_external_collaborator]) do |input_data:, res_id:, **|
833
+ res_instance_path = "#{res}/#{res_id}"
834
+ result = @api_v5.create("#{res_instance_path}/external_collaborator", input_data)
835
+ formatter.display_status(result['message'])
836
+ Result::SingleObject.new(@api_v5.lookup_entity_by_field(entity: "#{res_instance_path}/members", items_key: 'members', value: input_data['email_address'], query: {}))
837
+ end
838
+ end
839
+
840
+ # --- root setup ---
841
+
842
+ # Build @api_v5 for all commands that need it (all except :health and :postprocessing).
843
+ # @return [Hash] empty ctx (state stored in @api_v5)
844
+ def setup_api_v5(**)
845
+ return {} if @api_v5
846
+ @api_v5 = Api::Faspex.new(**Oauth.kwargs_from_options(options))
847
+ # in case user wants to use HTTPGW tell transfer agent how to get address
848
+ transfer.httpgw_url_cb = lambda{@api_v5.read('account')['gateway_url']}
849
+ {}
850
+ end
851
+
852
+ # Setup for package sub-commands that need an id: resolves package_id from pub_link or argument.
853
+ # @return [Hash] ctx key: package_id
854
+ def setup_package_id(**)
855
+ package_id = @api_v5.pub_link_context&.key?('package_id') ? @api_v5.pub_link_context['package_id'] : options.instance_identifier
856
+ {package_id: package_id}
857
+ end
858
+
859
+ def action_packages_list
860
+ list, total = list_packages_with_filter
861
+ fields = %w[id title status sender.name recipients.0.name release_date total_bytes total_files]
862
+ fields.delete('recipients.0.name') if %w[inbox inbox_history].include?(options.get_option(:box))
863
+ fields.delete('sender.name') if %w[outbox outbox_history].include?(options.get_option(:box))
864
+ Result::ObjectList.new(list, total: total, fields: fields)
865
+ end
866
+
867
+ def action_packages_delete(package_id:, **)
868
+ ids = package_id.is_a?(Array) ? package_id : [package_id]
869
+ Aspera.assert_array_all(ids, String){'Package id(s)'}
870
+ # API returns 204, empty on success
871
+ @api_v5.call(
872
+ operation: 'DELETE',
873
+ subpath: 'packages',
874
+ content_type: Mime::JSON,
875
+ body: {ids: ids},
876
+ headers: {'Accept' => Mime::JSON}
877
+ )
878
+ Result::Status.new('Package(s) deleted')
879
+ end
880
+
881
+ # --- handlers ---
882
+
883
+ def action_health
884
+ nagios = Nagios.new
885
+ begin
886
+ data, http = Rest.new(base_url: options.get_option(:url, mandatory: true))
887
+ .read('health', ret: :both)
888
+ data.each do |k, v|
889
+ nagios.add_ok(k, v.to_s)
673
890
  end
674
- when :admin
675
- return execute_admin
676
- when :invitations
677
- invitation_endpoint = 'invitations'
678
- invitation_command = options.get_next_command(%i[resend].concat(Operations::ALL))
679
- case invitation_command
680
- when :create
681
- return do_bulk_operation(command: invitation_command, descr: 'data') do |params|
682
- invitation_endpoint = params.key?('recipient_name') ? 'public_invitations' : 'invitations'
683
- @api_v5.create(invitation_endpoint, params)
684
- end
685
- when :resend
686
- @api_v5.create("#{invitation_endpoint}/#{options.instance_identifier}/resend", nil)
687
- return Main.result_status('Invitation resent')
688
- else
689
- return entity_execute(
690
- api: @api_v5,
691
- entity: invitation_endpoint,
692
- command: invitation_command,
693
- items_key: invitation_endpoint,
694
- display_fields: %w[id public recipient_type recipient_name email_address]
695
- ) do |field, value|
696
- @api_v5.lookup_entity_by_field(entity: invitation_endpoint, field: field, value: value, query: {})['id']
697
- end
891
+ nagios.add_ok('version', http['X-IBM-Aspera']) if http['X-IBM-Aspera']
892
+ rescue StandardError => e
893
+ nagios.add_critical('core', e.to_s)
894
+ end
895
+ Result::ObjectList.new(nagios.status_list)
896
+ end
897
+
898
+ # Lookup a shared folder id by field/value.
899
+ # Called via lookup: :lookup_shared_folder_id on the shared_folders > browse command.
900
+ def lookup_shared_folder_id(field, value, **)
901
+ all = @api_v5.read('shared_folders')['shared_folders']
902
+ matches = all.select{ |i| i[field].eql?(value)}
903
+ Aspera.assert(!matches.empty?){"no match for #{field} = #{value}"}
904
+ Aspera.assert(matches.length == 1){"multiple matches for #{field} = #{value}"}
905
+ matches.first['id']
906
+ end
907
+
908
+ def action_shared_folders_browse(folder_path:, shared_folder_id:, **)
909
+ all_shared_folders = @api_v5.read('shared_folders')['shared_folders']
910
+ node = all_shared_folders.find{ |i| i['id'].eql?(shared_folder_id)}
911
+ Aspera.assert(!node.nil?){"No such shared folder id #{shared_folder_id}"}
912
+ browse_folder("nodes/#{node['node_id']}/shared_folders/#{shared_folder_id}/browse", {}, folder_path: folder_path)
913
+ end
914
+
915
+ # invitations sub-handlers
916
+
917
+ def action_invitations_resend(invitation_id:, **)
918
+ @api_v5.create("invitations/#{invitation_id}/resend", nil)
919
+ Result::Status.new('Invitation resent')
920
+ end
921
+
922
+ def action_invitations_create(input_data:, **)
923
+ is_bulk = options.get_option(:bulk)
924
+ Result.bulk(input_data, is_bulk: is_bulk, command: :create, bfail: options.get_option(:bfail)) do |params|
925
+ endpoint = params.key?('recipient_name') ? 'public_invitations' : 'invitations'
926
+ @api_v5.create(endpoint, params)
927
+ end
928
+ end
929
+
930
+ # CRUD handlers for invitations (list, show, modify, delete)
931
+ Operations::ALL.reject{ |op| op == :create}.each do |op|
932
+ define_action_method([:invitations, op]) do
933
+ entity_execute(
934
+ api: @api_v5,
935
+ entity: 'invitations',
936
+ command: op,
937
+ items_key: 'invitations',
938
+ display_fields: %w[id public recipient_type recipient_name email_address]
939
+ ) do |field, value|
940
+ @api_v5.lookup_entity_by_field(entity: 'invitations', field: field, value: value, query: {})['id']
698
941
  end
699
- when :gateway
700
- require 'aspera/faspex_gw'
701
- parameters = value_create_modify(command: command, default: {}).symbolize_keys
702
- uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
703
- server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
704
- Aspera.assert(parameters.except(*WebServerSimple::PARAMS).empty?)
705
- server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
706
- server.start
707
- return Main.result_status('Gateway terminated')
708
- when :postprocessing
709
- require 'aspera/faspex_postproc' # cspell:disable-line
710
- parameters = value_create_modify(command: command, default: {}).symbolize_keys
711
- uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
712
- parameters[:root] = uri.path
713
- server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
714
- server.mount(uri.path, Faspex4PostProcServlet, parameters.except(*WebServerSimple::PARAMS))
715
- server.start
716
- return Main.result_status('Gateway terminated')
717
942
  end
718
943
  end
944
+
945
+ def action_gateway(parameters: {}, **)
946
+ require 'aspera/faspex_gw'
947
+ parameters = parameters.symbolize_keys
948
+ uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
949
+ server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
950
+ Aspera.assert(parameters.except(*WebServerSimple::PARAMS).empty?){"unexpected parameters: #{parameters.except(*WebServerSimple::PARAMS).keys}"}
951
+ server.mount(uri.path, Faspex4GWServlet, @api_v5, nil)
952
+ server.start
953
+ Result::Status.new('Gateway terminated')
954
+ end
955
+
956
+ def action_postprocessing(parameters: {}, **)
957
+ require 'aspera/faspex_postproc' # cspell:disable-line
958
+ parameters = parameters.symbolize_keys
959
+ uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
960
+ parameters[:root] = uri.path
961
+ server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
962
+ server.mount(uri.path, Faspex4PostProcServlet, parameters.except(*WebServerSimple::PARAMS))
963
+ server.start
964
+ Result::Status.new('Gateway terminated')
965
+ end
719
966
  SHARED_INBOX_MEMBER_LEVELS = %i[submit_only standard shared_inbox_admin].freeze
720
967
  ACCOUNT_TYPES = %w{local_user saml_user self_registered_user external_user}.freeze
721
968
  WORKGROUP_TYPES = %w{workgroup shared_inbox}.freeze
722
969
  CONTACT_TYPES = (WORKGROUP_TYPES + %w{distribution_list user external_user}).freeze
723
970
  PACKAGE_RECIPIENT_TYPES = %i{recipients private_recipients notified_on_upload notified_on_download notified_on_receipt}
724
- private_constant :SHARED_INBOX_MEMBER_LEVELS, :ACCOUNT_TYPES, :CONTACT_TYPES, :PACKAGE_RECIPIENT_TYPES
971
+ private_constant :SHARED_INBOX_MEMBER_LEVELS, :ACCOUNT_TYPES, :CONTACT_TYPES, :PACKAGE_RECIPIENT_TYPES,
972
+ :MEMBER_SUBS, :CRUD_NO_SHOW, :CRUD_NO_LIST
725
973
  end
726
974
  end
727
975
  end