aspera-cli 4.27.3 → 4.27.5

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 (79) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +118 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +1 -0
  6. data/docs/README.md +224 -42
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +0 -4
  9. data/lib/aspera/agent/desktop.rb +0 -4
  10. data/lib/aspera/agent/direct.rb +49 -21
  11. data/lib/aspera/agent/node.rb +6 -5
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/faspex.rb +2 -0
  14. data/lib/aspera/api/httpgw.rb +1 -2
  15. data/lib/aspera/api/node.rb +3 -3
  16. data/lib/aspera/ascp/installation.rb +1 -1
  17. data/lib/aspera/cli/async_transfer_store.rb +10 -9
  18. data/lib/aspera/cli/bootstrapper.rb +3 -1
  19. data/lib/aspera/cli/command_registry.rb +69 -7
  20. data/lib/aspera/cli/command_spec.rb +1 -1
  21. data/lib/aspera/cli/extended_value.rb +4 -3
  22. data/lib/aspera/cli/formatter.rb +10 -8
  23. data/lib/aspera/cli/http.rb +8 -20
  24. data/lib/aspera/cli/option_types.rb +3 -1
  25. data/lib/aspera/cli/option_value.rb +16 -19
  26. data/lib/aspera/cli/options.schema.yaml +86 -10
  27. data/lib/aspera/cli/parser.rb +30 -18
  28. data/lib/aspera/cli/plugins/aoc.rb +133 -156
  29. data/lib/aspera/cli/plugins/ats.rb +1 -7
  30. data/lib/aspera/cli/plugins/base.rb +40 -34
  31. data/lib/aspera/cli/plugins/config.rb +24 -11
  32. data/lib/aspera/cli/plugins/console.rb +1 -1
  33. data/lib/aspera/cli/plugins/faspex5.rb +30 -11
  34. data/lib/aspera/cli/plugins/faspio.rb +1 -1
  35. data/lib/aspera/cli/plugins/node.rb +26 -19
  36. data/lib/aspera/cli/plugins/orchestrator.rb +73 -44
  37. data/lib/aspera/cli/plugins/preview.rb +21 -19
  38. data/lib/aspera/cli/plugins/server.rb +3 -4
  39. data/lib/aspera/cli/plugins/shares.rb +21 -24
  40. data/lib/aspera/cli/preset_actions.rb +28 -18
  41. data/lib/aspera/cli/preset_manager.rb +34 -19
  42. data/lib/aspera/cli/prompt.rb +2 -1
  43. data/lib/aspera/cli/result.rb +33 -22
  44. data/lib/aspera/cli/runner.rb +1 -5
  45. data/lib/aspera/cli/sync_actions.rb +17 -16
  46. data/lib/aspera/cli/transfer_actions.rb +14 -3
  47. data/lib/aspera/cli/transfer_agent.rb +6 -4
  48. data/lib/aspera/cli/transfer_progress.rb +290 -55
  49. data/lib/aspera/cli/version.rb +1 -1
  50. data/lib/aspera/cli/wizard.rb +1 -1
  51. data/lib/aspera/coverage.rb +0 -1
  52. data/lib/aspera/environment.rb +29 -5
  53. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  54. data/lib/aspera/keychain/factory.rb +2 -1
  55. data/lib/aspera/log.rb +25 -2
  56. data/lib/aspera/node_emulator.rb +759 -0
  57. data/lib/aspera/oauth/base.rb +2 -1
  58. data/lib/aspera/oauth/factory.rb +6 -3
  59. data/lib/aspera/oauth/json_credentials.rb +34 -0
  60. data/lib/aspera/oauth/jwt.rb +3 -4
  61. data/lib/aspera/oauth.rb +1 -0
  62. data/lib/aspera/persistency_folder.rb +1 -3
  63. data/lib/aspera/preview/generator.rb +4 -1
  64. data/lib/aspera/preview/options.schema.yaml +119 -0
  65. data/lib/aspera/rest/aspera_errors.rb +12 -0
  66. data/lib/aspera/rest/client.rb +28 -19
  67. data/lib/aspera/rest/list.rb +14 -8
  68. data/lib/aspera/rest/parameters.rb +2 -2
  69. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  70. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  71. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  72. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  73. data/lib/aspera/schema/reader.rb +12 -18
  74. data/lib/aspera/schema/registry.rb +6 -1
  75. data.tar.gz.sig +0 -0
  76. metadata +5 -3
  77. metadata.gz.sig +0 -0
  78. data/lib/aspera/node_simulator.rb +0 -345
  79. data/lib/aspera/preview/options.rb +0 -45
