aspera-cli 4.26.1 → 4.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +300 -22
- data/CONTRIBUTING.md +100 -12
- data/bin/ascli +5 -5
- data/docs/README.md +10757 -0
- data/docs/test-mcp-with-ai.md +202 -0
- data/lib/aspera/agent/base.rb +20 -6
- data/lib/aspera/agent/connect.rb +53 -16
- data/lib/aspera/agent/desktop.rb +52 -17
- data/lib/aspera/agent/direct.rb +27 -25
- data/lib/aspera/agent/factory.rb +4 -0
- data/lib/aspera/agent/httpgw.rb +2 -2
- data/lib/aspera/agent/node.rb +34 -2
- data/lib/aspera/agent/transferd.rb +39 -3
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +56 -51
- data/lib/aspera/api/cos_node.rb +2 -2
- data/lib/aspera/api/faspex.rb +22 -20
- data/lib/aspera/api/httpgw.rb +4 -4
- data/lib/aspera/api/node.rb +33 -30
- data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
- data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
- data/lib/aspera/ascmd.rb +25 -23
- data/lib/aspera/ascp/installation.rb +43 -40
- data/lib/aspera/ascp/management.rb +2 -3
- data/lib/aspera/cli/ascp_actions.rb +155 -0
- data/lib/aspera/cli/async_transfer_store.rb +81 -0
- data/lib/aspera/cli/bootstrapper.rb +197 -0
- data/lib/aspera/cli/command_registry.rb +145 -0
- data/lib/aspera/cli/command_spec.rb +138 -0
- data/lib/aspera/cli/context.rb +71 -0
- data/lib/aspera/cli/error.rb +16 -0
- data/lib/aspera/cli/extended_value.rb +5 -3
- data/lib/aspera/cli/formatter.rb +122 -209
- data/lib/aspera/cli/gem_checker.rb +65 -0
- data/lib/aspera/cli/hints.rb +2 -2
- data/lib/aspera/cli/http.rb +218 -0
- data/lib/aspera/cli/info.rb +2 -0
- data/lib/aspera/cli/mailer.rb +97 -0
- data/lib/aspera/cli/mcp_tool.rb +198 -0
- data/lib/aspera/cli/options.schema.yaml +364 -7
- data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
- data/lib/aspera/cli/plugins/alee.rb +20 -22
- data/lib/aspera/cli/plugins/aoc.rb +1173 -849
- data/lib/aspera/cli/plugins/ats.rb +200 -161
- data/lib/aspera/cli/plugins/base.rb +470 -107
- data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
- data/lib/aspera/cli/plugins/config.rb +434 -1032
- data/lib/aspera/cli/plugins/console.rb +106 -64
- data/lib/aspera/cli/plugins/cos.rb +44 -32
- data/lib/aspera/cli/plugins/factory.rb +7 -4
- data/lib/aspera/cli/plugins/faspex.rb +296 -259
- data/lib/aspera/cli/plugins/faspex5.rb +592 -344
- data/lib/aspera/cli/plugins/faspio.rb +49 -51
- data/lib/aspera/cli/plugins/httpgw.rb +18 -25
- data/lib/aspera/cli/plugins/mcp.rb +279 -0
- data/lib/aspera/cli/plugins/node.rb +1001 -797
- data/lib/aspera/cli/plugins/oauth.rb +7 -10
- data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
- data/lib/aspera/cli/plugins/preview.rb +183 -142
- data/lib/aspera/cli/plugins/server.rb +125 -80
- data/lib/aspera/cli/plugins/shares.rb +301 -107
- data/lib/aspera/cli/preset_actions.rb +139 -0
- data/lib/aspera/cli/preset_manager.rb +236 -0
- data/lib/aspera/cli/result.rb +360 -0
- data/lib/aspera/cli/runner.rb +473 -0
- data/lib/aspera/cli/secret_finder.rb +40 -0
- data/lib/aspera/cli/special_values.rb +1 -0
- data/lib/aspera/cli/sync_actions.rb +84 -56
- data/lib/aspera/cli/terminal_formatter.rb +65 -0
- data/lib/aspera/cli/transfer_actions.rb +83 -0
- data/lib/aspera/cli/transfer_agent.rb +116 -51
- data/lib/aspera/cli/transfer_progress.rb +9 -9
- data/lib/aspera/cli/vault_manager.rb +57 -0
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +26 -26
- data/lib/aspera/command_line_builder.rb +23 -23
- data/lib/aspera/data_repository.rb +6 -1
- data/lib/aspera/dot_container.rb +5 -5
- data/lib/aspera/environment.rb +39 -13
- data/lib/aspera/exec_spec.rb +13 -0
- data/lib/aspera/faspex_gw.rb +2 -2
- data/lib/aspera/faspex_postproc.rb +4 -3
- data/lib/aspera/graphql.rb +35 -0
- data/lib/aspera/hash_ext.rb +6 -0
- data/lib/aspera/json_rpc/client.rb +62 -0
- data/lib/aspera/json_rpc/version.rb +7 -0
- data/lib/aspera/keychain/base.rb +1 -1
- data/lib/aspera/keychain/encrypted_hash.rb +2 -2
- data/lib/aspera/keychain/factory.rb +4 -4
- data/lib/aspera/keychain/macos_security.rb +4 -4
- data/lib/aspera/link_header.rb +82 -0
- data/lib/aspera/log.rb +23 -5
- data/lib/aspera/markdown.rb +85 -2
- data/lib/aspera/nagios.rb +2 -2
- data/lib/aspera/node_simulator.rb +3 -1
- data/lib/aspera/oauth/base.rb +10 -10
- data/lib/aspera/oauth/boot.rb +43 -0
- data/lib/aspera/oauth/factory.rb +41 -15
- data/lib/aspera/oauth/jwt.rb +4 -4
- data/lib/aspera/oauth/web.rb +4 -4
- data/lib/aspera/oauth.rb +1 -0
- data/lib/aspera/persistency_action_once.rb +10 -10
- data/lib/aspera/persistency_folder.rb +10 -3
- data/lib/aspera/preview/file_types.rb +7 -3
- data/lib/aspera/preview/generator.rb +3 -3
- data/lib/aspera/preview/utils.rb +1 -1
- data/lib/aspera/products/connect.rb +4 -1
- data/lib/aspera/products/other.rb +1 -1
- data/lib/aspera/products/transferd.rb +3 -3
- data/lib/aspera/proxy_auto_config.rb +10 -9
- data/lib/aspera/rest.rb +18 -40
- data/lib/aspera/rest_error_analyzer.rb +7 -3
- data/lib/aspera/rest_errors_aspera.rb +0 -10
- data/lib/aspera/rest_list.rb +13 -6
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
- data/lib/aspera/schema/async_tables.yaml +361 -0
- data/lib/aspera/schema/documentation.rb +57 -26
- data/lib/aspera/schema/reader.rb +60 -11
- data/lib/aspera/schema/registry.rb +50 -10
- data/lib/aspera/secret_hider.rb +11 -2
- data/lib/aspera/ssl.rb +4 -4
- data/lib/aspera/sync/conf.schema.yaml +2 -2
- data/lib/aspera/sync/database.rb +14 -10
- data/lib/aspera/sync/operations.rb +15 -18
- data/lib/aspera/temp_file_manager.rb +5 -1
- data/lib/aspera/timer_limiter.rb +1 -1
- data/lib/aspera/transfer/faux_file.rb +24 -11
- data/lib/aspera/transfer/parameters.rb +27 -25
- data/lib/aspera/transfer/result.rb +74 -0
- data/lib/aspera/transfer/resumer.rb +7 -7
- data/lib/aspera/transfer/spec.rb +18 -1
- data/lib/aspera/transfer/spec.schema.yaml +26 -8
- data/lib/aspera/transfer/uri.rb +1 -1
- data/lib/aspera/uri_reader.rb +2 -2
- data/lib/aspera/web_auth.rb +2 -2
- data/lib/aspera/web_server_simple.rb +8 -5
- data/lib/aspera/yaml.rb +2 -1
- data.tar.gz.sig +0 -0
- metadata +45 -16
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/main.rb +0 -480
- data/lib/aspera/json_rpc.rb +0 -51
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/agent/factory'
|
|
4
|
+
require 'aspera/schema/registry'
|
|
5
|
+
require 'aspera/schema/reader'
|
|
6
|
+
|
|
7
|
+
module Aspera
|
|
8
|
+
module Cli
|
|
9
|
+
# Mixin for Config plugin: ASCP / Transferd related actions
|
|
10
|
+
module AscpActions
|
|
11
|
+
TRANSFERD_APP_NAME = 'sdk'
|
|
12
|
+
|
|
13
|
+
# Mapping from agent symbol to schema key in options.schema.yaml
|
|
14
|
+
# Agents without dedicated schema (connect, desktop) are omitted.
|
|
15
|
+
AGENT_SCHEMA_KEY = {
|
|
16
|
+
direct: Schema::Registry::DIRECT_AGENT_OPTIONS,
|
|
17
|
+
node: Schema::Registry::NODE_AGENT_OPTIONS,
|
|
18
|
+
httpgw: Schema::Registry::HTTPGW_AGENT_OPTIONS,
|
|
19
|
+
transferd: Schema::Registry::TRANSFERD_AGENT_OPTIONS
|
|
20
|
+
}.freeze
|
|
21
|
+
private_constant :AGENT_SCHEMA_KEY
|
|
22
|
+
|
|
23
|
+
# Set the SDK directory, checking default and former locations
|
|
24
|
+
def set_sdk_dir
|
|
25
|
+
sdk_dir = Products::Transferd.sdk_directory rescue nil
|
|
26
|
+
if sdk_dir.nil?
|
|
27
|
+
@sdk_default_location = true
|
|
28
|
+
Log.log.debug('SDK folder is not set, checking default')
|
|
29
|
+
sdk_dir = self.class.default_app_main_folder(app_name: TRANSFERD_APP_NAME)
|
|
30
|
+
Log.log.debug{"Checking: #{sdk_dir}"}
|
|
31
|
+
if !Dir.exist?(sdk_dir)
|
|
32
|
+
Log.log.debug{"No such folder: #{sdk_dir}"}
|
|
33
|
+
former_sdk_folder = File.join(self.class.default_app_main_folder(app_name: Info::CMD_NAME), TRANSFERD_APP_NAME)
|
|
34
|
+
Log.log.debug{"Checking: #{former_sdk_folder}"}
|
|
35
|
+
sdk_dir = former_sdk_folder if Dir.exist?(former_sdk_folder)
|
|
36
|
+
end
|
|
37
|
+
Log.log.debug{"Using: #{sdk_dir}"}
|
|
38
|
+
Products::Transferd.sdk_directory = sdk_dir
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Install the transfer SDK (ascp + transferd) from a URL or using the default source.
|
|
43
|
+
# Version defaults to +Info::SDK_VERSION+; pass +LATEST+ as argument to install the latest available version.
|
|
44
|
+
# @param version [String, nil] version to install; nil means use the default SDK version
|
|
45
|
+
# @return [Result::Status] installation result message
|
|
46
|
+
def install_transfer_sdk(version: nil)
|
|
47
|
+
asked_version = version.nil? ? Info::SDK_VERSION : version
|
|
48
|
+
asked_version = nil if asked_version.eql?(SpecialValues::LATEST)
|
|
49
|
+
sdk_url = options.get_option(:sdk_url, mandatory: true)
|
|
50
|
+
sdk_url = nil if sdk_url.eql?(SpecialValues::DEF)
|
|
51
|
+
name, ver, folder = Ascp::Installation.instance.install_sdk(url: sdk_url, version: asked_version)
|
|
52
|
+
return Result::Status.new("Installed #{name} version #{ver} in #{folder}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def action_ascp_show(**)
|
|
56
|
+
Result::Text.new(Ascp::Installation.instance.path(:ascp))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def action_ascp_info(**)
|
|
60
|
+
data = Ascp::Installation.instance.ascp_info
|
|
61
|
+
data['ts'] = transfer.user_transfer_spec
|
|
62
|
+
DataRepository::ELEMENTS.each_with_object(data){ |i, h| h[i.to_s] = DataRepository.instance.item(i)}
|
|
63
|
+
SecretHider::ADDITIONAL_KEYS_TO_HIDE.concat(DataRepository::ELEMENTS.map(&:to_s))
|
|
64
|
+
Result::SingleObject.new(data)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def action_ascp_install(version: nil, **)
|
|
68
|
+
install_transfer_sdk(version: version)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def action_ascp_spec(**)
|
|
72
|
+
builder = Schema::Documentation.new(TerminalFormatter, Transfer::Spec::SCHEMA, include_option: true, agent_columns: true).build
|
|
73
|
+
Result::ObjectList.new(builder.rows, fields: builder.columns)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def action_ascp_schema(agent_name: nil, **)
|
|
77
|
+
schema = Transfer::Spec::SCHEMA.current.merge({'$comment'=>'DO NOT EDIT, this file was generated from the YAML.'})
|
|
78
|
+
schema['properties'] = schema['properties'].select{ |_k, v| CommandLineBuilder.supported_by_agent(agent_name, v)} unless agent_name.nil?
|
|
79
|
+
schema['properties'] = schema['properties'].sort.to_h
|
|
80
|
+
Result::SingleObject.new(schema)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def action_ascp_errors(**)
|
|
84
|
+
error_data = []
|
|
85
|
+
Ascp::Management::ERRORS.each_pair do |code, prop|
|
|
86
|
+
error_data.push(code: code, mnemonic: prop[:c], retry: prop[:r], info: prop[:a])
|
|
87
|
+
end
|
|
88
|
+
Result::ObjectList.new(error_data)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def action_ascp_products_list(**)
|
|
92
|
+
Result::ObjectList.new(Ascp::Installation.instance.installed_products, fields: %w[name app_root])
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def action_agents_list(**)
|
|
96
|
+
rows = Agent::Factory::ALL.map do |sym, names|
|
|
97
|
+
schema_key = AGENT_SCHEMA_KEY[sym]
|
|
98
|
+
param_names =
|
|
99
|
+
if schema_key
|
|
100
|
+
Schema::Registry.instance.reader(schema_key).current['properties']&.keys&.sort&.join(', ') || ''
|
|
101
|
+
else
|
|
102
|
+
''
|
|
103
|
+
end
|
|
104
|
+
{
|
|
105
|
+
'name' => sym.to_s,
|
|
106
|
+
'short' => names[:short].to_s,
|
|
107
|
+
'parameters' => param_names
|
|
108
|
+
}
|
|
109
|
+
end.sort_by{ |r| r['name']}
|
|
110
|
+
Result::ObjectList.new(rows, fields: %w[name short parameters])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def action_agents_show(agent_name:, **)
|
|
114
|
+
names = Agent::Factory::ALL[agent_name]
|
|
115
|
+
schema_key = AGENT_SCHEMA_KEY[agent_name]
|
|
116
|
+
agent_info = {
|
|
117
|
+
'name' => agent_name.to_s,
|
|
118
|
+
'short' => names[:short].to_s,
|
|
119
|
+
'description' => schema_key ? Schema::Registry.instance.reader(schema_key).current['description'].to_s : '(no configurable parameters)'
|
|
120
|
+
}
|
|
121
|
+
if schema_key
|
|
122
|
+
properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
|
|
123
|
+
rows = properties.map do |pname, pdef|
|
|
124
|
+
row = {'parameter' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
|
|
125
|
+
row['required'] = (Schema::Registry.instance.reader(schema_key).current['required'] || []).include?(pname) ? 'yes' : 'no'
|
|
126
|
+
row['default'] = pdef.key?('default') ? pdef['default'].inspect : ''
|
|
127
|
+
row['enum'] = pdef.key?('enum') ? pdef['enum'].join(', ') : ''
|
|
128
|
+
row
|
|
129
|
+
end
|
|
130
|
+
return Result::ObjectList.new(rows, fields: %w[parameter required type default enum description])
|
|
131
|
+
end
|
|
132
|
+
Result::SingleObject.new(agent_info)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def action_agents_parameters(agent_name:, **)
|
|
136
|
+
schema_key = AGENT_SCHEMA_KEY[agent_name]
|
|
137
|
+
return Result::Nothing.new if schema_key.nil?
|
|
138
|
+
properties = Schema::Registry.instance.reader(schema_key).current['properties'] || {}
|
|
139
|
+
rows = properties.map do |pname, pdef|
|
|
140
|
+
{'name' => pname, 'type' => pdef['type'].to_s, 'description' => pdef['description'].to_s}
|
|
141
|
+
end
|
|
142
|
+
Result::ObjectList.new(rows, fields: %w[name type description])
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def action_transferd_install(**)
|
|
146
|
+
install_transfer_sdk
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def action_transferd_list(**)
|
|
150
|
+
sdk_list = Ascp::Installation.instance.sdk_locations
|
|
151
|
+
Result::ObjectList.new(sdk_list, fields: sdk_list.first.keys - ['url'])
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/log'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module Aspera
|
|
8
|
+
module Cli
|
|
9
|
+
# Persist the state of asynchronous transfers on the local file system.
|
|
10
|
+
#
|
|
11
|
+
# Each entry is a JSON document identified by a `job_id` (UUID generated by ascli).
|
|
12
|
+
# The underlying storage uses PersistencyFolder with the category prefix
|
|
13
|
+
# +CATEGORY+ so all async-transfer files are grouped together and can be
|
|
14
|
+
# garbage-collected independently.
|
|
15
|
+
#
|
|
16
|
+
# Schema of a stored entry:
|
|
17
|
+
# job_id [String] UUID generated by ascli (= the store key)
|
|
18
|
+
# agent_type [String] 'desktop' | 'node' | 'connect' | 'transferd' | 'direct'
|
|
19
|
+
# transfer_id [String] Opaque ID returned by the agent's start_transfer
|
|
20
|
+
# agent_params [Hash] Agent-specific connection parameters for re-querying
|
|
21
|
+
# status [String] 'running' | 'completed' | 'failed' | 'cancelled'
|
|
22
|
+
# bytes_transferred [Integer] Last known bytes transferred (0 if unknown)
|
|
23
|
+
# started_at [String] ISO-8601 timestamp
|
|
24
|
+
# ended_at [String, nil] ISO-8601 timestamp once finished
|
|
25
|
+
# error [String, nil] Error message when status == 'failed'
|
|
26
|
+
class AsyncTransferStore
|
|
27
|
+
CATEGORY = 'async_transfer_'
|
|
28
|
+
private_constant :CATEGORY
|
|
29
|
+
|
|
30
|
+
# @param persistency [PersistencyFolder]
|
|
31
|
+
def initialize(persistency)
|
|
32
|
+
@persistency = persistency
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Persist (create or update) an async transfer entry.
|
|
36
|
+
# @param job_id [String] the ascli-generated UUID
|
|
37
|
+
# @param data [Hash] fields to store (will be JSON-serialised)
|
|
38
|
+
def write(job_id, data)
|
|
39
|
+
Aspera.assert_type(job_id, String){'job_id'}
|
|
40
|
+
Aspera.assert_type(data, Hash){'data'}
|
|
41
|
+
@persistency.put(store_key(job_id), JSON.generate(data))
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Read one entry.
|
|
46
|
+
# @param job_id [String]
|
|
47
|
+
# @return [Hash, nil] the stored data, or nil if not found
|
|
48
|
+
def read(job_id)
|
|
49
|
+
Aspera.assert_type(job_id, String){'job_id'}
|
|
50
|
+
raw = @persistency.get(store_key(job_id))
|
|
51
|
+
return if raw.nil?
|
|
52
|
+
JSON.parse(raw)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# List all async transfer entries.
|
|
56
|
+
# @return [Array<Hash>] each entry includes the +job_id+ field
|
|
57
|
+
def list
|
|
58
|
+
@persistency.current_items(CATEGORY).map do |key, raw|
|
|
59
|
+
data = JSON.parse(raw)
|
|
60
|
+
data['job_id'] ||= key.sub(CATEGORY, '')
|
|
61
|
+
data
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Delete one entry.
|
|
66
|
+
# @param job_id [String]
|
|
67
|
+
def delete(job_id)
|
|
68
|
+
Aspera.assert_type(job_id, String){'job_id'}
|
|
69
|
+
@persistency.delete(store_key(job_id))
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Map a job_id to the PersistencyFolder key (includes category prefix).
|
|
76
|
+
def store_key(job_id)
|
|
77
|
+
"#{CATEGORY}#{job_id}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/cli/plugins/factory'
|
|
4
|
+
require 'aspera/cli/extended_value'
|
|
5
|
+
require 'aspera/cli/special_values'
|
|
6
|
+
require 'aspera/cli/info'
|
|
7
|
+
require 'aspera/cli/transfer_progress'
|
|
8
|
+
require 'aspera/cli/preset_manager'
|
|
9
|
+
require 'aspera/cli/http'
|
|
10
|
+
require 'aspera/ascp/installation'
|
|
11
|
+
require 'aspera/products/transferd'
|
|
12
|
+
require 'aspera/transfer/parameters'
|
|
13
|
+
require 'aspera/proxy_auto_config'
|
|
14
|
+
require 'aspera/environment'
|
|
15
|
+
require 'aspera/persistency_folder'
|
|
16
|
+
require 'aspera/rest'
|
|
17
|
+
require 'aspera/log'
|
|
18
|
+
require 'aspera/assert'
|
|
19
|
+
require 'aspera/oauth'
|
|
20
|
+
require 'aspera/ssl'
|
|
21
|
+
require 'aspera/schema/registry'
|
|
22
|
+
|
|
23
|
+
module Aspera
|
|
24
|
+
module Cli
|
|
25
|
+
# Performs one-time bootstrap of all shared services into Context.
|
|
26
|
+
# Called by Runner before instantiating Plugins::Config, so that Config
|
|
27
|
+
# acts only as a CLI plugin (option declaration + command handlers).
|
|
28
|
+
#
|
|
29
|
+
# Responsibilities:
|
|
30
|
+
# - resolve context.main_folder (option :home)
|
|
31
|
+
# - populate context.persistency, context.presets, context.http_config, context.progress_bar
|
|
32
|
+
# - register plugin lookup folders
|
|
33
|
+
# - register @preset / @vault extended-value handlers
|
|
34
|
+
# - configure global singletons: RestParameters, OAuth::Factory, SSL, Transfer::Parameters,
|
|
35
|
+
# RestErrorAnalyzer
|
|
36
|
+
# - set up the PAC proxy executor (option :fpac)
|
|
37
|
+
class Bootstrapper
|
|
38
|
+
# Folder name inside $HOME for all Aspera tool data (~/.aspera)
|
|
39
|
+
ASPERA_HOME_FOLDER_NAME = '.aspera'
|
|
40
|
+
# Default name for the YAML config file
|
|
41
|
+
DEFAULT_CONFIG_FILENAME = 'config.yaml'
|
|
42
|
+
# Sub-folder for user-installed plugins
|
|
43
|
+
ASPERA_PLUGINS_FOLDERNAME = 'plugins'
|
|
44
|
+
# Sub-folder for persistency data
|
|
45
|
+
PERSISTENCY_FOLDER = 'persist_store'
|
|
46
|
+
# Sub-folder for file lists used by transfers
|
|
47
|
+
FILE_LIST_FOLDER_NAME = 'filelists'
|
|
48
|
+
# Log file for REST call exceptions
|
|
49
|
+
REST_EXCEPTIONS_LOG_FILENAME = 'rest_exceptions.log'
|
|
50
|
+
# Extended-value prefix that resolves a named preset
|
|
51
|
+
EXTEND_PRESET = :preset
|
|
52
|
+
# Extended-value prefix that resolves a vault secret
|
|
53
|
+
EXTEND_VAULT = :vault
|
|
54
|
+
# Default preset name for the global config section
|
|
55
|
+
CONF_GLOBAL_SYM = :config
|
|
56
|
+
|
|
57
|
+
private_constant :ASPERA_HOME_FOLDER_NAME,
|
|
58
|
+
:DEFAULT_CONFIG_FILENAME,
|
|
59
|
+
:ASPERA_PLUGINS_FOLDERNAME,
|
|
60
|
+
:PERSISTENCY_FOLDER,
|
|
61
|
+
:FILE_LIST_FOLDER_NAME,
|
|
62
|
+
:REST_EXCEPTIONS_LOG_FILENAME,
|
|
63
|
+
:EXTEND_PRESET,
|
|
64
|
+
:EXTEND_VAULT,
|
|
65
|
+
:CONF_GLOBAL_SYM
|
|
66
|
+
|
|
67
|
+
# @param context [Context] the shared context to populate
|
|
68
|
+
def initialize(context)
|
|
69
|
+
Aspera.assert_type(context, Context){'context'}
|
|
70
|
+
@context = context
|
|
71
|
+
@pac_exec = nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Run the full bootstrap sequence.
|
|
75
|
+
# Must be called after context.options and context.formatter are set.
|
|
76
|
+
# On return, context.persistency, context.presets, context.http_config and
|
|
77
|
+
# context.progress_bar are populated and all global singletons are configured.
|
|
78
|
+
#
|
|
79
|
+
# @param gem_plugins_folder [String] folder of built-in plugins (from Plugins::Config)
|
|
80
|
+
# @param vault_value_cb [Proc] block(name): secret value (from VaultManager)
|
|
81
|
+
def run(gem_plugins_folder:, vault_value_cb:)
|
|
82
|
+
setup_main_folder
|
|
83
|
+
setup_persistency_and_plugin_folders(gem_plugins_folder)
|
|
84
|
+
setup_config_file
|
|
85
|
+
setup_extended_value_handlers(vault_value_cb)
|
|
86
|
+
setup_progress_bar
|
|
87
|
+
setup_pac_executor
|
|
88
|
+
setup_rest_and_transfer_runtime
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Public accessor used as option handler for :config_file
|
|
92
|
+
attr_accessor :config_file_option
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Declare + parse :home option: sets context.main_folder
|
|
97
|
+
def setup_main_folder
|
|
98
|
+
@context.options.declare(
|
|
99
|
+
:home, description: 'Home folder for tool',
|
|
100
|
+
handler: {o: @context, m: :main_folder},
|
|
101
|
+
default: default_app_main_folder(app_name: Info::CMD_NAME)
|
|
102
|
+
)
|
|
103
|
+
@context.options.parse_options!
|
|
104
|
+
Log.log.debug{"#{Info::CMD_NAME} folder: #{@context.main_folder}"}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# context.persistency + plugin lookup folders
|
|
108
|
+
def setup_persistency_and_plugin_folders(gem_plugins_folder)
|
|
109
|
+
@context.persistency = PersistencyFolder.new(File.join(@context.main_folder, PERSISTENCY_FOLDER))
|
|
110
|
+
Plugins::Factory.instance.add_lookup_folder(gem_plugins_folder)
|
|
111
|
+
Plugins::Factory.instance.add_lookup_folder(File.join(@context.main_folder, ASPERA_PLUGINS_FOLDERNAME))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Declare + parse :config_file option: sets context.presets + context.http_config
|
|
115
|
+
def setup_config_file
|
|
116
|
+
@context.options.declare(
|
|
117
|
+
:config_file, description: 'Path to YAML file with preset configuration',
|
|
118
|
+
handler: {o: self, m: :config_file_option},
|
|
119
|
+
default: File.join(@context.main_folder, DEFAULT_CONFIG_FILENAME)
|
|
120
|
+
)
|
|
121
|
+
@context.options.parse_options!
|
|
122
|
+
@context.presets = PresetManager.new(config_file: @config_file_option)
|
|
123
|
+
@context.http_config = Http.new
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Register @preset and @vault extended-value handlers + global config default preset
|
|
127
|
+
def setup_extended_value_handlers(vault_value_cb)
|
|
128
|
+
@context.options.declare(:secret, description: 'Secret for access keys')
|
|
129
|
+
@context.options.declare(:vault, schema: Schema::Registry::VAULT_OPTIONS)
|
|
130
|
+
@context.options.declare(:vault_password, description: 'Vault password')
|
|
131
|
+
# Register @preset and @vault handlers BEFORE parse_options! so that
|
|
132
|
+
# values like --secret=@preset:name are correctly evaluated at parse time.
|
|
133
|
+
ExtendedValue.instance.on(EXTEND_PRESET){ |v| @context.presets.by_name(v)}
|
|
134
|
+
ExtendedValue.instance.on(EXTEND_VAULT, &vault_value_cb)
|
|
135
|
+
@context.options.parse_options!
|
|
136
|
+
# Load global config default preset (equivalent of add_plugin_default_preset(:config))
|
|
137
|
+
default_config_name = @context.presets.plugin_default_name(CONF_GLOBAL_SYM)
|
|
138
|
+
unless default_config_name.nil?
|
|
139
|
+
Log.log.debug{"add_plugin_default_preset:#{CONF_GLOBAL_SYM}:#{default_config_name}"}
|
|
140
|
+
@context.options.add_option_preset(@context.presets.by_name(default_config_name), 'default_plugin', override: false)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Declare + parse :progress_bar: sets context.progress_bar
|
|
145
|
+
def setup_progress_bar
|
|
146
|
+
@context.options.declare(:progress_bar, description: 'Display progress bar', allowed: Allowed::TYPES_BOOLEAN, default: Environment.terminal?)
|
|
147
|
+
@context.options.parse_options!
|
|
148
|
+
@context.progress_bar = TransferProgress.new if @context.options.get_option(:progress_bar)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Declare + parse :fpac / :proxy_credentials: sets up PAC executor
|
|
152
|
+
def setup_pac_executor
|
|
153
|
+
@context.options.declare(:fpac, description: 'Proxy auto configuration script')
|
|
154
|
+
@context.options.declare(:proxy_credentials, description: 'HTTP proxy credentials for fpac: user, password', allowed: [Array, NilClass])
|
|
155
|
+
@context.options.parse_options!
|
|
156
|
+
pac_script = @context.options.get_option(:fpac)
|
|
157
|
+
return unless pac_script
|
|
158
|
+
|
|
159
|
+
@context.pac_executor = ProxyAutoConfig.new(pac_script).register_uri_generic
|
|
160
|
+
proxy_user_pass = @context.options.get_option(:proxy_credentials)
|
|
161
|
+
if proxy_user_pass
|
|
162
|
+
Aspera.assert(proxy_user_pass.length.eql?(2), type: Cli::BadArgument){"proxy_credentials shall have two elements (#{proxy_user_pass.length})"}
|
|
163
|
+
@context.pac_executor.proxy_user = proxy_user_pass[0]
|
|
164
|
+
@context.pac_executor.proxy_pass = proxy_user_pass[1]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Configure global singletons: RestParameters, SSL, Transfer, RestErrorAnalyzer.
|
|
169
|
+
# OAuth persist_mgr is NOT set here: it depends on :cache_tokens which is parsed later
|
|
170
|
+
# by Config#initialize. Runner sets it after Config.new.
|
|
171
|
+
def setup_rest_and_transfer_runtime
|
|
172
|
+
RestParameters.instance.user_agent = Info::CMD_NAME
|
|
173
|
+
RestParameters.instance.progress_bar = @context.progress_bar
|
|
174
|
+
RestParameters.instance.session_cb = ->(http_session){@context.http_config.update_session(http_session)}
|
|
175
|
+
RestParameters.instance.spinner_cb = ->(title = nil, action: :spin){@context.formatter.long_operation(title, action: action)}
|
|
176
|
+
OAuth::Web.additional_info = "#{Info::CMD_NAME} v#{Cli::VERSION}"
|
|
177
|
+
Transfer::Parameters.file_list_folder = File.join(@context.main_folder, FILE_LIST_FOLDER_NAME)
|
|
178
|
+
RestErrorAnalyzer.instance.log_file = File.join(@context.main_folder, REST_EXCEPTIONS_LOG_FILENAME)
|
|
179
|
+
RestErrorsAspera.register_handlers
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @return [String] ~/.aspera
|
|
183
|
+
def module_family_folder
|
|
184
|
+
user_home_folder = Dir.home
|
|
185
|
+
Aspera.assert(Dir.exist?(user_home_folder), type: Cli::Error){"Home folder does not exist: #{user_home_folder}. Check your user environment."}
|
|
186
|
+
File.join(user_home_folder, ASPERA_HOME_FOLDER_NAME)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [String] ~/.aspera/<app_name>
|
|
190
|
+
def default_app_main_folder(app_name:)
|
|
191
|
+
Aspera.assert_type(app_name, String)
|
|
192
|
+
Aspera.assert(!app_name.empty?, 'app_name must not be empty')
|
|
193
|
+
File.join(module_family_folder, app_name)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/cli/command_spec'
|
|
4
|
+
|
|
5
|
+
module Aspera
|
|
6
|
+
module Cli
|
|
7
|
+
# Stores CommandSpec objects indexed by their full path (Array<Symbol>).
|
|
8
|
+
# Each plugin class gets its own instance (not shared across the inheritance chain).
|
|
9
|
+
#
|
|
10
|
+
# Public API:
|
|
11
|
+
# register(spec) - store a CommandSpec; raises on duplicate full_path
|
|
12
|
+
# register_option(spec) - store an OptionSpec by name
|
|
13
|
+
# option_specs - Hash{Symbol => OptionSpec} of all registered options
|
|
14
|
+
# [](path) - retrieve a CommandSpec by full path
|
|
15
|
+
# children_of(path) - Hash{Symbol => CommandSpec} of direct children (O(1))
|
|
16
|
+
# all_paths - Array of all registered full paths
|
|
17
|
+
# any? - true if at least one spec has been registered
|
|
18
|
+
# validate! - cross-spec consistency checks; raises on violation
|
|
19
|
+
class CommandRegistry
|
|
20
|
+
# @param path [Array<Symbol>] full path to look up
|
|
21
|
+
# @return [CommandSpec, nil]
|
|
22
|
+
def [](path)
|
|
23
|
+
@specs[Array(path)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register a CommandSpec. Raises if the full_path is already registered.
|
|
27
|
+
# Also updates the children index so children_of remains O(1).
|
|
28
|
+
# @param spec [CommandSpec]
|
|
29
|
+
# @raise [ArgumentError] on duplicate full path
|
|
30
|
+
# @return [CommandSpec] the registered spec
|
|
31
|
+
def register(spec)
|
|
32
|
+
path = spec.full_path
|
|
33
|
+
raise ArgumentError, "Duplicate command path: #{path.inspect}" if @specs.key?(path)
|
|
34
|
+
@specs[path] = spec
|
|
35
|
+
# Index: parent_path -> { child_id -> spec }
|
|
36
|
+
parent = path[0..-2] # [] for root-level commands
|
|
37
|
+
(@children_index[parent] ||= {})[spec.id] = spec
|
|
38
|
+
spec
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns a Hash mapping each child id to its CommandSpec for all direct
|
|
42
|
+
# children of `path`. Empty hash if no children are registered.
|
|
43
|
+
# O(1) lookup via the children index built in register().
|
|
44
|
+
# @param path [Array<Symbol>] parent path ([] for root-level commands)
|
|
45
|
+
# @return [Hash{Symbol => CommandSpec}]
|
|
46
|
+
def children_of(path)
|
|
47
|
+
@children_index[Array(path)] || {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Array<Symbol>>] all registered full paths
|
|
51
|
+
def all_paths
|
|
52
|
+
@specs.keys
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register an OptionSpec. Raises if the option name is already registered.
|
|
56
|
+
# @param spec [OptionSpec]
|
|
57
|
+
# @raise [ArgumentError] on duplicate option name
|
|
58
|
+
# @return [OptionSpec] the registered spec
|
|
59
|
+
def register_option(spec)
|
|
60
|
+
raise ArgumentError, "Duplicate option: #{spec.name.inspect}" if @option_specs.key?(spec.name)
|
|
61
|
+
@option_specs[spec.name] = spec
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash{Symbol => OptionSpec}] all registered option specs
|
|
65
|
+
def option_specs
|
|
66
|
+
@option_specs.dup
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Boolean] true if at least one spec is registered
|
|
70
|
+
def any?
|
|
71
|
+
!@specs.empty?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] true if no specs have been registered
|
|
75
|
+
def none?
|
|
76
|
+
@specs.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Cross-spec consistency checks.
|
|
80
|
+
# @param plugin_class [Class, nil] when given, also verify that implicit action methods exist
|
|
81
|
+
# @raise [ArgumentError] on any violation
|
|
82
|
+
# @return [self]
|
|
83
|
+
def validate!(plugin_class: nil)
|
|
84
|
+
# Rule: every non-root parent path that appears in the children index must have
|
|
85
|
+
# a registered CommandSpec. A missing parent means commands_under(:x) was used
|
|
86
|
+
# without a matching command :x declaration.
|
|
87
|
+
@children_index.each_key do |parent_path|
|
|
88
|
+
next if parent_path.empty? # root is never a CommandSpec
|
|
89
|
+
unless @specs.key?(parent_path)
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"commands_under(#{parent_path.map(&:inspect).join(', ')}) used but #{parent_path.last.inspect} has no command declaration"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@specs.each_value do |spec|
|
|
96
|
+
path = spec.full_path
|
|
97
|
+
|
|
98
|
+
# Rule: delegates_to must point to a known path when present (non-empty array or symbol)
|
|
99
|
+
if spec.delegates_to
|
|
100
|
+
dt_path =
|
|
101
|
+
case spec.delegates_to
|
|
102
|
+
when Symbol then [spec.delegates_to]
|
|
103
|
+
when Array then spec.delegates_to
|
|
104
|
+
end
|
|
105
|
+
# An empty array [] means re-enter the root - always valid
|
|
106
|
+
unless dt_path.empty? || @specs.key?(dt_path)
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"#{path.inspect}: delegates_to #{dt_path.inspect} points to unknown path"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Rule: delegate_instance requires delegates_to
|
|
113
|
+
if spec.delegate_instance && spec.delegates_to.nil?
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"#{path.inspect}: delegate_instance requires delegates_to to be set"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Rule: leaf commands with no explicit action must have a matching instance method
|
|
119
|
+
next if spec.action # explicit action: skip
|
|
120
|
+
next if @children_index[path]&.any? # intermediate node: skip
|
|
121
|
+
next if spec.delegates_to || spec.entity_execute # delegated: skip
|
|
122
|
+
next unless plugin_class
|
|
123
|
+
implicit_method = CommandSpec.action_method(path)
|
|
124
|
+
unless plugin_class.method_defined?(implicit_method) || plugin_class.private_method_defined?(implicit_method)
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"#{path.inspect}: no action: and no method #{implicit_method} on #{plugin_class}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def initialize
|
|
135
|
+
# Keyed by Array<Symbol> full path
|
|
136
|
+
@specs = {}
|
|
137
|
+
# Keyed by Symbol option name
|
|
138
|
+
@option_specs = {}
|
|
139
|
+
# Children index: parent Array<Symbol> -> Hash{child_id Symbol => CommandSpec}
|
|
140
|
+
# Built incrementally in register(); enables O(1) children_of lookups.
|
|
141
|
+
@children_index = {}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|