aspera-cli 4.26.2 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  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 +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -67,10 +67,10 @@ module Aspera
67
67
  product_name = ascp_location.delete_prefix(USE_PRODUCT_PREFIX)
68
68
  if product_name.eql?(FIRST_FOUND)
69
69
  pl = installed_products.first
70
- raise "No Aspera transfer module or SDK found.\nRefer to the manual or install SDK with command:\nascli conf transferd install" if pl.nil?
70
+ Aspera.assert(!pl.nil?){"No Aspera transfer module or SDK found.\nRefer to the manual or install SDK with command:\nascli conf transferd install"}
71
71
  else
72
72
  pl = installed_products.find{ |i| i[:name].eql?(product_name)}
73
- raise "No such product installed: #{product_name}" if pl.nil?
73
+ Aspera.assert(!pl.nil?){"No such product installed: #{product_name}"}
74
74
  end
75
75
  File.dirname(pl[:ascp_path])
76
76
  else
@@ -88,14 +88,14 @@ module Aspera
88
88
  # @return [Hash] with key = file name (String), and value = path to file
89
89
  def file_paths
90
90
  return SDK_FILES.to_h do |v|
91
- [v.to_s, begin
92
- path(v)
93
- rescue Errno::ENOENT => e
94
- e.message.gsub(/.*assertion failed: /, '').gsub(/\): .*/, ')')
95
- rescue => e
96
- e.message
97
- end]
98
- end
91
+ [v.to_s, begin
92
+ path(v)
93
+ rescue Errno::ENOENT => e
94
+ e.message.gsub(/.*assertion failed: /, '').gsub(/\): .*/, ')')
95
+ rescue => e
96
+ e.message
97
+ end]
98
+ end
99
99
  end
100
100
 
101
101
  # TODO: if using another product than SDK, should use files from there
@@ -106,18 +106,18 @@ module Aspera
106
106
 
107
107
  # Get path of one resource file of currently activated product
108
108
  # keys and certs are generated locally... (they are well known values, arch. independent)
109
- # @param k [Symbol] key of the resource file
109
+ # @param file_type [Symbol] key of the resource file
110
110
  # @return [String, nil] Full path to the resource file or nil if not found
111
- def path(k)
111
+ def path(file_type)
112
112
  file_is_required = true
113
- case k
113
+ case file_type
114
114
  when *EXE_FILES
115
- file_is_required = k.eql?(:ascp)
115
+ file_is_required = file_type.eql?(:ascp)
116
116
  file = Products::Transferd.transferd_path
117
- file = File.join(File.dirname(file), Environment.instance.exe_file(k.to_s)) unless k.eql?(:transferd)
117
+ file = File.join(File.dirname(file), Environment.instance.exe_file(file_type.to_s)) unless file_type.eql?(:transferd)
118
118
  when :ssh_private_dsa, :ssh_private_rsa
119
119
  # assume last 3 letters are type
120
- type = k.to_s[-3..].to_sym
120
+ type = file_type.to_s[-3..].to_sym
121
121
  file = check_or_create_sdk_file("aspera_bypass_#{type}.pem"){DataRepository.instance.item(type)}
122
122
  when :aspera_license
123
123
  file = check_or_create_sdk_file('aspera-license'){DataRepository.instance.item(:license)}
@@ -134,22 +134,23 @@ module Aspera
134
134
  check_or_create_sdk_file('aspera_fallback_cert_private_key.pem', force: true){private_key.to_pem}
135
135
  check_or_create_sdk_file('aspera_fallback_cert.pem', force: true){cert.to_pem}
136
136
  end
137
- file = k.eql?(:fallback_certificate) ? file_cert : file_key
138
- else Aspera.error_unexpected_value(k)
137
+ file = file_type.eql?(:fallback_certificate) ? file_cert : file_key
138
+ else Aspera.error_unexpected_value(file_type)
139
139
  end
140
140
  return unless file_is_required || File.exist?(file)
141
- Aspera.assert(File.exist?(file), type: Errno::ENOENT){"#{k} not found (#{file})"}
141
+ Aspera.assert(File.exist?(file), type: Errno::ENOENT){"#{file_type} not found (#{file})"}
142
142
  return file