@@ -72,7 +72,7 @@ module Aspera
72
72
  username: options.get_option(:username, mandatory: true),
73
73
  password: options.get_option(:password, mandatory: true)
74
74
  },
75
- test_args: 'files browse /'
75
+ test_args: 'browse /'
76
76
  }
77
77
  end
78
78
 
@@ -159,7 +159,7 @@ module Aspera
159
159
  end
160
160
 
161
161
  Sync::Operations::DIRECTIONS.each do |dir|
162
- define_method(:"action_sync_#{dir}") { |path:, sync_info: {}, **| run_sync_transfer(dir, path: path, sync_info: sync_info) { server_transfer_spec } }
162
+ define_action_method([:sync, dir]) { |path:, sync_info: {}, **| run_sync_transfer(dir, path: path, sync_info: sync_info) { server_transfer_spec } }
163
163
  end
164
164
 
165
165
  # --- DSL ---
@@ -187,8 +187,7 @@ module Aspera
187
187
  Sync::Operations::DIRECTIONS.each do |dir|
188
188
  command dir, description: "#{dir.capitalize}-sync with server", transfer_paths: :send, arguments: SyncActions::PATH_AND_INFO_ARGS
189
189
  end
190
- command :admin, description: 'Manage sync database (admin operations)'
191
- SyncActions.register_sync_admin_commands(self, :admin)
190
+ sync_admin_commands :admin
192
191
  end
193
192
 
194
193
  commands_under :health do
@@ -247,19 +247,22 @@ module Aspera
247
247
  lookup: :lookup_share_id
248
248
  command :user_permissions,
249
249
  description: 'Manage user permissions on a share',
250
- arguments: [{name: :share_id, type: :identifier, lookup: :lookup_share_id}]
250
+ arguments: [{name: :share_id, type: :identifier, lookup: :lookup_share_id}],
251
+ setup: :setup_share_permissions
251
252
  command :group_permissions,
252
253
  description: 'Manage group permissions on a share',
253
- arguments: [{name: :share_id, type: :identifier, lookup: :lookup_share_id}]
254
+ arguments: [{name: :share_id, type: :identifier, lookup: :lookup_share_id}],
255
+ setup: :setup_share_permissions
254
256
  end
255
257
 
256
- # share user_permissions and group_permissions: Rails only exposes index+show (read-only)
257
- %i[user_permissions group_permissions].each do |perm_type|
258
+ # share user_permissions and group_permissions: full CRUD, identified by the user or group id
259
+ {user_permissions: :user, group_permissions: :group}.each do |perm_type, entity_type|
258
260
  commands_under [:admin, :share, perm_type] do
259
- SHARE_PERMISSIONS_OPS.each do |op|
260
- command op, description: "#{op.capitalize} #{entity_noun(perm_type, singular: false)}",
261
- arguments: op.eql?(:show) ? [{name: :permission_id, type: :identifier}] : nil
262
- end
261
+ crud_commands entity: :"share_#{perm_type}",
262
+ api: :@api_shares_admin,
263
+ name: entity_noun(perm_type),
264
+ id_name: :"#{entity_type}_id",
265
+ lookup: :"lookup_shares_#{entity_type}_all_id"
263
266
  end
264
267
  end
265
268
 
@@ -286,6 +289,12 @@ module Aspera
286
289
  {}
287
290
  end
288
291
 
292
+ # share_id: resolved by Phase A via arguments:(:identifier, lookup: :lookup_share_id) on the perm_type node
293
+ # @return [Hash] ctx with API path of user and group permissions of the share
294
+ def setup_share_permissions(share_id:, **)
295
+ %i[user_permissions group_permissions].to_h { |perm_type| [:"share_#{perm_type}", "data/shares/#{share_id}/#{perm_type}"] }
296
+ end
297
+
289
298
  # Lookup a share id by field/value using the admin API.
290
299
  def lookup_share_id(field, value, **)
291
300
  Rest::List.lookup_entity_generic(entity: 'share', field: field, value: value) { @api_shares_admin.read('data/shares') }['id']
@@ -328,7 +337,7 @@ module Aspera
328
337
  # @param op [Symbol] CRUD operation
329
338
  # entity_id: resolved via arguments:(:identifier) on the setting node
330
339
  # permission_id: (share_permissions show) and data: (modify) resolved via arguments: on the leaf
331
- def action_admin_entity_setting(entity_type, location, setting, op, entity_id:, permission_id: nil, data: nil)
340
+ def admin_entity_setting(entity_type, location, setting, op, entity_id:, permission_id: nil, data: nil)
332
341
  entity_path = "#{admin_entity_path(entity_type, location)}/#{entity_id}/#{setting}"
