aspera-cli 4.27.5 → 4.28.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 (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. data/lib/aspera/web_auth/index.html +0 -147
@@ -110,7 +110,9 @@ environment is available.
110
110
  ### 4.1 Demo server — browse files
111
111
 
112
112
  > "Browse the root folder of the Aspera demo server at ssh://demo.asperademo.com:33001
113
- > using username `asperaweb` and password `demoaspera`."
113
+ > using username `asperaweb` and password `<PASSWORD>`."
114
+
115
+ `<PASSWORD>` is the public password of the demo server.
114
116
 
115
117
  **Expected MCP calls**:
116
118
 
@@ -118,7 +120,7 @@ environment is available.
118
120
  ["server", "browse", "/",
119
121
  "--url=ssh://demo.asperademo.com:33001",
120
122
  "--username=asperaweb",
121
- "--password=demoaspera"]
123
+ "--password=<PASSWORD>"]
122
124
  ```
123
125
 
124
126
  **Pass criterion**: the AI returns a file/folder listing without errors.
@@ -134,7 +136,7 @@ environment is available.
134
136
  ["server", "download", "/aspera-test-dir-small/10KB.1",
135
137
  "--url=ssh://demo.asperademo.com:33001",
136
138
  "--username=asperaweb",
137
- "--password=demoaspera",
139
+ "--password=<PASSWORD>",
138
140
  "--to-folder=/tmp"]
139
141
  ```
140
142
 
@@ -4,6 +4,7 @@ require 'aspera/agent/base'
4
4
  require 'aspera/ascp/installation'
5
5
  require 'aspera/ascp/management'
6
6
  require 'aspera/exec_spec'
7
+ require 'aspera/temp_file_manager'
7
8
  require 'aspera/transfer/parameters'
8
9
  require 'aspera/transfer/error'
9
10
  require 'aspera/transfer/spec'
@@ -14,6 +15,7 @@ require 'socket'
14
15
  require 'securerandom'
15
16
  require 'shellwords'
16
17
  require 'English'
18
+ require 'time'
17
19
  require 'aspera/rainbow'
18
20
  using Rainbow
19
21
 
@@ -27,7 +29,7 @@ module Aspera
27
29
  # agent_params must contain '_agent_ref' pointing to the live Direct instance.
28
30
  # @param transfer_id [String] job_id returned by start_transfer / last_job_id
29
31
  # @param agent_params [Hash] connection parameters; '_agent_ref' is the live agent
30
- # @return [Hash] status hash with at least 'status' key
32
+ # @return [Hash] status hash with at least 'status' key, and `bytes_transferred`, and `ended_at` when all sessions ended
31
33
  def transfer_status(transfer_id, agent_params)
32
34
  agent = agent_params['_agent_ref']
33
35
  return {'status' => 'unknown', 'note' => 'direct agent not available (different process?)'} if agent.nil?
@@ -35,12 +37,14 @@ module Aspera
35
37
  return {'status' => 'unknown', 'note' => "no sessions found for job #{transfer_id}"} if sessions.empty?
36
38
  errors = sessions.filter_map { |s| s[:error] }
37
39
  running = sessions.any? { |s| s[:thread]&.alive? }
40
+ result = {'bytes_transferred' => sessions.sum { |s| s[:last_size].to_i }}
41
+ result['ended_at'] = sessions.filter_map { |s| s[:ended_at] }.max unless running
38
42
  if errors.any?
39
- {'status' => 'failed', 'error' => errors.first.message}
43
+ result.merge('status' => 'failed', 'error' => errors.first.message)
40
44
  elsif running
41
- {'status' => 'running'}
45
+ result.merge('status' => 'running')
42
46
  else
43
- {'status' => 'completed'}
47
+ result.merge('status' => 'completed')
44
48
  end
45
49
  end
46
50
  end
@@ -171,6 +175,10 @@ module Aspera
171
175
  exec_spec: Transfer::Parameters.new(transfer_spec, **@tr_opts).ascp_args
172
176
  }
173
177
 
178
+ # Temporary files of `ascp` (file list) are deleted when the last session ends,
179
+ # also after the end of the command (asynchronous transfer): hold them once per session, before any session starts
180
+ (multi_session_info.nil? ? 1 : multi_session_info[:count]).times { TempFileManager.instance.hold(session[:exec_spec].temp_files) }
181
+
174
182
  if multi_session_info.nil?
175
183
  Log.log.debug('Starting single session thread')
176
184
  # single session for transfer : simple
@@ -266,6 +274,9 @@ module Aspera
266
274
  rescue => e
267
275
  session[:error] = e
268
276
  raise if Log.log.debug? || !e.is_a?(Transfer::Error)