143
143
  end
144
144
 
145
145
  # default bypass key phrase
146
- # @return [String]
146
+ # @return [String] UUID used as bypass key phrase for SSH certificate
147
147
  def ssh_cert_uuid
148
148
  return DataRepository.instance.item(:uuid)
149
149
  end
150
150
 
151
151
  # get paths of SSH keys to use for ascp client
152
- # @param types [Symbol] types to use
152
+ # @param types [Symbol] types to use (one of CLIENT_SSH_KEY_OPTIONS)
153
+ # @return [Array<String>] list of private key file paths
153
154
  def aspera_token_ssh_key_paths(types)
154
155
  Aspera.assert_values(types, CLIENT_SSH_KEY_OPTIONS)
155
156
  return case types
@@ -170,13 +171,11 @@ module Aspera
170
171
  Aspera.assert_type(exe_path, String)
171
172
  Aspera.assert_type(vers_arg, String)
172
173
  return unless File.exist?(exe_path)
173
- exe_version = nil
174
- cmd_out = %x("#{exe_path}" #{vers_arg})
175
- raise "An error occurred when testing #{exe_path}: #{cmd_out}" unless $CHILD_STATUS == 0
174
+ cmd_out, _err, status = Environment.secure_execute(exe_path, vers_arg, mode: :capture, exception: false)
175
+ raise "An error occurred when testing #{exe_path}: #{cmd_out}" unless status.success?
176
176
  # get version from ascp, only after full extract, as windows requires DLLs (SSL/TLS/etc...)
177
177
  m = cmd_out.match(/ version ([0-9.]+)/)
178
- exe_version = m[1].gsub(/\.$/, '') unless m.nil?
179
- return exe_version
178
+ m.nil? ? nil : m[1].gsub(/\.$/, '')
180
179
  end
181
180
 
182
181
  # Extract some stings from ascp logs
@@ -234,15 +233,15 @@ module Aspera
234
233
  return ascp_data
235
234
  end
236
235
 
237
- # @return the url for download of SDK archive for the given platform and version
236
+ # @return [String] the url for download of SDK archive for the given platform and version
238
237
  def sdk_url_for_platform(platform: nil, version: nil)
239
238
  all_locations = sdk_locations
240
239
  platform = Environment.instance.architecture if platform.nil?
241
240
  locations = all_locations.select{ |l| l['platform'].eql?(platform)}
242
- raise "No SDK for platform: #{platform}, available: #{all_locations.map{ |i| i['platform']}.uniq}" if locations.empty?
241
+ Aspera.assert(!locations.empty?){"No SDK for platform: #{platform}, available: #{all_locations.map{ |i| i['platform']}.uniq}"}
243
242
  version = locations.max_by{ |entry| Gem::Version.new(entry['version'])}['version'] if version.nil?
244
243
  info = locations.select{ |entry| entry['version'].eql?(version)}
245
- raise "No such version: #{version} for #{platform}" if info.empty?
244
+ Aspera.assert(!info.empty?){"No such version: #{version} for #{platform}"}
246
245
  return info.first['url']
247
246
  end
248
247
 
@@ -282,15 +281,14 @@ module Aspera
282
281
  end
283
282
  end
284
283
 
285
- # Retrieves ascp binary for current system architecture from URL or file
284
+ # Downloads and extracts the SDK archive for the current platform into +folder+
286
285
  # @param folder [String] Destination folder path
287
286
  # @param url [nil, String] URL to SDK archive, if nil: default url for version
288
287
  # @param version [nil, String] Specific version, if nil: latest version
289
288
  # @param backup [Boolean] If destination folder exists, then rename
290
289
  # @yieldparam entry_name [String] File path from archive
291
290
  # @yieldreturn [String, nil] Destination sub folder (end with /) or file, or nil to not extract
292
- # @return [Array] name, ascp version (from execution), folder
293
- def install_sdk(folder:, url: nil, version: nil, backup: true)
291
+ def download_sdk(folder:, url: nil, version: nil, backup: true)
294
292
  url ||= sdk_url_for_platform(version: version)
295
293
  # Rename old install
296
294
  if backup && Dir.exist?(folder) && !Dir.empty?(folder)
@@ -372,12 +370,13 @@ module Aspera
372
370
  end
373
371
  end
374
372
 
375
- # Retrieve SDK either from specified URL, or specified version from standard location, or latest version
376
- # @param url [nil, String] URL
377
- # @param version [nil, String] URL
378
- def retrieve_sdk(url: nil, version: nil)
373
+ # Downloads, installs and configures the SDK, returns name, version and install folder
374
+ # @param url [nil, String] URL to SDK archive, if nil: default url for version
375
+ # @param version [nil, String] Specific version, if nil: latest version
376
+ # @return [Array<String>] sdk name, sdk version, install folder
377
+ def install_sdk(url: nil, version: nil)
379
378
  folder = Products::Transferd.sdk_directory
380
- install_sdk(folder: folder, url: url, version: version)
379
+ download_sdk(folder: folder, url: url, version: version)
381
380
  # Ensure necessary files are there, or generate them, restrict file access on SDK executables
382
381
  SDK_FILES.each do |file_id_sym|
383
382
  file_path = path(file_id_sym)
@@ -433,7 +432,7 @@ module Aspera
433
432
 
434
433
  public
435
434
 
436
- # @return the list of installed products in format of product_locations_on_current_os
435
+ # @return [Array<Hash>] the list of installed products in format of product_locations_on_current_os
437
436
  def installed_products
438
437
  return @found_products unless @found_products.nil?
439
438
  # :expected M app name is taken from the manifest if present, else defaults to this value
@@ -12,7 +12,7 @@ module Aspera
12
12
  # https://www.ibm.com/support/pages/error-code-reference-tables
13
13
  # mgmtmess.c : as_mgmt_err_is_retryable
14
14
  # Note that the fact that an error is retry-able is not internally defined by protocol, it's client-side responsibility
15
- # rubocop:disable Layout/FirstHashElementLineBreak
15
+ # rubocop:disable-next Layout/FirstHashElementLineBreak
16
16
  ERRORS = {
17
17
  # id retry-able mnemo/code message additional info
18
18
  0 => {r: false, c: 'UNKNOWN', m: 'unknown', a: 'unknown'},
@@ -93,7 +93,6 @@ module Aspera
93
93
  66 => {r: false, c: 'PEER_REQUIRES_FIPS', m: 'Peer rejects cipher due to FIPS mode enabled on peer',
94
94
  a: 'Peer rejects cipher due to FIPS mode enabled on peer'}
95
95
  }.freeze
96
- # rubocop:enable Layout/FirstHashElementLineBreak
97
96
  # cspell: disable
98
97
  OPERATIONS = %w[
99
98
  NOP
@@ -307,7 +306,7 @@ module Aspera
307
306
  def field_snake_to_native(name)
308
307
  field = name.delete('_')
309
308
  result = PARAMETERS.find{ |w| w.casecmp?(field)}
310
- raise "No such field: #{name}" if result.nil?
309
+ Aspera.assert(!result.nil?){"No such field: #{name}"}
311
310
  result
312
311
  end
313
312
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/agent/factory'
4
+ require 'aspera/schema/registry'
5
+ require 'aspera/schema/reader'
6
+
7
+ module Aspera
8
+ module Cli
9
+ # Mixin for Config plugin: ASCP / Transferd related actions
10
+ module AscpActions
11
+ TRANSFERD_APP_NAME = 'sdk'
12
+
13
+ # Mapping from agent symbol to schema key in options.schema.yaml
14
+ # Agents without dedicated schema (connect, desktop) are omitted.
15
+ AGENT_SCHEMA_KEY = {
16
+ direct: Schema::Registry::DIRECT_AGENT_OPTIONS,
17
+ node: Schema::Registry::NODE_AGENT_OPTIONS,
18
+ httpgw: Schema::Registry::HTTPGW_AGENT_OPTIONS,
19
+ transferd: Schema::Registry::TRANSFERD_AGENT_OPTIONS
20
+ }.freeze
21
+ private_constant :AGENT_SCHEMA_KEY
22
+
23
+ # Set the SDK directory, checking default and former locations
24
+ def set_sdk_dir
25
+ sdk_dir = Products::Transferd.sdk_directory rescue nil
26
+ if sdk_dir.nil?
27
+ @sdk_default_location = true
28
+ Log.log.debug('SDK folder is not set, checking default')
29
+ sdk_dir = self.class.default_app_main_folder(app_name: TRANSFERD_APP_NAME)
30
+ Log.log.debug{"Checking: #{sdk_dir}"}
31
+ if !Dir.exist?(sdk_dir)
32
+ Log.log.debug{"No such folder: #{sdk_dir}"}
33
+ former_sdk_folder = File.join(self.class.default_app_main_folder(app_name: Info::CMD_NAME), TRANSFERD_APP_NAME)
34
+ Log.log.debug{"Checking: #{former_sdk_folder}"}
35
+ sdk_dir = former_sdk_folder if Dir.exist?(former_sdk_folder)
36
+ end
37
+ Log.log.debug{"Using: #{sdk_dir}"}
38
+ Products::Transferd.sdk_directory = sdk_dir
39
+ end
40
+ end
41
+
42
+ # Install the transfer SDK (ascp + transferd) from a URL or using the default source.
43
+ # Version defaults to +Info::SDK_VERSION+; pass +LATEST+ as argument to install the latest available version.
44
+ # @param version [String, nil] version to install; nil means use the default SDK version
45
+ # @return [Result::Status] installation result message
46
+ def install_transfer_sdk(version: nil)
47
+ asked_version = version.nil? ? Info::SDK_VERSION : version
48
+ asked_version = nil if asked_version.eql?(SpecialValues::LATEST)
49
+ sdk_url = options.get_option(:sdk_url, mandatory: true)
50
+ sdk_url = nil if sdk_url.eql?(SpecialValues::DEF)
51
+ name, ver, folder = Ascp::Installation.instance.install_sdk(url: sdk_url, version: asked_version)
52
+ return Result::Status.new("Installed #{name} version #{ver} in #{folder}")
53
+ end
54
+
55
+ def action_ascp_show(**)
56
+ Result::Text.new(Ascp::Installation.instance.path(:ascp))
57
+ end
58
+
59
+ def action_ascp_info(**)
60
+ data = Ascp::Installation.instance.ascp_info
61
+ data['ts'] = transfer.user_transfer_spec
62
+ DataRepository::ELEMENTS.each_with_object(data){ |i, h| h[i.to_s] = DataRepository.instance.item(i)}
63
+ SecretHider::ADDITIONAL_KEYS_TO_HIDE.concat(DataRepository::ELEMENTS.map(&:to_s))
64
+ Result::SingleObject.new(data)
65
+ end
66
+
67
+ def action_ascp_install(version: nil, **)
68
+ install_transfer_sdk(version: version)
69
+ end
70
+
71
+ def action_ascp_spec(**)
72
+ builder = Schema::Documentation.new(TerminalFormatter, Transfer::Spec::SCHEMA, include_option: true, agent_columns: true).build
73
+ Result::ObjectList.new(builder.rows, fields: builder.columns)
74
+ end
75
+
76
+ def action_ascp_schema(agent_name: nil, **)
77
+ schema = Transfer::Spec::SCHEMA.current.merge({'$comment'=>'DO NOT EDIT, this file was generated from the YAML.'})
78
+ schema['properties'] = schema['properties'].select{ |_k, v| CommandLineBuilder.supported_by_agent(agent_name, v)} unless agent_name.nil?
79
+ schema['properties'] = schema['properties'].sort.to_h
80
+ Result::SingleObject.new(schema)
81
+ end
82
+
83
+ def action_ascp_errors(**)
84
+ error_data = []
85
+ Ascp::Management::ERRORS.each_pair do |code, prop|
86
+ error_data.push(code: code, mnemonic: prop[:c], retry: prop[:r], info: prop[:a])
87
+ end
88
+ Result::ObjectList.new(error_data)
89
+ end
90
+
91
+ def action_ascp_products_list(**)
92
+ Result::ObjectList.new(Ascp::Installation.instance.installed_products, fields: %w[name app_root])
93
+ end
94
+
95
+ def action_agents_list(**)
96
+ rows = Agent::Factory::ALL.map do |sym, names|
97
+ schema_key = AGENT_SCHEMA_KEY[sym]
98
+ param_names =
99
+ if schema_key
100
+ Schema::Registry.instance.reader(schema_key).current['properties']&.keys&.sort&.join(', ') || ''
101
+ else
102
+ ''
103
+ end
104
+ {
105
+ 'name' => sym.to_s,
106
+ 'short' => names[:short].to_s,
107
+ 'parameters' => param_names
108
+ }
109
+ end.sort_by{ |r| r['name']}
110
+ Result::ObjectList.new(rows, fields: %w[name short parameters])
111
+ end
112
+
113
+ def action_agents_show(agent_name:, **)
114
+ names = Agent::Factory::ALL[agent_name]
115
+ schema_key = AGENT_SCHEMA_KEY[agent_name]
116
+ agent_info = {
117
+ 'name' => agent_name.to_s,
118
+ 'short' => names[:short].to_s,
119
+ 'description' => schema_key ? Schema::Registry.instance.reader(schema_key).current['description'].to_s : '(no configurable parameters)'
120
+ }
121
+ if schema_key
122
+ properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
123
+ rows = properties.map do |pname, pdef|
124
+ row = {'parameter' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
125
+ row['required'] = (Schema::Registry.instance.reader(schema_key).current['required'] || []).include?(pname) ? 'yes' : 'no'
126
+ row['default'] = pdef.key?('default') ? pdef['default'].inspect : ''
127
+ row['enum'] = pdef.key?('enum') ? pdef['enum'].join(', ') : ''
128
+ row
129
+ end
130
+ return Result::ObjectList.new(rows, fields: %w[parameter required type default enum description])
131
+ end
132
+ Result::SingleObject.new(agent_info)
133
+ end
134
+
135
+ def action_agents_parameters(agent_name:, **)
136
+ schema_key = AGENT_SCHEMA_KEY[agent_name]
137
+ return Result::Nothing.new if schema_key.nil?
138
+ properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
139
+ rows = properties.map do |pname, pdef|
140
+ {'name' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
141
+ end
142
+ Result::ObjectList.new(rows, fields: %w[name type description])
143
+ end
144
+
145
+ def action_transferd_install(**)
146
+ install_transfer_sdk
147
+ end
148
+
149
+ def action_transferd_list(**)
150
+ sdk_list = Ascp::Installation.instance.sdk_locations
151
+ Result::ObjectList.new(sdk_list, fields: sdk_list.first.keys - ['url'])
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/log'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module Aspera
8
+ module Cli
9
+ # Persist the state of asynchronous transfers on the local file system.
10
+ #
11
+ # Each entry is a JSON document identified by a `job_id` (UUID generated by ascli).
12
+ # The underlying storage uses PersistencyFolder with the category prefix
13
+ # +CATEGORY+ so all async-transfer files are grouped together and can be
14
+ # garbage-collected independently.
15
+ #
16
+ # Schema of a stored entry:
17
+ # job_id [String] UUID generated by ascli (= the store key)
18
+ # agent_type [String] 'desktop' | 'node' | 'connect' | 'transferd' | 'direct'
19
+ # transfer_id [String] Opaque ID returned by the agent's start_transfer
20
+ # agent_params [Hash] Agent-specific connection parameters for re-querying
21
+ # status [String] 'running' | 'completed' | 'failed' | 'cancelled'
22
+ # bytes_transferred [Integer] Last known bytes transferred (0 if unknown)
23
+ # started_at [String] ISO-8601 timestamp
24
+ # ended_at [String, nil] ISO-8601 timestamp once finished
25
+ # error [String, nil] Error message when status == 'failed'
26
+ class AsyncTransferStore
27
+ CATEGORY = 'async_transfer_'
28
+ private_constant :CATEGORY
29
+
30
+ # @param persistency [PersistencyFolder]
31
+ def initialize(persistency)
32
+ @persistency = persistency
33
+ end
34
+
35
+ # Persist (create or update) an async transfer entry.
36
+ # @param job_id [String] the ascli-generated UUID
37
+ # @param data [Hash] fields to store (will be JSON-serialised)
38
+ def write(job_id, data)
39
+ Aspera.assert_type(job_id, String){'job_id'}
40
+ Aspera.assert_type(data, Hash){'data'}
41
+ @persistency.put(store_key(job_id), JSON.generate(data))
42
+ nil
43
+ end
44
+
45
+ # Read one entry.
46
+ # @param job_id [String]
47
+ # @return [Hash, nil] the stored data, or nil if not found
48
+ def read(job_id)
49
+ Aspera.assert_type(job_id, String){'job_id'}
50
+ raw = @persistency.get(store_key(job_id))
51
+ return if raw.nil?
52
+ JSON.parse(raw)
53
+ end
54
+
55
+ # List all async transfer entries.
56
+ # @return [Array<Hash>] each entry includes the +job_id+ field
57
+ def list
58
+ @persistency.current_items(CATEGORY).map do |key, raw|
59
+ data = JSON.parse(raw)
60
+ data['job_id'] ||= key.sub(CATEGORY, '')
61
+ data
62
+ end
63
+ end
64
+
65
+ # Delete one entry.
66
+ # @param job_id [String]
67
+ def delete(job_id)
68
+ Aspera.assert_type(job_id, String){'job_id'}
69
+ @persistency.delete(store_key(job_id))
70
+ nil
71
+ end
72
+
73
+ private
74
+
75
+ # Map a job_id to the PersistencyFolder key (includes category prefix).
76
+ def store_key(job_id)
77
+ "#{CATEGORY}#{job_id}"
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/plugins/factory'
4
+ require 'aspera/cli/extended_value'
5
+ require 'aspera/cli/special_values'
6
+ require 'aspera/cli/info'
7
+ require 'aspera/cli/transfer_progress'
8
+ require 'aspera/cli/preset_manager'
9
+ require 'aspera/cli/http'
10
+ require 'aspera/ascp/installation'
11
+ require 'aspera/products/transferd'
12
+ require 'aspera/transfer/parameters'
13
+ require 'aspera/proxy_auto_config'
14
+ require 'aspera/environment'
15
+ require 'aspera/persistency_folder'
16
+ require 'aspera/rest'
17
+ require 'aspera/log'
18
+ require 'aspera/assert'
19
+ require 'aspera/oauth'
20
+ require 'aspera/ssl'
21
+ require 'aspera/schema/registry'
22
+
23
+ module Aspera
24
+ module Cli
25
+ # Performs one-time bootstrap of all shared services into Context.
26
+ # Called by Runner before instantiating Plugins::Config, so that Config
27
+ # acts only as a CLI plugin (option declaration + command handlers).
28
+ #
29
+ # Responsibilities:
30
+ # - resolve context.main_folder (option :home)
31
+ # - populate context.persistency, context.presets, context.http_config, context.progress_bar
32
+ # - register plugin lookup folders
33
+ # - register @preset / @vault extended-value handlers
34
+ # - configure global singletons: RestParameters, OAuth::Factory, SSL, Transfer::Parameters,
35
+ # RestErrorAnalyzer
36
+ # - set up the PAC proxy executor (option :fpac)
37
+ class Bootstrapper
38
+ # Folder name inside $HOME for all Aspera tool data (~/.aspera)
39
+ ASPERA_HOME_FOLDER_NAME = '.aspera'
40
+ # Default name for the YAML config file
41
+ DEFAULT_CONFIG_FILENAME = 'config.yaml'
42
+ # Sub-folder for user-installed plugins
43
+ ASPERA_PLUGINS_FOLDERNAME = 'plugins'
44
+ # Sub-folder for persistency data
45
+ PERSISTENCY_FOLDER = 'persist_store'
46
+ # Sub-folder for file lists used by transfers
47
+ FILE_LIST_FOLDER_NAME = 'filelists'
48
+ # Log file for REST call exceptions
49
+ REST_EXCEPTIONS_LOG_FILENAME = 'rest_exceptions.log'
50
+ # Extended-value prefix that resolves a named preset
51
+ EXTEND_PRESET = :preset
52
+ # Extended-value prefix that resolves a vault secret
53
+ EXTEND_VAULT = :vault
54
+ # Default preset name for the global config section
55
+ CONF_GLOBAL_SYM = :config
56
+
57
+ private_constant :ASPERA_HOME_FOLDER_NAME,
58
+ :DEFAULT_CONFIG_FILENAME,
59
+ :ASPERA_PLUGINS_FOLDERNAME,
60
+ :PERSISTENCY_FOLDER,
61
+ :FILE_LIST_FOLDER_NAME,
62
+ :REST_EXCEPTIONS_LOG_FILENAME,
63
+ :EXTEND_PRESET,
64
+ :EXTEND_VAULT,
65
+ :CONF_GLOBAL_SYM
66
+
67
+ # @param context [Context] the shared context to populate
68
+ def initialize(context)
69
+ Aspera.assert_type(context, Context){'context'}
70
+ @context = context
71
+ @pac_exec = nil
72
+ end
73
+
74
+ # Run the full bootstrap sequence.
75
+ # Must be called after context.options and context.formatter are set.
76
+ # On return, context.persistency, context.presets, context.http_config and
77
+ # context.progress_bar are populated and all global singletons are configured.
78
+ #
79
+ # @param gem_plugins_folder [String] folder of built-in plugins (from Plugins::Config)
80
+ # @param vault_value_cb [Proc] block(name): secret value (from VaultManager)
81
+ def run(gem_plugins_folder:, vault_value_cb:)
82
+ setup_main_folder
83
+ setup_persistency_and_plugin_folders(gem_plugins_folder)
84
+ setup_config_file
85
+ setup_extended_value_handlers(vault_value_cb)
86
+ setup_progress_bar
87
+ setup_pac_executor
88
+ setup_rest_and_transfer_runtime
89
+ end
90
+
91
+ # Public accessor used as option handler for :config_file
92
+ attr_accessor :config_file_option
93
+
94
+ private
95
+
96
+ # Declare + parse :home option: sets context.main_folder
97
+ def setup_main_folder
98
+ @context.options.declare(
99
+ :home, description: 'Home folder for tool',
100
+ handler: {o: @context, m: :main_folder},
101
+ default: default_app_main_folder(app_name: Info::CMD_NAME)
102
+ )
103
+ @context.options.parse_options!
104
+ Log.log.debug{"#{Info::CMD_NAME} folder: #{@context.main_folder}"}
105
+ end
106
+
107
+ # context.persistency + plugin lookup folders
108
+ def setup_persistency_and_plugin_folders(gem_plugins_folder)
109
+ @context.persistency = PersistencyFolder.new(File.join(@context.main_folder, PERSISTENCY_FOLDER))
110
+ Plugins::Factory.instance.add_lookup_folder(gem_plugins_folder)
111
+ Plugins::Factory.instance.add_lookup_folder(File.join(@context.main_folder, ASPERA_PLUGINS_FOLDERNAME))
112
+ end
113
+
114
+ # Declare + parse :config_file option: sets context.presets + context.http_config
115
+ def setup_config_file
116
+ @context.options.declare(
117
+ :config_file, description: 'Path to YAML file with preset configuration',
118
+ handler: {o: self, m: :config_file_option},
119
+ default: File.join(@context.main_folder, DEFAULT_CONFIG_FILENAME)
120
+ )
121
+ @context.options.parse_options!
122
+ @context.presets = PresetManager.new(config_file: @config_file_option)
123
+ @context.http_config = Http.new
124
+ end
125
+
126
+ # Register @preset and @vault extended-value handlers + global config default preset
127
+ def setup_extended_value_handlers(vault_value_cb)
128
+ @context.options.declare(:secret, description: 'Secret for access keys')
129
+ @context.options.declare(:vault, schema: Schema::Registry::VAULT_OPTIONS)
130
+ @context.options.declare(:vault_password, description: 'Vault password')
131
+ # Register @preset and @vault handlers BEFORE parse_options! so that
132
+ # values like --secret=@preset:name are correctly evaluated at parse time.
133
+ ExtendedValue.instance.on(EXTEND_PRESET){ |v| @context.presets.by_name(v)}
134
+ ExtendedValue.instance.on(EXTEND_VAULT, &vault_value_cb)
135
+ @context.options.parse_options!
136
+ # Load global config default preset (equivalent of add_plugin_default_preset(:config))
137
+ default_config_name = @context.presets.plugin_default_name(CONF_GLOBAL_SYM)
138
+ unless default_config_name.nil?
139
+ Log.log.debug{"add_plugin_default_preset:#{CONF_GLOBAL_SYM}:#{default_config_name}"}
140
+ @context.options.add_option_preset(@context.presets.by_name(default_config_name), 'default_plugin', override: false)
141
+ end
142
+ end
143
+
144
+ # Declare + parse :progress_bar: sets context.progress_bar
145
+ def setup_progress_bar
146
+ @context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Allowed::TYPES_BOOLEAN, default: Environment.terminal?)
147
+ @context.options.parse_options!
148
+ @context.progress_bar = TransferProgress.new if @context.options.get_option(:progress_bar)
149
+ end
150
+
151
+ # Declare + parse :fpac / :proxy_credentials: sets up PAC executor
152
+ def setup_pac_executor
153
+ @context.options.declare(:fpac, description: 'Proxy auto configuration script')
154
+ @context.options.declare(:proxy_credentials, description: 'HTTP proxy credentials for fpac: user, password', allowed: [Array, NilClass])
155
+ @context.options.parse_options!
156
+ pac_script = @context.options.get_option(:fpac)
157
+ return unless pac_script
158
+
159
+ @context.pac_executor = ProxyAutoConfig.new(pac_script).register_uri_generic
160
+ proxy_user_pass = @context.options.get_option(:proxy_credentials)
161
+ if proxy_user_pass
162
+ Aspera.assert(proxy_user_pass.length.eql?(2), type: Cli::BadArgument){"proxy_credentials shall have two elements (#{proxy_user_pass.length})"}
163
+ @context.pac_executor.proxy_user = proxy_user_pass[0]
164
+ @context.pac_executor.proxy_pass = proxy_user_pass[1]
165
+ end
166
+ end
167
+
168
+ # Configure global singletons: RestParameters, SSL, Transfer, RestErrorAnalyzer.
169
+ # OAuth persist_mgr is NOT set here: it depends on :cache_tokens which is parsed later
170
+ # by Config#initialize. Runner sets it after Config.new.
171
+ def setup_rest_and_transfer_runtime
172
+ RestParameters.instance.user_agent = Info::CMD_NAME
173
+ RestParameters.instance.progress_bar = @context.progress_bar
174
+ RestParameters.instance.session_cb = ->(http_session){@context.http_config.update_session(http_session)}
175
+ RestParameters.instance.spinner_cb = ->(title = nil, action: :spin){@context.formatter.long_operation(title, action: action)}
176
+ OAuth::Web.additional_info = "#{Info::CMD_NAME} v#{Cli::VERSION}"
177
+ Transfer::Parameters.file_list_folder = File.join(@context.main_folder, FILE_LIST_FOLDER_NAME)
178
+ RestErrorAnalyzer.instance.log_file = File.join(@context.main_folder, REST_EXCEPTIONS_LOG_FILENAME)
179
+ RestErrorsAspera.register_handlers
180
+ end
181
+
182
+ # @return [String] ~/.aspera
183
+ def module_family_folder
184
+ user_home_folder = Dir.home
185
+ Aspera.assert(Dir.exist?(user_home_folder), type: Cli::Error){"Home folder does not exist: #{user_home_folder}. Check your user environment."}
186
+ File.join(user_home_folder, ASPERA_HOME_FOLDER_NAME)
187
+ end
188
+
189
+ # @return [String] ~/.aspera/<app_name>
190
+ def default_app_main_folder(app_name:)
191
+ Aspera.assert_type(app_name, String)
192
+ Aspera.assert(!app_name.empty?, 'app_name must not be empty')
193
+ File.join(module_family_folder, app_name)
194
+ end
195
+ end
196
+ end
197
+ end