333
342
  send(:"entity_#{op}", api: @api_shares_admin, entity: entity_path, id: permission_id, is_singleton: !setting.eql?(:share_permissions), data: data)
334
343
  end
@@ -336,7 +345,7 @@ module Aspera
336
345
  # Shared handler for :users (group only)
337
346
  # group_id: resolved by Phase A via arguments:(:identifier) on the :users node
338
347
  # user_id: and data: resolved via arguments: on the leaf (GROUP_USERS_ARGS)
339
- def action_admin_entity_users(entity_type, location, op, group_id:, user_id: nil, data: nil)
348
+ def admin_entity_users(entity_type, location, op, group_id:, user_id: nil, data: nil)
340
349
  path = admin_entity_path(entity_type, location)
341
350
  prefix = location.eql?(:all) ? '' : "#{location}_"
342
351
  send(:"entity_#{op}", api: @api_shares_admin, entity: "#{path}/#{group_id}/#{prefix}users", id: user_id, data: data)
@@ -353,7 +362,7 @@ module Aspera
353
362
  setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
354
363
  setting_ops.each do |op|
355
364
  define_action_method([:admin, entity_type, location, setting, op]) do |**kwargs|
356
- action_admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], permission_id: kwargs[:permission_id], data: kwargs[setting])
365
+ admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], permission_id: kwargs[:permission_id], data: kwargs[setting])
357
366
  end
358
367
  end
359
368
  end
@@ -362,7 +371,7 @@ module Aspera
362
371
  # group users: no create route (Rails only exposes index+show+update+destroy)
363
372
  GROUP_USERS_OPS.each do |op|
364
373
  define_action_method([:admin, entity_type, location, :users, op]) do |group_id:, user_id: nil, user: nil, **|
365
- action_admin_entity_users(entity_type, location, op, group_id: group_id, user_id: user_id, data: user)
374
+ admin_entity_users(entity_type, location, op, group_id: group_id, user_id: user_id, data: user)
366
375
  end
367
376
  end
368
377
  end
@@ -389,18 +398,6 @@ module Aspera
389
398
  end
390
399
  end
391
400
  end
392
-
393
- # Handlers for admin > share > user_permissions|group_permissions > op
394
- # share_id: resolved by Phase A via arguments:(:identifier, lookup: :lookup_share_id) on the perm_type node
395
- # permission_id: resolved via arguments: on the show leaf
396
- # Rails only exposes index+show for share permissions (read-only)
397
- %i[user_permissions group_permissions].each do |perm_type|
398
- SHARE_PERMISSIONS_OPS.each do |op|
399
- define_action_method([:admin, :share, perm_type, op]) do |share_id:, permission_id: nil, **|
400
- send(:"entity_#{op}", api: @api_shares_admin, entity: "data/shares/#{share_id}/#{perm_type}", id: permission_id)
401
- end
402
- end
403
- end
404
401
  end
405
402
  end
406
403
  end
@@ -52,6 +52,14 @@ module Aspera
52
52
 
53
53
  SECRET_KEYWORDS = %w[password secret].freeze
54
54
 
55
+ # @param name [String] preset name, or `GLOBAL` for the global default preset
56
+ # @param create [Boolean] `true` for an action writing in the preset: the global default preset is declared and created if needed
57
+ # @return [String] preset name
58
+ def preset_name(name, create: false)
59
+ return name unless name.eql?(GLOBAL_DEFAULT_KEYWORD)
60
+ presets.global_default_preset(create: create)
61
+ end
62
+
55
63
  # If `option_name` ends with a secret keyword and the vault is configured,
56
64
  # move the clear-text value into the vault and replace it with a @vault: reference.
57
65
  # @param preset [Hash] the preset hash (modified in place)
@@ -82,16 +90,16 @@ module Aspera
82
90
  public
83
91
 
84
92
  def action_preset_show(name:, **)
85
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
93
+ name = preset_name(name)
86
94
  cp = presets.config_presets
87
- raise "no such preset: #{name}" unless cp.key?(name)
95
+ raise Cli::BadArgument, "no such preset: #{name}" unless cp.key?(name)
88
96
  Result::SingleObject.new(PresetManager.deep_clone(cp[name]))
89
97
  end
90
98
 
91
99
  def action_preset_delete(name:, **)
92
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
100
+ name = preset_name(name)
93
101
  cp = presets.config_presets
94
- raise "no such preset: #{name}" unless cp.key?(name)
102
+ raise Cli::BadArgument, "no such preset: #{name}" unless cp.key?(name)
95
103
  cp.delete(name)