277
+ ensure
278
+ session[:ended_at] = Time.now.utc.iso8601
279
+ TempFileManager.instance.release(session[:exec_spec].temp_files)
269
280
  end
270
281
  Log.log.debug { "EXIT (#{Thread.current[:name]})" }
271
282
  end
@@ -26,7 +26,8 @@ module Aspera
26
26
  Dir.children(File.dirname(File.expand_path(__FILE__)))
27
27
  .select { |file| file.end_with?(Environment::RB_EXT) }
28
28
  .map { |file| File.basename(file, Environment::RB_EXT).to_sym }
29
- .reject { |item| IGNORED_ITEMS.include?(item) }.to_h do |agent_sym|
29
+ .reject { |item| IGNORED_ITEMS.include?(item) }
30
+ .sort.to_h do |agent_sym|
30
31
  [agent_sym, {
31
32
  long: agent_sym.to_s.capitalize,
32
33
  short: agent_sym.eql?(:direct) ? :a : agent_sym.to_s[0].to_sym
@@ -6,7 +6,7 @@ require 'aspera/transfer/spec'
6
6
  require 'aspera/api/node'
7
7
  require 'aspera/log'
8
8
  require 'aspera/assert'
9
- require 'aspera/oauth'
9
+ require 'aspera/oauth/client/factory'
10
10
 
11
11
  module Aspera
12
12
  module Agent
@@ -62,7 +62,7 @@ module Aspera
62
62
  ret: :resp
63
63
  ).body
64
64
  ats_info = XmlSimple.xml_in(xml_result_text, {'ForceArray' => false})
65
- Log.dump(:ats_info, ats_info.reject { |k, _| k == 'AccessKey' })
65
+ Log.dump(:ats_info, ats_info)
66
66
  Aspera.assert_hash_all(ats_info, String, nil) { 'ats_info' }
67
67
  Aspera.assert((FASP_INFO_KEYS - ats_info.keys).empty?, 'ats_info missing required keys')
68
68
  Aspera.assert_hash_all(ats_info['AccessKey'], String, String) { 'ats_info' }
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'aspera/assert'
4
4
  require 'aspera/rest'
5
- require 'aspera/oauth/base'
5
+ require 'aspera/oauth/client/base'
6
6
  require 'aspera/rest/list'
7
7
  require 'digest'
8
8
 
@@ -3,9 +3,10 @@
3
3
  require 'aspera/link_header'
4
4
  require 'aspera/transfer/spec'
5
5
  require 'aspera/rest'
6
- require 'aspera/oauth'
6
+ require 'aspera/oauth/client/factory'
7
7
  require 'aspera/log'
8
8
  require 'aspera/assert'
9
+ require 'aspera/secret_hider'
9
10
  require 'aspera/environment'
10
11
  require 'base64'
11
12
  require 'openssl'
@@ -173,12 +174,15 @@ module Aspera
173
174
  payload['auth_type'] ||= 'access_key'
174
175
  payload['expires_at'] ||= (Time.now + expiration_sec).utc.strftime('%FT%TZ')
175
176
  payload_json = JSON.generate(payload)
176
- return Base64.strict_encode64(Zlib::Deflate.deflate([
177
+ token = Base64.strict_encode64(Zlib::Deflate.deflate([
177
178
  payload_json,
178
179
  SIGNATURE_DELIMITER,
179
180
  Base64.strict_encode64(private_key.sign(OpenSSL::Digest.new('sha512'), payload_json)).scan(/.{1,60}/).join("\n"),
180
181
  ''
181
182
  ].join("\n")))
183
+ # Hidden wherever it appears (logs, results)
184
+ SecretHider.instance.add_secret_values(token)
185
+ return token
182
186
  end
183
187
 
184
188
  # Decode an Aspera Node bearer token
@@ -51,15 +51,24 @@ module Aspera
51
51
  end
52
52
 
53
53
  # Loads YAML from cloud with locations of SDK archives for all platforms
54
- # @return [Hash] location structure
54
+ # Tries each URL of option `locations_url` (default if empty) in order, until one provides a valid list.
55
+ # All but the last URL are read with a short timeout and no retry, so that fallback is quick.
56
+ # @return [Array<Hash>] location structure
55
57
  def sdk_locations
56
- location_url = @transferd_urls
57
- transferd_locations = UriReader.read(location_url)
58
- Log.log.debug { "Retrieving SDK locations from #{location_url}" }
59
- begin
60
- return Yaml.safe_load(transferd_locations)
61
- rescue Psych::SyntaxError
62
- raise "Error when parsing yaml data from: #{location_url}"
58
+ Aspera.assert_type(@transferd_urls, Array) { 'locations_url' }
59
+ location_urls = @transferd_urls.empty? ? TRANSFERD_ARCHIVE_LOCATION_URLS : @transferd_urls
60
+ location_urls.each_with_index do |location_url, index|
61
+ last = index.eql?(location_urls.length - 1)
62
+ Log.log.debug { "Retrieving SDK locations from #{location_url}" }
63
+ begin
64
+ transferd_locations = last ? UriReader.read(location_url) : read_fail_fast(location_url)
65
+ locations = Yaml.safe_load(transferd_locations)
66
+ Aspera.assert_type(locations, Array) { "SDK locations from #{location_url}" }
67
+ return locations
68
+ rescue StandardError => e
69
+ raise if last
70
+ Log.log.warn { "Failed to retrieve SDK locations from #{location_url}: #{e.message}, trying next" }
71
+ end
63
72
  end
64
73
  end
65
74
 
@@ -415,6 +424,7 @@ module Aspera
415
424
  return sdk_name, sdk_version, folder
416
425
  end
417
426
 
427
+ # @return [Array<String>] URLs of SDK locations, tried in order
418
428
  attr_accessor :transferd_urls
419
429
 
420
430
  private
@@ -434,20 +444,46 @@ module Aspera
434
444
  EXE_FILES = %i[ascp ascp4 async transferd].freeze
435
445
  # IDs of files present in SDK
436
446
  SDK_FILES = (EXE_FILES + %i[ssh_private_dsa ssh_private_rsa aspera_license aspera_conf fallback_certificate fallback_private_key]).freeze
437
- TRANSFERD_ARCHIVE_LOCATION_URL = 'https://ibm.biz/sdk_location'
447
+ # Primary URL (ibm.biz short link) with fallback to the canonical GitHub raw source
448
+ TRANSFERD_ARCHIVE_LOCATION_URLS = [
449
+ 'https://ibm.biz/sdk_location',
450
+ 'https://raw.githubusercontent.com/IBM/aspera-cli/refs/heads/main/docs/sdk_location.yaml'
451
+ ].freeze
452
+ # Timeout in seconds when another location URL remains to be tried
453
+ LOCATION_FAIL_FAST_TIMEOUT = 10
438
454
  # filename for ascp with optional extension (Windows)
439
- private_constant :DEFAULT_ASPERA_CONF, :EXE_FILES, :SDK_FILES, :TRANSFERD_ARCHIVE_LOCATION_URL
455
+ private_constant :DEFAULT_ASPERA_CONF, :EXE_FILES, :SDK_FILES, :TRANSFERD_ARCHIVE_LOCATION_URLS, :LOCATION_FAIL_FAST_TIMEOUT
440
456
 
441
457
  def initialize
442
458
  # cache for installed products found
443
459
  @found_products = nil
444
- @transferd_urls = TRANSFERD_ARCHIVE_LOCATION_URL
460
+ # URLs of SDK locations, tried in order
461
+ @transferd_urls = TRANSFERD_ARCHIVE_LOCATION_URLS
445
462
  # product selected with option `sdk_folder`, or nil to use SDK folder
446
463
  @ascp_product = nil
447
464
  # cache for folder of `ascp` of selected product
448
465
  @ascp_folder = nil
449
466
  end
450
467
 
468
+ # Read URL with a short timeout and no retry (temporarily changes global HTTP parameters)
469
+ # @param url [String] URL to read
470
+ # @return [String] content
471
+ def read_fail_fast(url)
472
+ parameters = Rest::Parameters.instance
473
+ session_cb = parameters.session_cb
474
+ retry_max = parameters.retry_max
475
+ parameters.retry_max = 0
476
+ parameters.session_cb = lambda do |http_session|
477
+ session_cb&.call(http_session)
478
+ http_session.open_timeout = [http_session.open_timeout, LOCATION_FAIL_FAST_TIMEOUT].compact.min
479
+ http_session.read_timeout = [http_session.read_timeout, LOCATION_FAIL_FAST_TIMEOUT].compact.min
480
+ end
481
+ UriReader.read(url)
482
+ ensure
483
+ parameters.session_cb = session_cb
484
+ parameters.retry_max = retry_max
485
+ end
486
+
451
487
  # @param file_type [Symbol] one of EXE_FILES
452
488
  # @return [String] path to executable in SDK folder
453
489
  def sdk_exe_path(file_type)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/assert'
4
+ require 'aspera/log'
4
5
  require 'aspera/string_ext'
5
6
 
6
7
  module Aspera
@@ -8,16 +9,17 @@ module Aspera
8
9
  # processing of ascp management port events
9
10
  # Reference: `mgmtmess.c`
10
11
  class Management
12
+ # cspell:ignore asmgmt faspmgmt
11
13
  # References:
12
- # https://www.google.com/search?q=FASP+error+codes
13
14
  # https://www.ibm.com/support/pages/error-code-reference-tables
14
- # mgmtmess.c : as_mgmt_err_is_retryable
15
+ # `asmgmt_err.h` : codes, mnemonics and messages
16
+ # `mgmtmess.c` : `faspmgmt_err_name_str` (additional info) and `as_mgmt_err_is_retryable`
15
17
  # Note that the fact that an error is retry-able is not internally defined by protocol, it's client-side responsibility
16
18
  # rubocop:disable-next Layout/FirstHashElementLineBreak
17
19
  ERRORS = {
18
20
  # id retry-able mnemo/code message additional info
19
21
  0 => {r: false, c: 'UNKNOWN', m: 'unknown', a: 'unknown'},
20
- 1 => {r: false, c: 'FASP_PROTO', m: 'Generic fasp(tm) protocol error', a: 'fasp(tm) error'},
22
+ 1 => {r: false, c: 'FASP_PROTO', m: 'Generic fasp(tm) protocol error', a: 'FASP error'},
21
23
  2 => {r: false, c: 'ASCP', m: 'Generic SCP error', a: 'ASCP error'},
22
24
  3 => {r: false, c: 'AMBIGUOUS_TARGET', m: 'Target incorrectly specified', a: 'Ambiguous target'},
23
25
  4 => {r: false, c: 'NO_SUCH_FILE', m: 'No such file or directory', a: 'No such file or directory'},
@@ -29,13 +31,13 @@ module Aspera
29
31
  10 => {r: false, c: 'LIC_RATE_EXCEEDED', m: 'Rate exceeds the cap imposed by license', a: 'Rate exceeds cap imposed by license'},
30
32
  11 => {r: false, c: 'INTERNAL_ERROR', m: 'Internal error (unexpected error)', a: 'Internal error'},
31
33
  12 => {r: true, c: 'TRANSFER_ERROR', m: 'Error establishing control connection',
32
- a: 'Error establishing SSH connection (check SSH port and firewall)'},
34
+ a: 'Error establishing SSH connection (check SSH port and firewall)'},
33
35
  13 => {r: true, c: 'TRANSFER_TIMEOUT', m: 'Timeout establishing control connection',
34
- a: 'Timeout establishing SSH connection (check SSH port and firewall)'},
36
+ a: 'Timeout establishing SSH connection (check SSH port and firewall)'},
35
37
  14 => {r: true, c: 'CONNECTION_ERROR', m: 'Error establishing data connection',
36
- a: 'Error establishing UDP connection (check UDP port and firewall)'},
38
+ a: 'Error establishing UDP connection (check UDP port and firewall)'},
37
39
  15 => {r: true, c: 'CONNECTION_TIMEOUT', m: 'Timeout establishing data connection',
38
- a: 'Timeout establishing UDP connection (check UDP port and firewall)'},
40
+ a: 'Timeout establishing UDP connection (check UDP port and firewall)'},
39
41
  16 => {r: true, c: 'CONNECTION_LOST', m: 'Connection lost', a: 'Connection lost'},
40
42
  17 => {r: true, c: 'RCVR_SEND_ERROR', m: 'Receiver fails to send feedback', a: 'Network failure (receiver can\'t send feedback)'},
41
43
  18 => {r: true, c: 'RCVR_RECV_ERROR', m: 'Receiver fails to receive data packets', a: 'Network failure (receiver can\'t receive UDP data)'},
@@ -67,34 +69,37 @@ module Aspera
67
69
  44 => {r: true, c: 'PMTU_BRTT_ERROR', m: 'UDP session initiation fatal error', a: 'UDP session initiation fatal error'},
68
70
  45 => {r: true, c: 'BWMEAS_ERROR', m: 'Bandwidth measurement fatal error', a: 'Bandwidth measurement fatal error'},
69
71
  46 => {r: false, c: 'VLINK_ERROR', m: 'Virtual link error', a: 'Virtual link error'},
70
- 47 => {r: false, c: 'CONNECTION_ERROR_HTTP', m: 'Error establishing HTTP connection',
71
- a: 'Error establishing HTTP connection (check HTTP port and firewall)'},
72
- 48 => {r: false, c: 'FILE_ENCRYPTION_ERROR', m: 'File encryption error, e.g. corrupt file',
73
- a: 'File encryption/decryption error, e.g. corrupt file'},
74
- 49 => {r: false, c: 'FILE_DECRYPTION_PASS', m: 'File encryption/decryption error, e.g. corrupt file', a: 'File decryption error, bad passphrase'},
75
- 50 => {r: false, c: 'BAD_CONFIGURATION', m: 'Aspera.conf contains invalid data and was rejected', a: 'Invalid configuration'},
72
+ 47 => {r: true, c: 'CONNECTION_ERROR_HTTP', m: 'Error establishing HTTP connection',
73
+ a: 'Error establishing HTTP connection (check HTTP port and firewall)'},
74
+ 48 => {r: false, c: 'FILE_ENCRYPTION_ERROR', m: 'File encryption error, e.g. corrupt file', a: 'File encryption/decryption error, e.g. corrupt file'},
75
+ 49 => {r: false, c: 'FILE_DECRYPTION_PASS', m: 'File decryption bad passphrase', a: 'File decryption error, bad passphrase'},
76
+ 50 => {r: false, c: 'BAD_CONFIGURATION', m: 'Aspera.conf contains invalid data and was rejected', a: 'Invalid configuration'},
76
77
  51 => {r: false, c: 'INSECURE_CONNECTION', m: 'Remote-host key check failure', a: 'Remote host is not who we expected'},
77
78
  52 => {r: false, c: 'START_VALIDATION_FAILED', m: 'File start validation failed', a: 'File start validation failed'},
78
79
  53 => {r: false, c: 'STOP_VALIDATION_FAILED', m: 'File stop validation failed', a: 'File stop validation failed'},
79
80
  54 => {r: false, c: 'THRESHOLD_VALIDATION_FAILED', m: 'File threshold validation failed', a: 'File threshold validation failed'},
80
81
  55 => {r: false, c: 'FILEPATH_TOO_LONG', m: 'File path/name too long for underlying file system', a: 'File path exceeds underlying file system limit'},
81
82
  56 => {r: false, c: 'ILLEGAL_CHARS_IN_PATH', m: 'Windows path contains illegal characters',
82
- a: 'Path being written to Windows file system contains illegal characters'},
83
- 57 => {r: false, c: 'CHUNK_MUST_MATCH_ALIGNMENT', m: 'Chunk size/start must be aligned with storage', a: 'Chunk size/start must be aligned with storage'},
84
- 58 => {r: false, c: 'VALIDATION_SESSION_ABORT', m: 'Session aborted to due to validation error', a: 'Session aborted to due validation error'},
83
+ a: 'Path being written to Windows file system contains illegal characters'},
84
+ 57 => {r: false, c: 'CHUNK_MUST_MATCH_ALIGNMENT', m: 'Chunk size/start must be aligned with storage',
85
+ a: 'Data size must match storage chunk size and alignment requirements'},
86
+ 58 => {r: false, c: 'VALIDATION_SESSION_ABORT', m: 'Session aborted due to validation error(s)', a: 'Session aborted due to validation error(s)'},
85
87
  59 => {r: false, c: 'REMOTE_STORAGE_ERROR', m: 'Remote storage errored', a: 'Remote storage errored'},
