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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +118 -0
- data/CONTRIBUTING.md +5 -2
- data/bin/ascli +1 -0
- data/docs/README.md +224 -42
- data/lib/aspera/agent/base.rb +7 -2
- data/lib/aspera/agent/connect.rb +0 -4
- data/lib/aspera/agent/desktop.rb +0 -4
- data/lib/aspera/agent/direct.rb +49 -21
- data/lib/aspera/agent/node.rb +6 -5
- data/lib/aspera/agent/transferd.rb +2 -2
- data/lib/aspera/api/faspex.rb +2 -0
- data/lib/aspera/api/httpgw.rb +1 -2
- data/lib/aspera/api/node.rb +3 -3
- data/lib/aspera/ascp/installation.rb +1 -1
- data/lib/aspera/cli/async_transfer_store.rb +10 -9
- data/lib/aspera/cli/bootstrapper.rb +3 -1
- data/lib/aspera/cli/command_registry.rb +69 -7
- data/lib/aspera/cli/command_spec.rb +1 -1
- data/lib/aspera/cli/extended_value.rb +4 -3
- data/lib/aspera/cli/formatter.rb +10 -8
- data/lib/aspera/cli/http.rb +8 -20
- data/lib/aspera/cli/option_types.rb +3 -1
- data/lib/aspera/cli/option_value.rb +16 -19
- data/lib/aspera/cli/options.schema.yaml +86 -10
- data/lib/aspera/cli/parser.rb +30 -18
- data/lib/aspera/cli/plugins/aoc.rb +133 -156
- data/lib/aspera/cli/plugins/ats.rb +1 -7
- data/lib/aspera/cli/plugins/base.rb +40 -34
- data/lib/aspera/cli/plugins/config.rb +24 -11
- data/lib/aspera/cli/plugins/console.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +30 -11
- data/lib/aspera/cli/plugins/faspio.rb +1 -1
- data/lib/aspera/cli/plugins/node.rb +26 -19
- data/lib/aspera/cli/plugins/orchestrator.rb +73 -44
- data/lib/aspera/cli/plugins/preview.rb +21 -19
- data/lib/aspera/cli/plugins/server.rb +3 -4
- data/lib/aspera/cli/plugins/shares.rb +21 -24
- data/lib/aspera/cli/preset_actions.rb +28 -18
- data/lib/aspera/cli/preset_manager.rb +34 -19
- data/lib/aspera/cli/prompt.rb +2 -1
- data/lib/aspera/cli/result.rb +33 -22
- data/lib/aspera/cli/runner.rb +1 -5
- data/lib/aspera/cli/sync_actions.rb +17 -16
- data/lib/aspera/cli/transfer_actions.rb +14 -3
- data/lib/aspera/cli/transfer_agent.rb +6 -4
- data/lib/aspera/cli/transfer_progress.rb +290 -55
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +1 -1
- data/lib/aspera/coverage.rb +0 -1
- data/lib/aspera/environment.rb +29 -5
- data/lib/aspera/keychain/encrypted_hash.rb +1 -1
- data/lib/aspera/keychain/factory.rb +2 -1
- data/lib/aspera/log.rb +25 -2
- data/lib/aspera/node_emulator.rb +759 -0
- data/lib/aspera/oauth/base.rb +2 -1
- data/lib/aspera/oauth/factory.rb +6 -3
- data/lib/aspera/oauth/json_credentials.rb +34 -0
- data/lib/aspera/oauth/jwt.rb +3 -4
- data/lib/aspera/oauth.rb +1 -0
- data/lib/aspera/persistency_folder.rb +1 -3
- data/lib/aspera/preview/generator.rb +4 -1
- data/lib/aspera/preview/options.schema.yaml +119 -0
- data/lib/aspera/rest/aspera_errors.rb +12 -0
- data/lib/aspera/rest/client.rb +28 -19
- data/lib/aspera/rest/list.rb +14 -8
- data/lib/aspera/rest/parameters.rb +2 -2
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
- data/lib/aspera/schema/reader.rb +12 -18
- data/lib/aspera/schema/registry.rb +6 -1
- data.tar.gz.sig +0 -0
- metadata +5 -3
- metadata.gz.sig +0 -0
- data/lib/aspera/node_simulator.rb +0 -345
- data/lib/aspera/preview/options.rb +0 -45
data/lib/aspera/agent/direct.rb
CHANGED
|
@@ -107,8 +107,6 @@ module Aspera
|
|
|
107
107
|
@sessions = []
|
|
108
108
|
# mutex protects global data accessed by threads
|
|
109
109
|
@mutex = Mutex.new
|
|
110
|
-
@pre_calc_sent = false
|
|
111
|
-
@pre_calc_last_size = nil
|
|
112
110
|
# Check on all management messages if that file exists, and if so, read commands from it
|
|
113
111
|
@command_file = File.join(config_dir || '.', "send_#{$PROCESS_ID}")
|
|
114
112
|
end
|
|
@@ -164,6 +162,11 @@ module Aspera
|
|
|
164
162
|
error: nil, # exception if failed
|
|
165
163
|
io: nil, # management port server socket
|
|
166
164
|
token_regenerator: token_regenerator, # regenerate bearer token with oauth
|
|
165
|
+
progress_id: nil, # session identifier for progress, one per `ascp` process, same on resume
|
|
166
|
+
size_sent: false, # `true` when total size was notified (PreTransferBytes)
|
|
167
|
+
last_size: nil, # last notified transferred size
|
|
168
|
+
skipped: 0, # size of whole files already at destination
|
|
169
|
+
last_skipped: nil, # last notified size not transferred (already at destination)
|
|
167
170
|
# env vars and args for ascp (from transfer spec)
|
|
168
171
|
exec_spec: Transfer::Parameters.new(transfer_spec, **@tr_opts).ascp_args
|
|
169
172
|
}
|
|
@@ -210,7 +213,6 @@ module Aspera
|
|
|
210
213
|
session[:thread].join
|
|
211
214
|
result.push(session[:error] || :success)
|
|
212
215
|
end
|
|
213
|
-
notify_progress(:end)
|
|
214
216
|
Log.log.debug('all transfers joined')
|
|
215
217
|
# since all are finished and we return the result, clear statuses
|
|
216
218
|
@sessions.clear
|
|
@@ -347,7 +349,7 @@ module Aspera
|
|
|
347
349
|
# store session identifier
|
|
348
350
|
session[:id] = event['SessionId'] if event['Type'].eql?('INIT')
|
|
349
351
|
@management_cb&.call(event)
|
|
350
|
-
process_progress(event)
|
|
352
|
+
process_progress(session, event)
|
|
351
353
|
next unless File.exist?(@command_file)
|
|
352
354
|
begin
|
|
353
355
|
commands = JSON.parse(File.read(@command_file))
|
|
@@ -373,7 +375,9 @@ module Aspera
|
|
|
373
375
|
env['ASPERA_SCP_TOKEN'] = session[:token_regenerator].refreshed_transfer_token
|
|
374
376
|
end
|
|
375
377
|
raise Transfer::Error.new(last_event['Description'], code: last_event['Code'].to_i)
|
|
376
|
-
else
|
|
378
|
+
else
|
|
379
|
+
# e.g. process was killed: like a lost connection (code 16), so that transfer is resumed
|
|
380
|
+
raise Transfer::Error.new("#{exec} ended without final status (last event: #{last_event['Type']})", code: 16)
|
|
377
381
|
end
|
|
378
382
|
rescue SystemCallError => e
|
|
379
383
|
# Process.spawn failed, or socket error
|
|
@@ -418,39 +422,63 @@ module Aspera
|
|
|
418
422
|
attr_reader :sessions
|
|
419
423
|
|
|
420
424
|
# Notify progress to callback
|
|
421
|
-
# @param
|
|
422
|
-
|
|
423
|
-
|
|
425
|
+
# @param session [Hash] This session information, progress state is stored in it
|
|
426
|
+
# @param event [Hash] management port event
|
|
427
|
+
def process_progress(session, event)
|
|
428
|
+
# Not the `ascp` session id: must be the same when session is resumed
|
|
429
|
+
progress_id = session[:progress_id] ||= SecureRandom.uuid
|
|
424
430
|
case event['Type']
|
|
425
431
|
when 'INIT'
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
432
|
+
session[:size_sent] = false
|
|
433
|
+
session[:last_size] = nil
|
|
434
|
+
session[:skipped] = 0
|
|
435
|
+
session[:last_skipped] = nil
|
|
436
|
+
notify_progress(:session_start, session_id: progress_id)
|
|
429
437
|
when 'NOTIFICATION' # sent from remote
|
|
430
438
|
if event.key?('PreTransferBytes')
|
|
431
|
-
|
|
432
|
-
notify_progress(:session_size, session_id:
|
|
439
|
+
session[:size_sent] = true
|
|
440
|
+
notify_progress(:session_size, session_id: progress_id, info: event['PreTransferBytes'])
|
|
433
441
|
end
|
|
434
442
|
when 'STATS' # during transfer
|
|
435
|
-
|
|
436
|
-
|
|
443
|
+
notify_transferred(session, event)
|
|
444
|
+
when 'STOP' # one file is completed
|
|
445
|
+
size = event['Size'].to_i
|
|
446
|
+
# Whole file already at destination (resume): not transferred, and not in `FileBytes`.
|
|
447
|
+
# Note: in multi-session, each session notifies all such files: progress bar limits progress to size of session.
|
|
448
|
+
session[:skipped] += size if size.positive? && event['StartByte'].to_i.eql?(size)
|
|
449
|
+
notify_transferred(session, event)
|
|
437
450
|
when 'DONE', 'ERROR' # end of session
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
notify_progress(:
|
|
441
|
-
notify_progress(:session_end, session_id:
|
|
451
|
+
notify_transferred(session, event)
|
|
452
|
+
total_size = session[:last_size] + session[:last_skipped]
|
|
453
|
+
notify_progress(:session_size, session_id: progress_id, info: total_size) if !session[:size_sent] && !total_size.zero?
|
|
454
|
+
notify_progress(:session_end, session_id: progress_id)
|
|
442
455
|
# cspell:disable
|
|
443
456
|
when 'SESSION'
|
|
444
457
|
when 'ARGSTOP'
|
|
445
458
|
when 'FILEERROR'
|
|
446
459
|
Log.log.error { "#{event['Type']} #{event['Description']}" }
|
|
447
|
-
when 'STOP'
|
|
448
460
|
# cspell:enable
|
|
449
|
-
# stop event when one file is completed
|
|
450
461
|
else
|
|
451
462
|
Log.log.debug { "Unknown event type for progress: #{event['Type']}" }
|
|
452
463
|
end
|
|
453
464
|
end
|
|
465
|
+
|
|
466
|
+
# Notify transferred size, and size not transferred because already at destination (resume), if changed
|
|
467
|
+
# @param session [Hash] This session information, progress state is stored in it
|
|
468
|
+
# @param event [Hash] management port event, with `TransferBytes` and `FileBytes`
|
|
469
|
+
def notify_transferred(session, event)
|
|
470
|
+
transferred = event['TransferBytes'].to_i
|
|
471
|
+
# `FileBytes` also includes the part of files already at destination.
|
|
472
|
+
# Note: `StartByte` is not used: in multi-session, it is the offset of the part of file transferred by the session.
|
|
473
|
+
skipped = session[:skipped] + [event['FileBytes'].to_i - transferred, 0].max
|
|
474
|
+
if session[:last_skipped] != skipped
|
|
475
|
+
session[:last_skipped] = skipped
|
|
476
|
+
notify_progress(:skip, session_id: session[:progress_id], info: skipped)
|
|
477
|
+
end
|
|
478
|
+
return if session[:last_size].eql?(transferred)
|
|
479
|
+
session[:last_size] = transferred
|
|
480
|
+
notify_progress(:transfer, session_id: session[:progress_id], info: transferred)
|
|
481
|
+
end
|
|
454
482
|
end
|
|
455
483
|
end
|
|
456
484
|
end
|
data/lib/aspera/agent/node.rb
CHANGED
|
@@ -38,6 +38,9 @@ module Aspera
|
|
|
38
38
|
result['ended_at'] = Time.now.utc.iso8601
|
|
39
39
|
when 'failed'
|
|
40
40
|
result.merge!('status' => 'failed', 'ended_at' => Time.now.utc.iso8601, 'error' => data['error_desc'].to_s)
|
|
41
|
+
when 'canceled'
|
|
42
|
+
result['status'] = 'cancelled'
|
|
43
|
+
result['ended_at'] = Time.now.utc.iso8601
|
|
41
44
|
else
|
|
42
45
|
result['status'] = 'running'
|
|
43
46
|
end
|
|
@@ -109,7 +112,7 @@ module Aspera
|
|
|
109
112
|
# status is empty sometimes with status 200...
|
|
110
113
|
transfer_data = node_api_.read("ops/transfers/#{@transfer_id}") || {'status' => 'unknown'} rescue {'status' => 'waiting(api error)'}
|
|
111
114
|
case transfer_data['status']
|
|
112
|
-
when 'waiting', 'partially_completed', 'unknown', 'waiting(read error)', 'waiting(api error)'
|
|
115
|
+
when 'waiting', 'partially_completed', 'paused', 'unknown', 'waiting(read error)', 'waiting(api error)'
|
|
113
116
|
notify_progress(:sessions_init, info: transfer_data['status'])
|
|
114
117
|
when 'running'
|
|
115
118
|
if !session_started
|
|
@@ -129,13 +132,11 @@ module Aspera
|
|
|
129
132
|
when 'completed'
|
|
130
133
|
notify_progress(:transfer, session_id: @transfer_id, info: bytes_expected) if bytes_expected
|
|
131
134
|
notify_progress(:session_end, session_id: @transfer_id)
|
|
132
|
-
notify_progress(:end)
|
|
133
135
|
break
|
|
134
|
-
when 'failed'
|
|
136
|
+
when 'failed', 'canceled'
|
|
135
137
|
notify_progress(:session_end, session_id: @transfer_id)
|
|
136
|
-
notify_progress(:end)
|
|
137
138
|
# Bug in HSTS ? transfer is marked failed, but there is no reason
|
|
138
|
-
break if transfer_data['error_code'].eql?(0) && transfer_data['error_desc'].empty?
|
|
139
|
+
break if transfer_data['status'].eql?('failed') && transfer_data['error_code'].eql?(0) && transfer_data['error_desc'].empty?
|
|
139
140
|
raise Transfer::Error, "status: #{transfer_data['status']}. code: #{transfer_data['error_code']}. description: #{transfer_data['error_desc']}"
|
|
140
141
|
else Aspera.error_unexpected_value(transfer_data['status']) { "transfer_data -> #{transfer_data}" }
|
|
141
142
|
end
|
|
@@ -140,6 +140,8 @@ module Aspera
|
|
|
140
140
|
|
|
141
141
|
# Actual endpoint the daemon is listening on (resolved after connect, e.g. when port 0 was used)
|
|
142
142
|
attr_reader :daemon_endpoint
|
|
143
|
+
# gRPC client of the daemon
|
|
144
|
+
attr_reader :transfer_client
|
|
143
145
|
|
|
144
146
|
# :reek:UnusedParameters token_regenerator
|
|
145
147
|
def start_transfer(transfer_spec, token_regenerator: nil)
|
|
@@ -181,11 +183,9 @@ module Aspera
|
|
|
181
183
|
when :COMPLETED
|
|
182
184
|
notify_progress(:transfer, session_id: @transfer_id, info: bytes_expected) if bytes_expected
|
|
183
185
|
notify_progress(:session_end, session_id: @transfer_id)
|
|
184
|
-
notify_progress(:end)
|
|
185
186
|
break
|
|
186
187
|
when :FAILED, :CANCELED
|
|
187
188
|
notify_progress(:session_end, session_id: @transfer_id)
|
|
188
|
-
notify_progress(:end)
|
|
189
189
|
raise Transfer::Error, JSON.parse(response.message)['Description']
|
|
190
190
|
when :QUEUED, :UNKNOWN_STATUS, :PAUSED, :ORPHANED
|
|
191
191
|
notify_progress(:sessions_init, info: response.status.to_s.downcase)
|
data/lib/aspera/api/faspex.rb
CHANGED
|
@@ -138,6 +138,7 @@ module Aspera
|
|
|
138
138
|
SENT_MAILBOX_TYPES.include?(box) || box == 'ALL' ? :sent : :received
|
|
139
139
|
end
|
|
140
140
|
end
|
|
141
|
+
# @return [Hash, nil] public link context decoded from URL (keys: resource, type, id, passcode, package_id, email), or nil if not a public link
|
|
141
142
|
attr_reader :pub_link_context
|
|
142
143
|
|
|
143
144
|
# @param url [String] Faspex URL, can be a public link
|
|
@@ -163,6 +164,7 @@ module Aspera
|
|
|
163
164
|
passphrase: nil
|
|
164
165
|
)
|
|
165
166
|
auth = :public_link if self.class.public_link?(url)
|
|
167
|
+
# @type [Hash, nil]
|
|
166
168
|
@pub_link_context = nil
|
|
167
169
|
super(**
|
|
168
170
|
case auth
|
data/lib/aspera/api/httpgw.rb
CHANGED
|
@@ -254,12 +254,11 @@ module Aspera
|
|
|
254
254
|
# session no more used
|
|
255
255
|
@ws_io = nil
|
|
256
256
|
http_session&.finish
|
|
257
|
-
@notify_cb&.call(:end)
|
|
258
257
|
end
|
|
259
258
|
|
|
260
259
|
def download(transfer_spec)
|
|
261
260
|
transfer_spec['source_root'] ||= '/'
|
|
262
|
-
default_file_name = transfer_spec['paths'].first
|
|
261
|
+
default_file_name = transfer_spec['paths'].first&.fetch('source')
|
|
263
262
|
source_is_folder = %w[. /].include?(default_file_name)
|
|
264
263
|
default_file_name = 'http_download' if source_is_folder
|
|
265
264
|
transfer_spec['zip_required'] ||= source_is_folder || transfer_spec['paths'].length > 1
|
data/lib/aspera/api/node.rb
CHANGED
|
@@ -457,7 +457,7 @@ module Aspera
|
|
|
457
457
|
create(
|
|
458
458
|
'files/download_setup',
|
|
459
459
|
{transfer_requests: [{transfer_request: {paths: [{source: '/'}]}}]}
|
|
460
|
-
)['transfer_specs'].first
|
|
460
|
+
)['transfer_specs'].first&.fetch('transfer_spec')
|
|
461
461
|
end
|
|
462
462
|
|
|
463
463
|
# Get generic part of transfer spec with transport parameters only
|
|
@@ -617,8 +617,8 @@ module Aspera
|
|
|
617
617
|
# Method called in loop for each entry for `resolve_api_fid`
|
|
618
618
|
# @return [Boolean] `true` to continue digging, `false` to stop processing: set state[:result] if found
|
|
619
619
|
def process_api_fid(entry, path, state)
|
|
620
|
-
# Stop digging here if not
|
|
621
|
-
return false
|
|
620
|
+
# Stop digging here if already found, or if not the next path element directly under the consumed path (not a sibling)
|
|
621
|
+
return false if state[:path].empty? || !path.eql?(File.join(PATH_SEPARATOR, *state[:consumed], state[:path].first))
|
|
622
622
|
# Ok it matches, so we remove the match, and continue digging
|
|
623
623
|
state[:consumed].push(state[:path].shift)
|
|
624
624
|
path_fully_consumed = state[:path].empty?
|
|
@@ -311,8 +311,8 @@ module Aspera
|
|
|
311
311
|
archive_io.write(File.binread(UriReader.file_path(url)))
|
|
312
312
|
else
|
|
313
313
|
Rest::Client.new(base_url: url, redirect_max: 3).call(operation: 'GET', save_to: archive_io)
|
|
314
|
-
archive_io.rewind
|
|
315
314
|
end
|
|
315
|
+
archive_io.rewind
|
|
316
316
|
extract_archive_files(url, archive_io) do |entry_name, entry_stream, link_target|
|
|
317
317
|
dest_folder = if block_given?
|
|
318
318
|
yield(entry_name)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'aspera/log'
|
|
4
|
+
require 'aspera/secret_hider'
|
|
4
5
|
require 'json'
|
|
5
6
|
require 'securerandom'
|
|
6
7
|
|
|
@@ -59,14 +60,14 @@ module Aspera
|
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
# Persist (create or update) an async transfer entry.
|
|
62
|
-
# Keys whose name starts with '_' are in-process references (e.g. '_agent_ref')
|
|
63
|
-
#
|
|
63
|
+
# Keys whose name starts with '_' are in-process references (e.g. '_agent_ref'), and secrets (e.g. agent `password`)
|
|
64
|
+
# are not written on disk: they are intentionally excluded from the JSON serialization — at every nesting level.
|
|
64
65
|
# @param job_id [String] the ascli-generated UUID
|
|
65
66
|
# @param data [Hash] fields to store (will be JSON-serialised)
|
|
66
67
|
def write(job_id, data)
|
|
67
68
|
Aspera.assert_type(job_id, String) { 'job_id' }
|
|
68
69
|
Aspera.assert_type(data, Hash) { 'data' }
|
|
69
|
-
@persistency.put(store_key(job_id), JSON.generate(
|
|
70
|
+
@persistency.put(store_key(job_id), JSON.generate(persistable(data)))
|
|
70
71
|
nil
|
|
71
72
|
end
|
|
72
73
|
|
|
@@ -105,17 +106,17 @@ module Aspera
|
|
|
105
106
|
"#{CATEGORY}#{job_id}"
|
|
106
107
|
end
|
|
107
108
|
|
|
108
|
-
# Recursively remove keys
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
def
|
|
109
|
+
# Recursively remove from a Hash the keys that must never reach the file system:
|
|
110
|
+
# - keys whose name starts with '_': in-process Ruby object references (e.g. '_agent_ref') that cannot be serialized to JSON
|
|
111
|
+
# - secrets (e.g. 'password'): taken from current options when the transfer is queried
|
|
112
|
+
def persistable(value)
|
|
112
113
|
case value
|
|
113
114
|
when Hash
|
|
114
115
|
value.each_with_object({}) do |(k, v), h|
|
|
115
|
-
h[k] =
|
|
116
|
+
h[k] = persistable(v) unless k.to_s.start_with?('_') || SecretHider.instance.secret?(k, v)
|
|
116
117
|
end
|
|
117
118
|
when Array
|
|
118
|
-
value.map { |v|
|
|
119
|
+
value.map { |v| persistable(v) }
|
|
119
120
|
else
|
|
120
121
|
value
|
|
121
122
|
end
|
|
@@ -139,9 +139,11 @@ module Aspera
|
|
|
139
139
|
|
|
140
140
|
# Declare + parse :progress_bar: sets context.progress_bar
|
|
141
141
|
def setup_progress_bar
|
|
142
|
-
@context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default:
|
|
142
|
+
@context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Type::BOOLEAN, default: $stderr.tty?)
|
|
143
143
|
@context.options.parse_options!
|
|
144
144
|
@context.progress_bar = TransferProgress.new if @context.options.get_option(:progress_bar)
|
|
145
|
+
# Log lines do not overwrite the progress bar
|
|
146
|
+
Log.instance.status_line = @context.progress_bar
|
|
145
147
|
end
|
|
146
148
|
|
|
147
149
|
# Declare + parse :fpac / :proxy_credentials: sets up PAC executor
|
|
@@ -20,6 +20,7 @@ module Aspera
|
|
|
20
20
|
# own_children_of(path) - Hash{Symbol => CommandSpec} of direct children declared on the node, without mounted ones
|
|
21
21
|
# arguments_at(path) - Array of ArgumentSpec read by the node at path (mount arguments first)
|
|
22
22
|
# leaf_paths - Array of all leaf paths (follows mounts, or stops at mount nodes)
|
|
23
|
+
# command_path(words) - command path designated by command line words (aliases, arguments)
|
|
23
24
|
# all_paths - Array of all locally registered full paths
|
|
24
25
|
# any? - true if at least one spec has been registered
|
|
25
26
|
# validate! - cross-spec consistency checks; raises on violation
|
|
@@ -138,6 +139,33 @@ module Aspera
|
|
|
138
139
|
end
|
|
139
140
|
end
|
|
140
141
|
|
|
142
|
+
# Command path designated by words of a command line: sub-commands (or their aliases),
|
|
143
|
+
# each possibly followed by its positional arguments, e.g. `packages receive ALL` designates `packages receive`.
|
|
144
|
+
# Words after a leaf command are its arguments.
|
|
145
|
+
# @param words [Array<Symbol>] words following the plugin name
|
|
146
|
+
# @return [Array(Array<Symbol>, Symbol)] command path, and the first word that is neither a sub-command
|
|
147
|
+
# nor an expected argument (`nil` if none)
|
|
148
|
+
def command_path(words)
|
|
149
|
+
path = []
|
|
150
|
+
# Number of positional arguments still accepted by the node at path
|
|
151
|
+
args_left = 0
|
|
152
|
+
words.each do |word|
|
|
153
|
+
children = children_of(path)
|
|
154
|
+
break if children.empty?
|
|
155
|
+
id = children.key?(word) ? word : children.find { |_, c| Array(c.aliases).include?(word) }&.first
|
|
156
|
+
if id
|
|
157
|
+
path += [id]
|
|
158
|
+
arguments = arguments_at(path)
|
|
159
|
+
args_left = arguments.any?(&:multiple) ? Float::INFINITY : arguments.length
|
|
160
|
+
elsif args_left.positive?
|
|
161
|
+
args_left -= 1
|
|
162
|
+
else
|
|
163
|
+
return [path, word]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
[path, nil]
|
|
167
|
+
end
|
|
168
|
+
|
|
141
169
|
# @return [Array<Array<Symbol>>] all locally registered full paths
|
|
142
170
|
def all_paths
|
|
143
171
|
@specs.keys
|
|
@@ -168,29 +196,42 @@ module Aspera
|
|
|
168
196
|
end
|
|
169
197
|
|
|
170
198
|
# Cross-spec consistency checks.
|
|
171
|
-
# @param plugin_class [Class, nil] when given, also verify that
|
|
199
|
+
# @param plugin_class [Class, nil] when given, also verify that methods referenced by Symbol
|
|
200
|
+
# (implicit and explicit actions, `setup:`, `condition:`, `lookup:`, mount `instance:`) exist
|
|
172
201
|
# @raise [ArgumentError] on any violation
|
|
173
202
|
# @return [self]
|
|
174
203
|
def validate!(plugin_class: nil)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
unless @specs.key?(parent_path)
|
|
204
|
+
@children_index.each do |parent_path, children|
|
|
205
|
+
# Rule: every non-root parent path that appears in the children index must have
|
|
206
|
+
# a registered CommandSpec. A missing parent means commands_under(:x) was used
|
|
207
|
+
# without a matching command :x declaration.
|
|
208
|
+
unless parent_path.empty? || @specs.key?(parent_path)
|
|
181
209
|
raise ArgumentError,
|
|
182
210
|
"commands_under(#{parent_path.map(&:inspect).join(', ')}) used but #{parent_path.last.inspect} has no command declaration"
|
|
183
211
|
end
|
|
212
|
+
# Rule: an alias designates a single command, and does not hide a sibling command
|
|
213
|
+
aliases = children.values.flat_map { |c| Array(c.aliases) }
|
|
214
|
+
conflicts = aliases.select { |a| children.key?(a) || aliases.count(a) > 1 }.uniq
|
|
215
|
+
raise ArgumentError, "#{parent_path.inspect}: alias conflicts with a sibling command or alias: #{conflicts.inspect}" unless conflicts.empty?
|
|
184
216
|
end
|
|
185
217
|
|
|
186
218
|
@specs.each_value do |spec|
|
|
187
219
|
path = spec.full_path
|
|
188
220
|
|
|
221
|
+
validate_arguments(path, spec.arguments, plugin_class)
|
|
222
|
+
# Rule: methods called on the plugin instance exist
|
|
223
|
+
if plugin_class
|
|
224
|
+
{setup: spec.setup, condition: spec.condition}.each do |attribute, method_name|
|
|
225
|
+
raise ArgumentError, "#{path.inspect}: no method #{method_name} on #{plugin_class} for #{attribute}:" unless method_name.nil? || instance_method?(plugin_class, method_name)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
189
229
|
if (mount = spec.mount)
|
|
190
230
|
# Rule: a mount needs an instance method, no action, and must point to existing target nodes
|
|
191
231
|
raise ArgumentError, "#{path.inspect}: mount requires instance:" if mount.instance.nil?
|
|
192
232
|
# Mount arguments precede the arguments of the mounted command: they cannot be optional
|
|
193
233
|
raise ArgumentError, "#{path.inspect}: mount arguments must be mandatory" unless mount.arguments.all?(&:mandatory)
|
|
234
|
+
validate_arguments(path, mount.arguments, plugin_class)
|
|
194
235
|
raise ArgumentError, "#{path.inspect}: mount and action: are exclusive" if spec.action
|
|
195
236
|
raise ArgumentError, "#{path.inspect}: mount at #{mount.at.inspect} not found in #{mount.plugin}" unless mount.at.empty? || mount.registry[mount.at]
|
|
196
237
|
target_ids = mount.registry.children_of(mount.at).keys
|
|
@@ -229,6 +270,27 @@ module Aspera
|
|
|
229
270
|
plugin_class.method_defined?(name) || plugin_class.private_method_defined?(name)
|
|
230
271
|
end
|
|
231
272
|
|
|
273
|
+
# Consistency of the positional arguments of a node, in reading order.
|
|
274
|
+
# @param path [Array<Symbol>] node path, for error messages
|
|
275
|
+
# @param arg_specs [Array<ArgumentSpec>] arguments of the node (or of its mount)
|
|
276
|
+
# @param plugin_class [Class, nil] when given, verify that `lookup:` methods exist
|
|
277
|
+
# @raise [ArgumentError] on any violation
|
|
278
|
+
def validate_arguments(path, arg_specs, plugin_class)
|
|
279
|
+
previous = nil
|
|
280
|
+
Array(arg_specs).each do |arg|
|
|
281
|
+
where = "#{path.inspect}: argument #{arg.name}"
|
|
282
|
+
# Rule: an argument following an optional one, or one that takes all remaining arguments, is never read reliably
|
|
283
|
+
raise ArgumentError, "#{where}: mandatory after optional #{previous.name}" if previous && arg.mandatory && !previous.mandatory
|
|
284
|
+
raise ArgumentError, "#{where}: after #{previous.name}, which takes all remaining arguments" if previous&.multiple.eql?(true)
|
|
285
|
+
unless arg.lookup.nil?
|
|
286
|
+
# Rule: the percent-selector lookup is only used for identifiers
|
|
287
|
+
raise ArgumentError, "#{where}: lookup: requires type: :identifier" unless arg.type.eql?(:identifier)
|
|
288
|
+
raise ArgumentError, "#{where}: no method #{arg.lookup} on #{plugin_class} for lookup:" if arg.lookup.is_a?(Symbol) && plugin_class && !instance_method?(plugin_class, arg.lookup)
|
|
289
|
+
end
|
|
290
|
+
previous = arg
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
232
294
|
# @param path [Array<Symbol>] non-empty path in this registry's namespace
|
|
233
295
|
# @return [Array<ArgumentSpec>] arguments of the mount exposing the last segment of path, if any
|
|
234
296
|
def mount_arguments(path)
|
|
@@ -23,7 +23,7 @@ module Aspera
|
|
|
23
23
|
# Proc/lambda → called via instance_exec(field, value, **ctx, &lookup).
|
|
24
24
|
# Style: use Symbol for named methods; ->(){} for 1-liners; lambda do…end for 2–3 statements.
|
|
25
25
|
# @!attribute allowed [Array<Symbol>, nil] Allowed Symbol values; when set, type is forced to Symbol and accept_list is applied
|
|
26
|
-
# @!attribute interactive [Boolean] When true,
|
|
26
|
+
# @!attribute interactive [Boolean] When true, prompts for this argument (only) when no CLI args are provided
|
|
27
27
|
ArgumentSpec = Struct.new(
|
|
28
28
|
:name,
|
|
29
29
|
:description,
|
|
@@ -77,7 +77,7 @@ module Aspera
|
|
|
77
77
|
case mode
|
|
78
78
|
when '' then $stdin.read
|
|
79
79
|
when 'bin' then $stdin.binmode.read
|
|
80
|
-
when 'chomp' then $stdin.chomp
|
|
80
|
+
when 'chomp' then $stdin.read.chomp
|
|
81
81
|
else raise BadArgument, "`stdin` supports only: '', 'bin' or 'chomp'"
|
|
82
82
|
end
|
|
83
83
|
end
|
|
@@ -118,10 +118,11 @@ module Aspera
|
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
# Update the Regex to match an extended value based on @handlers
|
|
121
|
+
# Anchored on the whole value (`\A`, `\z`), not on lines: a multi-line value is extended only if it starts with a modifier.
|
|
121
122
|
def update_regex
|
|
122
123
|
handler_regex = "#{MARKER_START}(#{modifiers.join('|')})#{MARKER_END}"
|
|
123
|
-
@regex_single = Regexp.new("
|
|
124
|
-
@regex_extend = Regexp.new("
|
|
124
|
+
@regex_single = Regexp.new("\\A#{handler_regex}(.*)\\z", Regexp::MULTILINE)
|
|
125
|
+
@regex_extend = Regexp.new("\\A(.*)#{handler_regex}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)\\z", Regexp::MULTILINE)
|
|
125
126
|
end
|
|
126
127
|
|
|
127
128
|
public
|
data/lib/aspera/cli/formatter.rb
CHANGED
|
@@ -51,6 +51,8 @@ module Aspera
|
|
|
51
51
|
option :multi_single, description: '(Table) Control how object list is displayed as single table, or multiple objects', allowed: %i[no yes single], default: :no, deprecation: {last: '4.27.0', message: 'use --out.table.pivot'}
|
|
52
52
|
option :show_secrets, description: 'Show secrets on command output', allowed: Type::BOOLEAN, default: false, deprecation: {last: '4.27.0', message: 'use --out.secrets'}
|
|
53
53
|
option :image, schema: Schema::Registry::IMAGE_OPTIONS, deprecation: {last: '4.27.0', message: 'use --out.img'}
|
|
54
|
+
# All formatter options are handled by `option_handler`, executed on the formatter instance
|
|
55
|
+
option_specs.each_value { |spec| spec.on_set = ->(value) { option_handler(spec.name, value) } }
|
|
54
56
|
|
|
55
57
|
class << self
|
|
56
58
|
# Replace special values with a readable version on terminal
|
|
@@ -146,15 +148,14 @@ module Aspera
|
|
|
146
148
|
end
|
|
147
149
|
end
|
|
148
150
|
|
|
149
|
-
#
|
|
151
|
+
# Declare formatter options on the parser, with `on_set` callbacks executed on this instance.
|
|
150
152
|
# Called from Runner after Formatter.new.
|
|
151
|
-
# @param
|
|
153
|
+
# @param parser [Aspera::Cli::Parser]
|
|
152
154
|
# @return [nil]
|
|
153
|
-
def
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
end
|
|
155
|
+
def declare_options(parser)
|
|
156
|
+
# Used by `option_handler` to dispatch `--out` sub-options
|
|
157
|
+
@parser = parser
|
|
158
|
+
self.class.declare_options(parser, target: self)
|
|
158
159
|
nil
|
|
159
160
|
end
|
|
160
161
|
|
|
@@ -195,7 +196,8 @@ module Aspera
|
|
|
195
196
|
# special handling of some options
|
|
196
197
|
case option_symbol
|
|
197
198
|
when :format
|
|
198
|
-
|
|
199
|
+
# The default output level depends on the format, unless the level is set explicitly (e.g. `--out.level`)
|
|
200
|
+
@options[:display] = value.eql?(:table) ? :info : :data if @parser.nil? || @parser.option_def(:display).source.eql?(:default)
|
|
199
201
|
when :output
|
|
200
202
|
$stdout = if value.eql?('-')
|
|
201
203
|
STDOUT # rubocop:disable Style/GlobalStdStream
|
data/lib/aspera/cli/http.rb
CHANGED
|
@@ -26,12 +26,13 @@ module Aspera
|
|
|
26
26
|
|
|
27
27
|
private_constant :CERT_EXT, :SELF_SIGNED_CERT
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
option :
|
|
31
|
-
option :
|
|
32
|
-
option :
|
|
33
|
-
option :
|
|
34
|
-
option :
|
|
29
|
+
# `on_set` callbacks are methods of the instance given as `target:` to `declare_options`
|
|
30
|
+
option :insecure, description: 'HTTP/S: Do not validate any certificate', allowed: Type::BOOLEAN, default: false, on_set: :insecure=
|
|
31
|
+
option :ignore_certificate, description: 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass], on_set: :ignore_cert_host_port=
|
|
32
|
+
option :warn_insecure, description: 'HTTP/S: Issue a warning if certificate is ignored', allowed: Type::BOOLEAN, default: true, on_set: :warn_insecure=
|
|
33
|
+
option :cert_stores, description: 'HTTP/S: List of folder with trusted certificates', allowed: Type::STRING_ARRAY, on_set: :trusted_cert_locations=
|
|
34
|
+
option :http_options, schema: Schema::Registry::HTTP_OPTIONS, on_set: :http_options=
|
|
35
|
+
option :http_proxy, description: 'HTTP/S: URL for proxy with optional credentials', on_set: :http_proxy=
|
|
35
36
|
|
|
36
37
|
def initialize
|
|
37
38
|
@insecure = false
|
|
@@ -46,19 +47,6 @@ module Aspera
|
|
|
46
47
|
attr_accessor :insecure, :warn_insecure
|
|
47
48
|
attr_reader :ignore_cert_host_port, :http_options
|
|
48
49
|
|
|
49
|
-
# Bind all HTTP options to this instance using `on_set`.
|
|
50
|
-
# Called from Config#initialize immediately after Http.new.
|
|
51
|
-
# @param options [Aspera::Cli::Parser]
|
|
52
|
-
# @return [nil]
|
|
53
|
-
def bind_options(options)
|
|
54
|
-
options.on_set(:insecure, method(:insecure=))
|
|
55
|
-
options.on_set(:ignore_certificate, method(:ignore_cert_host_port=))
|
|
56
|
-
options.on_set(:warn_insecure, method(:warn_insecure=))
|
|
57
|
-
options.on_set(:cert_stores, method(:trusted_cert_locations=))
|
|
58
|
-
options.on_set(:http_options, method(:http_options=))
|
|
59
|
-
options.on_set(:http_proxy, method(:http_proxy=))
|
|
60
|
-
end
|
|
61
|
-
|
|
62
50
|
# Setter for http_options: dispatch each key to its target singleton immediately.
|
|
63
51
|
# Keys matching Rest::Parameters setters go to Rest::Parameters, 'ssl_options' goes to SSL,
|
|
64
52
|
# keys matching OAuth::Factory.instance.parameters go to OAuth, and the rest are kept
|
|
@@ -148,7 +136,7 @@ module Aspera
|
|
|
148
136
|
if path.eql?(SpecialValues::DEF)
|
|
149
137
|
@certificate_store.set_default_paths
|
|
150
138
|
paths_to_add = [OpenSSL::X509::DEFAULT_CERT_DIR]
|
|
151
|
-
paths_to_add.push(OpenSSL::X509::DEFAULT_CERT_FILE) unless defined?(JRUBY_VERSION)
|
|
139
|
+
paths_to_add.push(ENV.fetch(OpenSSL::X509::DEFAULT_CERT_FILE_ENV, OpenSSL::X509::DEFAULT_CERT_FILE)) unless defined?(JRUBY_VERSION)
|
|
152
140
|
paths_to_add.select! { |f| File.exist?(f) }
|
|
153
141
|
elsif File.file?(path)
|
|
154
142
|
@certificate_store.add_file(path)
|
|
@@ -54,7 +54,7 @@ module Aspera
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
# Type specifiers for the `allowed:` parameter of option declarations.
|
|
57
|
-
# Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
|
|
57
|
+
# Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, FLOAT, BOOLEAN, NONE.
|
|
58
58
|
# Internal (do not pass as `allowed:`):
|
|
59
59
|
# ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
|
|
60
60
|
# STRING - the implicit default; equivalent to omitting `allowed:` entirely
|
|
@@ -65,6 +65,8 @@ module Aspera
|
|
|
65
65
|
SYMBOL_ARRAY = [Array, Symbol].freeze
|
|
66
66
|
# Option value is coerced to Integer
|
|
67
67
|
INTEGER = [Integer].freeze
|
|
68
|
+
# Option value is coerced to Float
|
|
69
|
+
FLOAT = [Float].freeze
|
|
68
70
|
# Option value is a Boolean
|
|
69
71
|
BOOLEAN = BoolValue::TYPES
|
|
70
72
|
# Option has no value — it is a flag switch (e.g. `-N`, `--help`)
|