96
104
  # Remove default declarations referring to the deleted preset
97
105
  defaults = cp[PresetManager::Key::DEFAULTS]
@@ -103,11 +111,11 @@ module Aspera
103
111
  end
104
112
 
105
113
  def action_preset_get(name:, param_name:, **)
106
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
114
+ name = preset_name(name)
107
115
  cp = presets.config_presets
108
- raise "no such preset: #{name}" unless cp.key?(name)
116
+ raise Cli::BadArgument, "no such preset: #{name}" unless cp.key?(name)
109
117
  value = cp[name][param_name]
110
- raise "no such option in preset #{name} : #{param_name}" if value.nil?
118
+ raise Cli::BadArgument, "no such option in preset #{name}: #{param_name}" if value.nil?
111
119
  case value
112
120
  when Numeric, String then return Result::Text.new(ExtendedValue.instance.evaluate(value.to_s, context: 'preset'))
113
121
  end
@@ -115,15 +123,15 @@ module Aspera
115
123
  end
116
124
 
117
125
  def action_preset_unset(name:, param_name:, **)
118
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
126
+ name = preset_name(name)
119
127
  cp = presets.config_presets
120
- raise "no such preset: #{name}" unless cp.key?(name)
128
+ raise Cli::BadArgument, "no such preset: #{name}" unless cp.key?(name)
121
129
  cp[name].delete(param_name)
122
130
  Result::Status.new("Removed: #{name}: #{param_name}")
123
131
  end
124
132
 
125
133
  def action_preset_set(name:, param_name:, param_value:, **)
126
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
134
+ name = preset_name(name, create: true)
127
135
  param_name = Parser.option_line_to_name(param_name)
128
136
  presets.set_key(name, param_name, Parser.smart_convert(param_value))
129
137
  secure_preset_option(presets.config_presets[name], name, param_name)
@@ -131,15 +139,15 @@ module Aspera
131
139
  end
132
140
 
133
141
  def action_preset_initialize(name:, preset:, **)
134
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
142
+ name = preset_name(name, create: true)
135
143
  cp = presets.config_presets
136
144
  Log.log.warn { "configuration already exists: #{name}, overwriting" } if cp.key?(name)
137
145
  cp[name] = preset
138
- Result::Status.new("Modified: #{@option_config_file}")
146
+ Result::Status.new("Initialized: #{name}")
139
147
  end
140
148
 
141
149
  def action_preset_update(name:, **)
142
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
150
+ name = preset_name(name, create: true)
143
151
  unprocessed_options = options.unprocessed_options_with_value
144
152
  Log.dump(:opts, unprocessed_options)
145
153
  cp = presets.config_presets
@@ -150,14 +158,16 @@ module Aspera
150
158
  end
151
159
 
152
160
  def action_preset_ask(name:, option_names:, **)
153
- name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
161
+ name = preset_name(name, create: true)
154
162
  cp = presets.config_presets
155
163
  cp[name] ||= {}
156
164
  # Option names are only known at runtime: cannot be declared as interactive arguments
157
- option_names.each do |option_name|
158
- option_value = options.get_interactive(option_name)
159
- cp[name][option_name] = option_value
160
- secure_preset_option(cp[name], name, option_name)
165
+ options.with_interactive do
166
+ option_names.each do |option_name|
167
+ option_value = options.get_interactive(option_name)
168
+ cp[name][option_name] = option_value
169
+ secure_preset_option(cp[name], name, option_name)
170
+ end
161
171
  end
162
172
  Result::Status.new("Updated: #{name}")
163
173
  end
@@ -59,30 +59,38 @@ module Aspera
59
59
 
60
60
  # Read and validate the YAML config file.
61
61
  # Sets @config_presets and @checksum_on_disk.
62
+ # A file that cannot be read (YAML error, duplicate keys, access) is left untouched: it shall be fixed.
63
+ # A file with an unexpected structure is renamed for manual conversion, and a new one is created next time.
62
64
  def read_config_file
63
65
  Log.log.debug { "config file is: #{@config_file}".red }
64
66
  if File.exist?(@config_file)
65
67
  Log.log.debug { "loading #{@config_file}" }
66
- @config_presets = Yaml.safe_load(File.read(@config_file))
68
+ begin
69
+ @config_presets = Yaml.safe_load(File.read(@config_file))
70
+ rescue Psych::SyntaxError
71
+ Log.log.error { "YAML error in config file: #{@config_file}" }
72
+ raise
73
+ rescue StandardError => e
74
+ raise Cli::Error, "Config file #{@config_file}: #{e.message}"
75
+ end
67
76
  @checksum_on_disk = checksum