86
- 60 => {r: false, c: 'LUA_SCRIPT_ABORTED_SESSION', m: 'Session aborted due to Lua script abort', a: 'Session aborted due to Lua script abort'},
88
+ 60 => {r: false, c: 'LUA_SCRIPT_ABORTED_SESSION', m: 'Session aborted due to Lua script abort', a: 'Session aborted'},
87
89
  61 => {r: true, c: 'SSEAR_RETRYABLE', m: 'Transfer failed because of a retryable Encryption at Rest error',
88
- a: 'Transfer failed because of a retryable Encryption at Rest error'},
89
- 62 => {r: false, c: 'SSEAR_FATAL', m: 'Transfer failed because of a fatal Encryption at Rest error',
90
- a: 'Transfer failed because of a fatal Encryption at Rest error'},
91
- 63 => {r: false, c: 'LINK_LOOP', m: 'Path refers to a symbolic link loop', a: 'Path refers to a symbolic link loop'},
92
- 64 => {r: false, c: 'CANNOT_RENAME_PARTIAL_FILES', m: 'Can\'t rename a partial file', a: 'Can\'t rename a partial file.'},
93
- 65 => {r: false, c: 'CIPHER_NON_COMPAT_FIPS', m: 'Can\'t use this cipher with FIPS mode enabled', a: 'Can\'t use this cipher with FIPS mode enabled'},
94
- 66 => {r: false, c: 'PEER_REQUIRES_FIPS', m: 'Peer rejects cipher due to FIPS mode enabled on peer',
95
- a: 'Peer rejects cipher due to FIPS mode enabled on peer'}
90
+ a: 'Server-side Encryption At Rest unavailable'},
91
+ 62 => {r: false, c: 'SSEAR_FATAL', m: 'Transfer failed because of a fatal Encryption at Rest error', a: 'Server-side Encryption At Rest failed'},
92
+ 63 => {r: false, c: 'LINK_LOOP', m: 'Path refers to a symbolic link loop', a: 'Symbolic link loop'},
93
+ 64 => {r: false, c: 'CANNOT_RENAME_PARTIAL', m: 'Can\'t rename a partial file', a: 'Cannot rename partial file'},
94
+ 65 => {r: false, c: 'CIPHER_NON_COMPAT_FIPS', m: 'Can\'t use this cipher with FIPS mode enabled', a: 'Cypher not supported in FIPS mode'},
95
+ 66 => {r: false, c: 'PEER_REQUIRES_FIPS', m: 'Peer rejects cipher due to FIPS mode enabled on peer', a: 'Peer requires FIPS be enabled'},
96
+ 67 => {r: false, c: 'UPLOAD_IN_PROGRESS', m: 'Conflict between two transfers', a: 'File upload already in progress'},
97
+ 68 => {r: false, c: 'MIN_VERSION_CHECK', m: 'Minimum version check failed', a: 'Failed to start due to minimum version check'},
98
+ 69 => {r: true, c: 'MAX_ASCP', m: 'Maximum ascp processes on a node exceeded',
99
+ a: 'Failed to start session maximum transfer sessions exceeded'}
96
100
  }.freeze