68
77
  else
69
78
  Log.log.warn { "No config file found. New configuration file: #{@config_file}" }
70
79
  @config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
71
80
  # @checksum_on_disk remains nil: will be saved on first write
72
81
  end
73
- validate_config_presets!
74
- rescue Psych::SyntaxError => e
75
- Log.log.error('YAML error in config file')
76
- raise e
77
- rescue StandardError => e
78
- Log.log.debug { "-> #{e.class.name} : #{e}" }
79
- if File.exist?(@config_file)
80
- new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
81
- File.rename(@config_file, new_name)
82
- Log.log.warn { "Renamed config file to #{new_name}." }
83
- Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
82
+ begin
83
+ validate_config_presets!
84
+ rescue StandardError => e
85
+ Log.log.debug { "-> #{e.class.name} : #{e}" }
86
+ if File.exist?(@config_file)
87
+ new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
88
+ File.rename(@config_file, new_name)
89
+ Log.log.warn { "Renamed config file to #{new_name}." }
90
+ Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
91
+ end
92
+ raise Cli::Error, e.to_s
84
93
  end
85
- raise Cli::Error, e.to_s
86
94
  end
87
95
 
88
96
  # Save to disk only if content changed since last load/save.
@@ -132,7 +140,11 @@ module Aspera
132
140
  Aspera.assert(!include_path.include?(config_name), type: Cli::Error) { 'loop in include' }
133
141
  include_path = include_path.clone
134
142
  current = @config_presets
135
- config_name.split(PRESET_DIG_SEPARATOR).each do |name|
143
+ # A preset name may contain a dot (e.g. a host name): the longest name of an existing preset is used
144
+ parts = config_name.split(PRESET_DIG_SEPARATOR)
145
+ count = parts.length.downto(1).find { |n| @config_presets.key?(parts.first(n).join(PRESET_DIG_SEPARATOR)) } || 1
146
+ names = [parts.first(count).join(PRESET_DIG_SEPARATOR)] + parts.drop(count)
147
+ names.each do |name|
136
148
  Aspera.assert_type(current, Hash, type: Cli::Error) { "sub key: #{include_path}" }
137
149
  include_path.push(name)
138
150
  current = current[name]
@@ -188,14 +200,17 @@ module Aspera
188
200
  nil
189
201
  end
190
202
 
191
- # @return [String] name of the global default preset, creating it if needed
192
- def global_default_preset
203
+ # @param create [Boolean] `true`: declare and create the global default preset if needed (to write in it)
204
+ # @return [String] name of the global default preset
205
+ def global_default_preset(create: true)
193
206
  result = plugin_default_name(CONF_GLOBAL_SYM)
194
207
  if result.nil?
195
208
  result = Key::GLOBAL
196
- set_key(Key::DEFAULTS, CONF_GLOBAL_SYM, result)
197
- # A default declaration requires the preset to exist
198
- @config_presets[result] ||= {}
209
+ if create
210
+ set_key(Key::DEFAULTS, CONF_GLOBAL_SYM, result)
211
+ # A default declaration requires the preset to exist
212
+ @config_presets[result] ||= {}
213
+ end
199
214
  end
200
215
  result
201
216
  end
@@ -8,12 +8,13 @@ module Aspera
8
8
  # Console input
9
9
  module Prompt
10
10
  # Prompt user for console input
11
+ # The prompt is displayed on `stderr`, like for sensitive input, so that `stdout` contains only results.
11
12
  # @param prompt [String] prompt string to display
12
13
  # @param sensitive [Boolean] whether to hide typed input
13
14
  # @return [String] user input stripped of trailing newline
14
15
  def prompt_user_input(prompt, sensitive: false)
15
16
  return $stdin.getpass("#{prompt}> ") if sensitive
16
- print("#{prompt}> ")
17
+ $stderr.print("#{prompt}> ")
17
18
  line = $stdin.gets
18
19
  Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
19
20
  line.chomp
@@ -43,10 +43,7 @@ module Aspera
43
43
  case formatter.format_type
44
44
  when :nagios then Nagios.process(@data)
45
45
  when :text then formatter.display_message(:data, @data.to_s)
46
- when :ruby then formatter.display_message(:data, PP.pp(filtered_data, +''))
47
- when :json then formatter.display_message(:data, JSON.generate(filtered_data))
48
- when :jsonpp then formatter.display_message(:data, JSON.pretty_generate(filtered_data))
49
- when :yaml then formatter.display_message(:data, YAML.dump(filtered_data))
46
+ when *STRUCTURED_FORMATS then display_structured(formatter, filtered_data)
50
47
  when :image
51
48
  if @data.nil?
52
49
  formatter.display_message(:data, formatter.special_format('null (no image)'))
@@ -81,6 +78,27 @@ module Aspera
81
78
  else Aspera.error_unexpected_value(formatter.format_type) { 'format' }
82
79
  end
83
80
  end
81
+
82
+ # Formats rendering the data structure itself
83
+ STRUCTURED_FORMATS = %i[ruby json jsonpp yaml].freeze
84
+
85
+ private
86
+
87
+ # Display a value in one of STRUCTURED_FORMATS
88
+ # @param formatter [Formatter] The formatter to use
89
+ # @param value [Object] Value to render
90
+ # @return [nil]
91
+ def display_structured(formatter, value)
92
+ text =
93
+ case formatter.format_type
94
+ when :ruby then PP.pp(value, +'')
95
+ when :json then JSON.generate(value)
96
+ when :jsonpp then JSON.pretty_generate(value)
97
+ when :yaml then YAML.dump(value)
98
+ else Aspera.error_unexpected_value(formatter.format_type) { 'structured format' }
99
+ end
100
+ formatter.display_message(:data, text)
101
+ end
84
102
  end
85
103
 
86
104
  # Special result types - each has its own class
@@ -96,8 +114,11 @@ module Aspera
96
114
 
97
115
  def format(formatter)
98
116
  case formatter.format_type
99
- when :text, :nagios, :ruby, :json, :jsonpp, :yaml
117
+ when :text, :nagios
100
118
  formatter.display_message(:data, @data.to_s)
119
+ when *STRUCTURED_FORMATS
120
+ # An empty list, or no value
121
+ display_structured(formatter, @data.eql?(:empty) ? [] : nil)
101
122
  when :table, :csv
102
123
  if @data.eql?(:nothing)
103
124
  Log.log.debug('no result expected')
@@ -132,8 +153,10 @@ module Aspera
132
153
  def format(formatter)
133
154
  data = formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
134
155
  case formatter.format_type
135
- when :text, :nagios, :ruby, :json, :jsonpp, :yaml
156
+ when :text, :nagios
136
157
  formatter.display_message(:data, data)
158
+ when *STRUCTURED_FORMATS
159
+ display_structured(formatter, data)
137
160
  when :table, :csv
138
161
  formatter.display_message(:info, data)
139
162
  else
@@ -161,14 +184,8 @@ module Aspera
161
184
  case formatter.format_type
162
185
  when :text, :nagios
163
186
  formatter.display_message(:data, data.to_s)
164
- when :ruby
165
- formatter.display_message(:data, PP.pp(data, +''))
166
- when :json
167
- formatter.display_message(:data, JSON.generate(data))
168
- when :jsonpp
169
- formatter.display_message(:data, JSON.pretty_generate(data))
170
- when :yaml
171
- formatter.display_message(:data, YAML.dump(data))
187
+ when *STRUCTURED_FORMATS
188
+ display_structured(formatter, data)
172
189
  when :table, :csv
173
190
  formatter.display_message(:data, data)
174
191
  else
@@ -271,14 +288,8 @@ module Aspera
271
288
  formatter.display_table(@data.map { |i| {@name => i} }, [@name])
272
289
  when :text
273
290
  formatter.display_message(:data, @data.join("\n"))
274
- when :ruby
275
- formatter.display_message(:data, PP.pp(@data, +''))
276
- when :json
277
- formatter.display_message(:data, JSON.generate(@data))
278
- when :jsonpp
279
- formatter.display_message(:data, JSON.pretty_generate(@data))
280
- when :yaml
281
- formatter.display_message(:data, YAML.dump(@data))
291
+ when *STRUCTURED_FORMATS
292
+ display_structured(formatter, @data)
282
293
  else
283
294
  super
284
295
  end
@@ -348,9 +348,7 @@ module Aspera
348
348
  # Create command line manager with arguments
349
349
  @context.options = Parser.new(Info::CMD_NAME, @argv)
350
350
  ExtendedValue.instance.on(EXTEND_ARGS) { |v| @context.options.args_as_extended(v) }
351
- # Formatter: declare metadata (class method), then bind to the instance
352
- Formatter.declare_options(@context.options)
353
- @context.formatter.bind_options(@context.options)
351
+ @context.formatter.declare_options(@context.options)
354
352
  # Compare $0 with expected name
355
353
  current_prog_name = File.basename($PROGRAM_NAME)
356
354
  Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn) { "Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'" }