97
101
  # cspell: disable
102
+ # Same as `as_mgmt_cmd_name_str` (`NOTIFICATION` is twice there too)
98
103
  OPERATIONS = %w[
99
104
  NOP
100
105
  START
@@ -117,8 +122,9 @@ module Aspera
117
122
  CLOSE
118
123
  SKIP
119
124
  ARGSTOP
120
- ]
125
+ ].freeze
121
126
 
127
+ # Same as `as_mgmt_arg_name_str`
122
128
  PARAMETERS = %w[
123
129
  Type
124
130
  File
@@ -241,7 +247,7 @@ module Aspera
241
247
  FilesEncrypt
242
248
  FilesDecrypt
243
249
  DatagramSize
244
- PrepostCommand
250
+ Stalled
245
251
  XoptFlags
246
252
  VLinkVersion
247
253
  PeerVLinkVersion
@@ -273,23 +279,36 @@ module Aspera
273
279
  PostTransferValidation
274
280
  OverwritePolicyCap
275
281
  ExtraCreatePolicy
276
- ]
282
+ FipsModeEnabled
283
+ ].freeze
277
284
  # Management port start message
278
285
  PROT_VERSION = '2'
279
286
  MGT_HEADER = "FASPMGR #{PROT_VERSION}"
280
287
  # empty line is separator to end event information