@@ -378,8 +376,6 @@ module Aspera
378
376
  @context.secret_finder = SecretFinder.new(@context.options, @context.presets)
379
377
  # The TransferAgent plugin may use the @preset parser
380
378
  @context.transfer = TransferAgent.new(@context)
381
- # Add commands for config plugin after all options have been added
382
- @context.config.add_manual_header(false)
383
379
  @context.validate
384
380
  # Set banner when all environment is created so that additional extended value modifiers are known, e.g. @preset
385
381
  end
@@ -17,55 +17,56 @@ module Aspera
17
17
  (23..24).map { |i| "P(#{i})" }).freeze
18
18
  # Positional arguments shared by sync transfer commands (push/pull/bidi) and sync admin commands.
19
19
  PATH_AND_INFO_ARGS = [{name: :path}, {name: :sync_info, type: Hash, mandatory: false, default: {}}].freeze
20
- # Names of the leaf commands registered under any `sync admin` node.
21
- ADMIN_COMMANDS = %i[find status meta counters file_info overview query].freeze
22
20
  # When a plugin class includes SyncActions, register the :sql option
23
- # in that class's DSL registry so Base#initialize auto-declares it.
21
+ # in that class's DSL registry so Base#initialize auto-declares it,
22
+ # and add the class-level DSL methods of ClassMethods.
24
23
  class << self
25
24
  def included(base)
26
25
  base.option(:sql, description: 'SQL suffix appended to sqlite3 queries for admin subcommands (e.g. WHERE clause)')
26
+ base.extend(ClassMethods)
27
27
  end
28
+ end
28
29
 
29
- # DSL helper: register the 7 `sync admin` leaf commands under the given parent path.
30
- # Called at class-load time from any plugin that includes SyncActions.
31
- # @param base [Class] the plugin class (receiver of DSL methods)
32
- # @param admin_path [Symbol, Array<Symbol>] path relative to current scope, e.g. :admin
33
- def register_sync_admin_commands(base, admin_path)
34
- base.commands_under(admin_path) do
35
- base.command(
30
+ # Class-level DSL methods added to the plugin class that includes SyncActions.
31
+ module ClassMethods
32
+ # DSL class method: declare the `sync admin` node and its 7 leaf commands.
33
+ # @param parent [Symbol, Array<Symbol>] path relative to current scope, e.g. :admin
34
+ def sync_admin_commands(parent)
35
+ commands_under(parent, description: 'Manage sync database (admin operations)') do
36
+ command(
36
37
  :find, description: 'Find sync database files', arguments: [{name: :path}],
37
38
  action: lambda do |path:, **|
38
39
  dbs = Sync::Operations.list_db_files(path)
39
40
  Result::ObjectList.new(dbs.keys.map { |n| {name: n, path: dbs[n]} })
40
41
  end
41
42
  )
42
- base.command(
43
+ command(
43
44
  :status, description: 'Show sync session status', arguments: PATH_AND_INFO_ARGS,
44
45
  action: ->(path:, sync_info: {}, **) { Result::SingleObject.new(Sync::Operations.admin_status(async_info_from_args(path: path, sync_info: sync_info))) }
45
46
  )
46
- base.command(
47
+ command(
47
48
  :meta, description: 'Show sync session metadata', arguments: PATH_AND_INFO_ARGS,
48
49
  action: lambda do |path:, sync_info: {}, **|
49
50
  require 'aspera/sync/database'
50
51
  Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).meta(options.get_option(:sql)))
51
52
  end
52
53
  )
53
- base.command(
54
+ command(
54
55
  :counters, description: 'Show sync counters', arguments: PATH_AND_INFO_ARGS,
55
56
  action: lambda do |path:, sync_info: {}, **|
56
57
  require 'aspera/sync/database'
57
58
  Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).counters(options.get_option(:sql)))
58
59
  end
59
60
  )