281
288
  MGT_FRAME_SEPARATOR = ''
282
289
  # fields description for JSON generation
283
- # cspell: disable
284
- INTEGER_FIELDS = %w[Bytescont FaspFileArgIndex StartByte Rate MinRate Port Priority RateCap MinRateCap TCPPort CreatePolicy TimePolicy
290
+ # Integer fields may also be a percentage (e.g. `Rate: 50%`): kept as String
291
+ INTEGER_FIELDS = %w[Bytescont FaspFileArgIndex StartByte EndByte Rate MinRate Port Priority RateCap MinRateCap PriorityCap TCPPort CreatePolicy TimePolicy
285
292
  DatagramSize XoptFlags VLinkVersion PeerVLinkVersion DSPipelineDepth PeerDSPipelineDepth ReadBlockSize WriteBlockSize
286
- ClusterNumNodes ClusterNodeId Size Written Loss FileBytes PreTransferBytes TransferBytes PMTU Elapsedusec ArgScansAttempted
287
- ArgScansCompleted PathScansAttempted FileScansCompleted TransfersAttempted TransfersPassed Delay].freeze
293
+ ClusterNumNodes ClusterNodeId Size Written Loss FileBytes PreTransferBytes PartialPreTransferBytes TransferBytes PMTU Elapsedusec Delay
294
+ Code RetryTimeout XferRetry MoveRangeLow MoveRangeHigh
295
+ PreTransferFiles PreTransferDirs PreTransferSpecial PreTransferFailed PreTransferExcluded
296
+ ArgScansAttempted ArgScansCompleted PathScansAttempted PathScansFailed PathScansIrregular PathScansExcluded
297
+ DirScansCompleted FileScansCompleted DirCreatesAttempted DirCreatesFailed DirCreatesPassed
298
+ TransfersAttempted TransfersFailed TransfersPassed TransfersSkipped
299
+ ArgTransfersAttempted ArgTransfersFailed ArgTransfersPassed ArgTransfersSkipped].freeze
288
300
  BOOLEAN_FIELDS = %w[Encryption Remote RateLock MinRateLock PolicyLock FilesEncrypt FilesDecrypt VLinkLocalEnabled VLinkRemoteEnabled
289
- MoveRange Keepalive TestLogin UseProxy Precalc RTTAutocorrect].freeze
301
+ MoveRange Keepalive TestLogin UseProxy Precalc RTTAutocorrect Stalled PostTransferValidation FipsModeEnabled].freeze
290
302
  BOOLEAN_TRUE = 'Yes'
303
+ INTEGER_VALUE = /\A-?\d+\z/
304
+ # Fields with path values, escaped on management port (`as_mgmt_escape_path`)
305
+ PATH_FIELDS = %w[Source Destination File].freeze
306
+ # Characters escaped in path values: `ESC` followed by the replacement
307
+ PATH_ESCAPE = "\e"
308
+ PATH_ESCAPES = {"\n" => 'n', "\r" => 'r', PATH_ESCAPE => PATH_ESCAPE}.freeze
291
309
 
292
- private_constant :OPERATIONS, :PARAMETERS, :MGT_HEADER, :MGT_FRAME_SEPARATOR, :INTEGER_FIELDS, :BOOLEAN_FIELDS, :BOOLEAN_TRUE
310
+ private_constant :OPERATIONS, :PARAMETERS, :MGT_HEADER, :MGT_FRAME_SEPARATOR, :INTEGER_FIELDS, :BOOLEAN_FIELDS, :BOOLEAN_TRUE, :INTEGER_VALUE,
311
+ :PATH_FIELDS, :PATH_ESCAPE, :PATH_ESCAPES
293
312
  # cspell: enable
294
313
 
295
314
  class << self
@@ -305,7 +324,7 @@ module Aspera
305
324
  # Translate snake case event name to native