60
- base.command(:file_info, description: 'Show per-file sync state', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_file_info)
61
- base.command(
61
+ command(:file_info, description: 'Show per-file sync state', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_file_info)
62
+ command(
62
63
  :overview, description: 'Show sync database overview', arguments: PATH_AND_INFO_ARGS,
63
64
  action: lambda do |path:, sync_info: {}, **|
64
65
  require 'aspera/sync/database'
65
66
  Result::ObjectList.new(db_from_args(path: path, sync_info: sync_info).overview, fields: %w[table name type])
66
67
  end
67
68
  )
68
- base.command(
69
+ command(
69
70
  :query, description: 'Execute a raw SQL query', arguments: PATH_AND_INFO_ARGS,
70
71
  action: lambda do |path:, sync_info: {}, **|
71
72
  require 'aspera/sync/database'
@@ -15,7 +15,8 @@ module Aspera
15
15
  #
16
16
  # Architecture:
17
17
  # - remote-daemon agents (desktop, node, connect, transferd): transfer_id + agent_params
18
- # are persisted in AsyncTransferStore; status is re-queried via Agent::Xxx.transfer_status
18
+ # are persisted in AsyncTransferStore (without secrets, taken from current options);
19
+ # status is re-queried via Agent::Xxx.transfer_status
19
20
  # - direct agent: transfers live in Ruby threads - store is the agent's @sessions;
20
21
  # status is not persistable across process restarts (returns an informational message)
21
22
  module TransferActions
@@ -25,10 +26,12 @@ module Aspera
25
26
  store = async_transfer_store
26
27
  entry = store.read(job_id)
27
28
  Aspera.assert(!entry.nil?, type: Cli::BadArgument) { "Unknown job_id: #{job_id}" }
29
+ # Secrets are not persisted: take them from current transfer options (e.g. `--transfer`), for the same agent type
30
+ agent_params = current_agent_params(entry['agent_type']).merge(entry['agent_params'] || {})
28
31
  # Inject the in-process agent reference (direct/httpgw) if still alive in this process.
29
32
  ref = store.agent_ref(job_id)
30
- entry['agent_params']['_agent_ref'] = ref if ref
31
- live = query_live_status(entry)
33
+ agent_params['_agent_ref'] = ref if ref
34
+ live = query_live_status(entry.merge('agent_params' => agent_params))
32
35
  if live
33
36
  entry.merge!(live)
34
37
  store.write(job_id, entry)
@@ -53,6 +56,14 @@ module Aspera
53
56
 
54
57
  private
55
58
 
59
+ # @param agent_type [String] agent of the async transfer
60
+ # @return [Hash] agent parameters from current transfer options (e.g. `--transfer`), if for the same agent type
61
+ def current_agent_params(agent_type)
62
+ current = context.transfer.transfer_options
63
+ return {} unless (current['agent'] || 'direct').to_s.eql?(agent_type)
64
+ current.except('agent', 'asynchronous')
65
+ end
66
+
56
67
  # Lazy accessor - requires context.persistency (available in all Base sub-classes).
57
68
  # Delegate to the TransferAgent's store so that the in-memory agent-ref
58
69
  # registry is shared between the two components in the same process.
@@ -44,7 +44,7 @@ module Aspera
44
44
 
45
45
  extend OptionDeclarator
46
46
 
47
- option :ts, description: 'Override transfer spec values', schema: Schema::Registry::TRANSFER_SPEC
47
+ option :ts, description: 'Override transfer spec values', schema: Schema::Registry::TRANSFER_SPEC, on_set: :user_transfer_spec=
48
48
  option :to_folder, description: 'Destination folder for transferred files'
49
49
  option :sources, description: "How list of transferred files is provided (#{FILE_LIST_OPTIONS.join(',')})", default: FILE_LIST_FROM_ARGS
50
50
  option :src_type, description: 'Type of file list', allowed: %i[list pair], default: :list
@@ -64,8 +64,8 @@ module Aspera
64
64
  @transfer_paths = nil
65
65
  # HTTPGW URL provided by webapp
66
66
  @httpgw_url_lambda = nil
67
- self.class.declare_options(@context.options)
68
- @context.options.on_set(:ts, method(:user_transfer_spec=))
67
+ # Instance variables used by `on_set` callbacks are set above
68
+ self.class.declare_options(@context.options, target: self)
69
69
  @context.options.parse_options!
70
70
  @notification_cb = nil
71
71
  if !@context.options.get_option(:notify_to).nil?
@@ -115,8 +115,10 @@ module Aspera
115
115
  agent_options.merge!(@context.presets.by_name(param_set_name).symbolize_keys)
116
116
  end
117
117
  when :direct
118
- # by default do not display ascp native progress bar
118
+ # By default, no native `ascp` progress bar
119
119
  agent_options[:quiet] = true unless agent_options.key?(:quiet)
120
+ # Native `ascp` progress bar requested: no other progress bar, unless option `progress_bar` is set
121
+ agent_options[:progress] = nil if !agent_options[:quiet] && @context.options.option_def(:progress_bar).source.eql?(:default)
120
122
  agent_options[:check_ignore_cb] = ->(host, port) { @context.http_config.ignore_cert?(host, port) }
121
123
  # JRuby
122
124
  agent_options[:trusted_certs] = @context.http_config.trusted_cert_locations unless agent_options.key?(:trusted_certs)