306
325
  # @param name [String] Field name
307
326
  def field_snake_to_native(name)
308
- field = name.delete('_')
327
+ field = name.to_s.delete('_')
309
328
  result = PARAMETERS.find { |w| w.casecmp?(field) }
310
329
  Aspera.assert(!result.nil?) { "No such field: #{name}" }
311
330
  result
@@ -315,7 +334,7 @@ module Aspera
315
334
  def event_native_to_snake(event)
316
335
  event.each_with_object({}) do |(key, value), h|
317
336
  h[field_native_to_snake(key)] =
318
- if INTEGER_FIELDS.include?(key) then value.to_i
337
+ if INTEGER_FIELDS.include?(key) && INTEGER_VALUE.match?(value) then value.to_i
319
338
  elsif BOOLEAN_FIELDS.include?(key) then value.eql?(BOOLEAN_TRUE)
320
339
  else
321
340
  value
@@ -323,12 +342,36 @@ module Aspera
323
342
  end
324
343
  end
325
344
 
345
+ # Escape path value for management port: line breaks and escape char
346
+ # @param value [String] Path
347
+ # @return [String] Escaped path
348
+ def escape_path(value)
349
+ value.gsub(/[\n\r\e]/) { |char| "#{PATH_ESCAPE}#{PATH_ESCAPES[char]}" }
350
+ end
351
+
352
+ # Reverse of `escape_path`
353
+ # @param value [String] Escaped path
354
+ # @return [String] Path
355
+ def unescape_path(value)
356
+ value.gsub(/\e([nr\e])/) { PATH_ESCAPES.key(Regexp.last_match(1)) }
357
+ end
358
+
326
359
  # Build command to send on management port
360
+ # Frame: header, one line per field, empty line (last empty element adds the final line break)
327
361
  # @param data [Hash] keys are snake case: e.g. {'type'=>'START','source'=>_path_,'destination'=>_path_}
328
362
  # @return [String] frame to send on management port
329
363
  def command_to_stream(data)
330
364
  data
331
- .map { |key, value| "#{field_snake_to_native(key)}: #{value}" }
365
+ .map do |key, value|
366
+ name = field_snake_to_native(key)
367
+ value = value.to_s
368
+ if PATH_FIELDS.include?(name)
369
+ value = escape_path(value)
370
+ else
371
+ Aspera.assert(!value.match?(/[\r\n]/)) { "mgt port: line break in value of #{name}" }
372
+ end
373
+ "#{name}: #{value}"
374
+ end
332
375
  .unshift(MGT_HEADER)
333
376
  .push(MGT_FRAME_SEPARATOR, '')
334
377
  .join("\n")
@@ -347,21 +390,24 @@ module Aspera
347
390
  # @param line [String] line of mgt port event
348
391
  # @return [Hash] event hash or nil if event is not yet complete
349
392
  def process_line(line)
350
- # Log.log.debug{"line=[#{line}]"}
351
393
  case line
352
394
  when MGT_HEADER
353
395
  # begin event
396
+ Log.log.warn { "mgt port: incomplete event discarded: #{@event_build}" } unless @event_build.nil?
354
397
  @event_build = {}
355
- when /^([^:]+): (.*)$/
356
- Aspera.assert_type(@event_build, Hash) { 'mgt port: unexpected line: data without header' }
357
- # event field
358
- @event_build[Regexp.last_match(1)] = Regexp.last_match(2)
359
398
  when MGT_FRAME_SEPARATOR
360
399
  Aspera.assert_type(@event_build, Hash) { 'mgt port: unexpected line: end frame without header' }
361
400
  @last_event = @event_build
362
401
  @event_build = nil
363
402
  return @last_event
364
- else Aspera.error_unexpected_value(line) { 'mgt port' }
403
+ else
404
+ # event field: name up to first colon, optional single space (as `as_mgmt_buffered_read_msg`)
405
+ name, value = line.split(':', 2)
406
+ Aspera.error_unexpected_value(line) { 'mgt port' } if value.nil?
407
+ Aspera.assert_type(@event_build, Hash) { 'mgt port: unexpected line: data without header' }
408
+ value = value.delete_prefix(' ')
409
+ value = self.class.unescape_path(value) if PATH_FIELDS.include?(name)
410
+ @event_build[name] = value
365
411
  end
366
412
  return
367
413
  end
@@ -3,6 +3,7 @@
3
3
  require 'aspera/agent/factory'
4
4
  require 'aspera/schema/registry'
5
5
  require 'aspera/schema/reader'
6
+ require 'aspera/schema/documentation'
6
7
 
7
8
  module Aspera
8
9
  module Cli
@@ -101,33 +102,18 @@ module Aspera
101
102
  def action_agents_show(agent_name:, **)
102
103
  names = Agent::Factory::ALL[agent_name]
103
104
  schema_key = AGENT_SCHEMA_KEY[agent_name]
104
- agent_info = {
105
+ Result::SingleObject.new({
105
106
  'name' => agent_name.to_s,
106
107
  'short' => names[:short].to_s,
107
108
  'description' => schema_key ? Schema::Registry.instance.reader(schema_key).current['description'].to_s : '(no configurable parameters)'
108
- }
109
- if schema_key
110
- properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
111
- rows = properties.map do |pname, pdef|
112
- row = {'parameter' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
113
- row['required'] = (Schema::Registry.instance.reader(schema_key).current['required'] || []).include?(pname) ? 'yes' : 'no'
114
- row['default'] = pdef.key?('default') ? pdef['default'].inspect : ''
115
- row['enum'] = pdef.key?('enum') ? pdef['enum'].join(', ') : ''
116
- row
117
- end
118
- return Result::ObjectList.new(rows, fields: %w[parameter required type default enum description])
119
- end
120
- Result::SingleObject.new(agent_info)
109
+ })
121
110
  end
122
111
 
123
112
  def action_agents_parameters(agent_name:, **)
124
113
  schema_key = AGENT_SCHEMA_KEY[agent_name]
125
114
  return Result::Nothing.new if schema_key.nil?
126
- properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
127
- rows = properties.map do |pname, pdef|
128
- {'name' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
129
- end
130
- Result::ObjectList.new(rows, fields: %w[name type description])
115
+ builder = Schema::Documentation.new(TerminalFormatter, Schema::Registry.instance.reader(schema_key)).build
116
+ Result::ObjectList.new(builder.rows, fields: builder.columns)
131
117
  end
132
118
  end
133
119
  end
@@ -16,7 +16,7 @@ require 'aspera/persistency_folder'
16
16
  require 'aspera/rest'
17
17
  require 'aspera/log'
18
18
  require 'aspera/assert'
19
- require 'aspera/oauth'
19
+ require 'aspera/oauth/client/web'
20
20
  require 'aspera/ssl'
21
21
  require 'aspera/schema/registry'
22
22
 
@@ -174,7 +174,6 @@ module Aspera
174
174
  OAuth::Web.additional_info = "#{Info::CMD_NAME} v#{Cli::VERSION}"
175
175
  Transfer::Parameters.file_list_folder = File.join(@context.main_folder, FILE_LIST_FOLDER_NAME)
176
176
  Rest::ErrorAnalyzer.instance.log_file = File.join(@context.main_folder, REST_EXCEPTIONS_LOG_FILENAME)
177
- Rest::AsperaErrors.register_handlers
178
177
  end
179
178
 
180
179
  # @return [String] ~/.aspera