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,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/cli/extended_value'
|
|
4
|
+
require 'aspera/cli/version'
|
|
5
|
+
require 'aspera/cli/info'
|
|
6
|
+
require 'aspera/log'
|
|
7
|
+
require 'aspera/assert'
|
|
8
|
+
require 'aspera/yaml'
|
|
9
|
+
require 'digest'
|
|
10
|
+
require 'yaml'
|
|
11
|
+
|
|
12
|
+
module Aspera
|
|
13
|
+
module Cli
|
|
14
|
+
# Manages the YAML config file and all preset-related operations.
|
|
15
|
+
# Extracted from Plugins::Config so it can be referenced independently
|
|
16
|
+
# via Context#presets without coupling to the plugin machinery.
|
|
17
|
+
class PresetManager
|
|
18
|
+
# Reserved keys in the YAML config file
|
|
19
|
+
module Key
|
|
20
|
+
CONFIG = 'config'
|
|
21
|
+
VERSION = 'version'
|
|
22
|
+
DEFAULTS = 'default'
|
|
23
|
+
GLOBAL = 'global_common_defaults'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
GLOBAL_DEFAULT_KEYWORD = 'GLOBAL'
|
|
27
|
+
CONF_GLOBAL_SYM = :config
|
|
28
|
+
# Separator for dot-notation digging into presets
|
|
29
|
+
PRESET_DIG_SEPARATOR = '.'
|
|
30
|
+
|
|
31
|
+
private_constant :PRESET_DIG_SEPARATOR
|
|
32
|
+
|
|
33
|
+
# @param config_file [String] absolute path to the YAML config file
|
|
34
|
+
# @param use_plugin_defaults [Boolean] if false, skip default preset lookup
|
|
35
|
+
def initialize(config_file:, use_plugin_defaults: true)
|
|
36
|
+
@config_file = config_file
|
|
37
|
+
@use_plugin_defaults = use_plugin_defaults
|
|
38
|
+
@config_presets = {}
|
|
39
|
+
@checksum_on_disk = nil
|
|
40
|
+
read_config_file
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :config_file, :config_presets, :use_plugin_defaults
|
|
44
|
+
attr_writer :use_plugin_defaults
|
|
45
|
+
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
# File I/O
|
|
48
|
+
# ------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# @return [String] SHA1 of current in-memory presets
|
|
51
|
+
def checksum
|
|
52
|
+
Digest::SHA1.hexdigest(JSON.generate(@config_presets))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read and validate the YAML config file.
|
|
56
|
+
# Sets @config_presets and @checksum_on_disk.
|
|
57
|
+
def read_config_file
|
|
58
|
+
Log.log.debug{"config file is: #{@config_file}".red}
|
|
59
|
+
if File.exist?(@config_file)
|
|
60
|
+
Log.log.debug{"loading #{@config_file}"}
|
|
61
|
+
@config_presets = Yaml.safe_load(File.read(@config_file))
|
|
62
|
+
@checksum_on_disk = checksum
|
|
63
|
+
else
|
|
64
|
+
Log.log.warn{"No config file found. New configuration file: #{@config_file}"}
|
|
65
|
+
@config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
|
|
66
|
+
# @checksum_on_disk remains nil: will be saved on first write
|
|
67
|
+
end
|
|
68
|
+
validate_config_presets!
|
|
69
|
+
rescue Psych::SyntaxError => e
|
|
70
|
+
Log.log.error('YAML error in config file')
|
|
71
|
+
raise e
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Log.log.debug{"-> #{e.class.name} : #{e}"}
|
|
74
|
+
if File.exist?(@config_file)
|
|
75
|
+
new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
|
|
76
|
+
File.rename(@config_file, new_name)
|
|
77
|
+
Log.log.warn{"Renamed config file to #{new_name}."}
|
|
78
|
+
Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
|
|
79
|
+
end
|
|
80
|
+
raise Cli::Error, e.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Save to disk only if content changed since last load/save.
|
|
84
|
+
# @return [Boolean] true if actually written
|
|
85
|
+
def save_if_needed
|
|
86
|
+
Aspera.assert(!@config_presets.nil?, type: Cli::Error){'no configuration loaded'}
|
|
87
|
+
current = checksum
|
|
88
|
+
return false if @checksum_on_disk.eql?(current)
|
|
89
|
+
FileUtils.mkdir_p(File.dirname(@config_file))
|
|
90
|
+
Environment.restrict_file_access(File.dirname(@config_file))
|
|
91
|
+
Log.log.info{"Saving config file: #{@config_file}"}
|
|
92
|
+
Environment.write_file_restricted(@config_file, force: true){@config_presets.to_yaml}
|
|
93
|
+
@checksum_on_disk = current
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# Preset read/write helpers
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# @return [String, nil] name of the default preset for a plugin, or nil
|
|
102
|
+
def plugin_default_name(plugin_name_sym)
|
|
103
|
+
Aspera.assert(!@config_presets.nil?, 'config_presets shall be defined')
|
|
104
|
+
return unless @use_plugin_defaults
|
|
105
|
+
return unless @config_presets.key?(Key::DEFAULTS)
|
|
106
|
+
Aspera.assert_type(@config_presets[Key::DEFAULTS], Hash){'default section'}
|
|
107
|
+
return unless @config_presets[Key::DEFAULTS].key?(plugin_name_sym.to_s)
|
|
108
|
+
default_name = @config_presets[Key::DEFAULTS][plugin_name_sym.to_s]
|
|
109
|
+
unless @config_presets.key?(default_name)
|
|
110
|
+
Log.log.error do
|
|
111
|
+
"Default config name [#{default_name}] specified for plugin [#{plugin_name_sym}], but it does not exist in config file.\n" \
|
|
112
|
+
"Please fix: either create preset:\n" \
|
|
113
|
+
"#{Info::CMD_NAME} config id #{default_name} init @json:'{}'\n" \
|
|
114
|
+
"or remove default:\n#{Info::CMD_NAME} config id default remove #{plugin_name_sym}"
|
|
115
|
+
end
|
|
116
|
+
raise Cli::Error, "No such preset: #{default_name}"
|
|
117
|
+
end
|
|
118
|
+
Aspera.assert_type(@config_presets[default_name], Hash, type: Cli::Error){'preset type'}
|
|
119
|
+
default_name
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Resolve and return a deep-cloned, extended copy of a named preset.
|
|
123
|
+
# Supports dot-notation (e.g. "outer.inner").
|
|
124
|
+
# @param config_name [String]
|
|
125
|
+
# @param include_path [Array] guard against include loops
|
|
126
|
+
def by_name(config_name, include_path = [])
|
|
127
|
+
Aspera.assert(!include_path.include?(config_name), type: Cli::Error){'loop in include'}
|
|
128
|
+
include_path = include_path.clone
|
|
129
|
+
current = @config_presets
|
|
130
|
+
config_name.split(PRESET_DIG_SEPARATOR).each do |name|
|
|
131
|
+
Aspera.assert_type(current, Hash, type: Cli::Error){"sub key: #{include_path}"}
|
|
132
|
+
include_path.push(name)
|
|
133
|
+
current = current[name]
|
|
134
|
+
Aspera.assert(!current.nil?, type: Cli::Error){"Unknown config preset: #{include_path}"}
|
|
135
|
+
end
|
|
136
|
+
current = self.class.deep_clone(current) unless current.is_a?(String)
|
|
137
|
+
ExtendedValue.instance.evaluate(current, context: 'preset')
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Set a single key in a preset hash (creates the preset if absent).
|
|
141
|
+
def set_key(preset, param_name, param_value)
|
|
142
|
+
Aspera.assert_type(param_name, String, Symbol){'parameter'}
|
|
143
|
+
param_name = param_name.to_s
|
|
144
|
+
selected = @config_presets[preset]
|
|
145
|
+
if selected.nil?
|
|
146
|
+
Log.log.debug{"Unknown preset name: #{preset}, initializing"}
|
|
147
|
+
selected = @config_presets[preset] = {}
|
|
148
|
+
end
|
|
149
|
+
Aspera.assert_type(selected, Hash){"#{preset}.#{param_name}"}
|
|
150
|
+
if selected.key?(param_name)
|
|
151
|
+
if selected[param_name].eql?(param_value)
|
|
152
|
+
Log.log.warn{"keeping same value for #{preset}: #{param_name}: #{param_value}"}
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
Log.log.warn{"overwriting value for #{param_name}: #{selected[param_name]}"}
|
|
156
|
+
end
|
|
157
|
+
selected[param_name] = param_value
|
|
158
|
+
Log.log.info("Updated: #{preset}: #{param_name} <- #{param_value}")
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [String] name of the global default preset, creating it if needed
|
|
163
|
+
def global_default_preset
|
|
164
|
+
result = plugin_default_name(CONF_GLOBAL_SYM)
|
|
165
|
+
if result.nil?
|
|
166
|
+
result = Key::GLOBAL
|
|
167
|
+
set_key(Key::DEFAULTS, CONF_GLOBAL_SYM, result)
|
|
168
|
+
end
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Set param in the global defaults preset
|
|
173
|
+
def set_global_default(key, value)
|
|
174
|
+
set_key(global_default_preset, key, value)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Create / overwrite a preset and optionally set it as plugin default.
|
|
178
|
+
def defaults_set(plugin_name, preset_name, preset_values, option_default, option_override)
|
|
179
|
+
@config_presets[Key::DEFAULTS] ||= {}
|
|
180
|
+
raise Cli::Error, "A default configuration already exists for plugin '#{plugin_name}' (use --override=yes or --default=no)" \
|
|
181
|
+
if !option_override && option_default && @config_presets[Key::DEFAULTS].key?(plugin_name)
|
|
182
|
+
raise Cli::Error, "Preset already exists: #{preset_name} (use --override=yes or provide alternate name on command line)" \
|
|
183
|
+
if !option_override && @config_presets.key?(preset_name)
|
|
184
|
+
if option_default
|
|
185
|
+
Log.log.info("Setting config preset as default for #{plugin_name}")
|
|
186
|
+
@config_presets[Key::DEFAULTS][plugin_name.to_s] = preset_name
|
|
187
|
+
end
|
|
188
|
+
@config_presets[preset_name] = preset_values
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Find the first preset whose url+username match
|
|
192
|
+
# @return [Hash, nil]
|
|
193
|
+
def lookup_preset(url:, username:)
|
|
194
|
+
url = canonical_url(url)
|
|
195
|
+
Log.log.debug{"Lookup preset for #{username}@#{url}"}
|
|
196
|
+
@config_presets.each_value do |v|
|
|
197
|
+
next unless v.is_a?(Hash)
|
|
198
|
+
conf_url = v['url'].is_a?(String) ? canonical_url(v['url']) : nil
|
|
199
|
+
return self.class.deep_clone(v) if conf_url.eql?(url) && v['username'].eql?(username)
|
|
200
|
+
end
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Class helpers
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
class << self
|
|
209
|
+
def deep_clone(val)
|
|
210
|
+
Marshal.load(Marshal.dump(val))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# Normalize a URL for comparison
|
|
217
|
+
def canonical_url(url)
|
|
218
|
+
url.chomp('/').sub(%r{^(https://[^/]+):443$}, '\1')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Structural validation + compatibility fixes on @config_presets
|
|
222
|
+
def validate_config_presets!
|
|
223
|
+
Log.dump(:available_presets, @config_presets, level: :trace1)
|
|
224
|
+
Aspera.assert_type(@config_presets, Hash){'config file YAML'}
|
|
225
|
+
Aspera.assert(@config_presets.key?(Key::CONFIG)){"Cannot find key: #{Key::CONFIG}"}
|
|
226
|
+
version = @config_presets[Key::CONFIG][Key::VERSION]
|
|
227
|
+
Aspera.assert(!version.nil?, type: Cli::Error){'No version found in config section.'}
|
|
228
|
+
Log.log.debug{"conf version: #{version}"}
|
|
229
|
+
# Fix bug in 4.4 (creating key "true" in "default" preset)
|
|
230
|
+
@config_presets[Key::DEFAULTS].delete(true) if @config_presets[Key::DEFAULTS].is_a?(Hash)
|
|
231
|
+
# Stamp with current version
|
|
232
|
+
@config_presets[Key::CONFIG][Key::VERSION] = Cli::VERSION
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/assert'
|
|
4
|
+
require 'aspera/nagios'
|
|
5
|
+
require 'aspera/log'
|
|
6
|
+
require 'aspera/environment'
|
|
7
|
+
require 'aspera/uri_reader'
|
|
8
|
+
require 'aspera/preview/terminal'
|
|
9
|
+
require 'aspera/dot_container'
|
|
10
|
+
require 'base64'
|
|
11
|
+
require 'uri'
|
|
12
|
+
require 'pp'
|
|
13
|
+
require 'json'
|
|
14
|
+
require 'yaml'
|
|
15
|
+
|
|
16
|
+
module Aspera
|
|
17
|
+
module Cli
|
|
18
|
+
# Base class for all result types
|
|
19
|
+
# Each result type is now a separate class instead of using a type field
|
|
20
|
+
class Result
|
|
21
|
+
attr_reader :data, :fields
|
|
22
|
+
|
|
23
|
+
# @param data [Object,nil] The result data
|
|
24
|
+
# @param fields [Object] Specification of fields to include
|
|
25
|
+
def initialize(data: nil, fields: nil)
|
|
26
|
+
@data = data
|
|
27
|
+
@fields = fields
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Format this result using the provided formatter
|
|
31
|
+
# This method implements the Visitor pattern, allowing each Result subclass
|
|
32
|
+
# to define how it should be formatted without the Formatter needing to know
|
|
33
|
+
# about all Result types.
|
|
34
|
+
# Base implementation handles common formats: text, nagios, ruby, json, jsonpp, yaml
|
|
35
|
+
# Subclasses should call super first, then handle their specific formats
|
|
36
|
+
# @param formatter [Formatter] The formatter to use
|
|
37
|
+
# @return [nil]
|
|
38
|
+
def format(formatter)
|
|
39
|
+
# Apply field filtering once for formats that need it
|
|
40
|
+
filtered_data = formatter.filter_list_on_fields(@data)
|
|
41
|
+
case formatter.format_type
|
|
42
|
+
when :text
|
|
43
|
+
formatter.display_message(:data, @data.to_s)
|
|
44
|
+
when :nagios
|
|
45
|
+
Nagios.process(@data)
|
|
46
|
+
when :ruby
|
|
47
|
+
formatter.display_message(:data, PP.pp(filtered_data, +''))
|
|
48
|
+
when :json
|
|
49
|
+
formatter.display_message(:data, JSON.generate(filtered_data))
|
|
50
|
+
when :jsonpp
|
|
51
|
+
formatter.display_message(:data, JSON.pretty_generate(filtered_data))
|
|
52
|
+
when :yaml
|
|
53
|
+
formatter.display_message(:data, YAML.dump(filtered_data))
|
|
54
|
+
when :image
|
|
55
|
+
if @data.nil?
|
|
56
|
+
formatter.display_message(:data, formatter.special_format('null (no image)'))
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
Aspera.assert_type(@data, String){'image: URL or blob'}
|
|
60
|
+
# Check if URL
|
|
61
|
+
data =
|
|
62
|
+
begin
|
|
63
|
+
# just validate
|
|
64
|
+
URI.parse(@data)
|
|
65
|
+
if Environment.instance.url_method.eql?(:text)
|
|
66
|
+
UriReader.read(@data)
|
|
67
|
+
else
|
|
68
|
+
Environment.instance.open_uri(@data)
|
|
69
|
+
formatter.display_message(:info, "Opened URL in browser: #{@data}")
|
|
70
|
+
:done
|
|
71
|
+
end
|
|
72
|
+
rescue URI::InvalidURIError
|
|
73
|
+
@data
|
|
74
|
+
end
|
|
75
|
+
unless data.eql?(:done)
|
|
76
|
+
# try base64
|
|
77
|
+
data = begin
|
|
78
|
+
Base64.strict_decode64(data)
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
data
|
|
81
|
+
end
|
|
82
|
+
# here, data is the image blob
|
|
83
|
+
formatter.display_message(:data, Preview::Terminal.build(data, **formatter.image_options))
|
|
84
|
+
end
|
|
85
|
+
else
|
|
86
|
+
Aspera.error_unexpected_value(formatter.format_type){'format'}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Special result types - each has its own class
|
|
92
|
+
class Result
|
|
93
|
+
# Base class for special results
|
|
94
|
+
# The type is automatically derived from the class name
|
|
95
|
+
class Special < Result
|
|
96
|
+
def initialize
|
|
97
|
+
# Convert class name to symbol: Empty -> :empty, Nothing -> :nothing
|
|
98
|
+
type = self.class.name.split('::').last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
|
|
99
|
+
super(data: type)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def format(formatter)
|
|
103
|
+
case formatter.format_type
|
|
104
|
+
when :text, :nagios, :ruby, :json, :jsonpp, :yaml
|
|
105
|
+
formatter.display_message(:data, @data.to_s)
|
|
106
|
+
when :table, :csv
|
|
107
|
+
if @data.eql?(:nothing)
|
|
108
|
+
Log.log.debug('no result expected')
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
formatter.display_message(:info, formatter.special_format(@data.to_s))
|
|
112
|
+
else
|
|
113
|
+
super
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Empty list result
|
|
119
|
+
class Empty < Special
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Nothing expected result
|
|
123
|
+
class Nothing < Special
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Null result
|
|
127
|
+
class Null < Special
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Status result (success, complete, etc.)
|
|
131
|
+
class Status < Result
|
|
132
|
+
def initialize(data)
|
|
133
|
+
Aspera.assert_type(data, String){'status result data'}
|
|
134
|
+
super(data: data)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def format(formatter)
|
|
138
|
+
data = formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
|
|
139
|
+
case formatter.format_type
|
|
140
|
+
when :text, :nagios, :ruby, :json, :jsonpp, :yaml
|
|
141
|
+
formatter.display_message(:data, data)
|
|
142
|
+
when :table, :csv
|
|
143
|
+
formatter.display_message(:info, data)
|
|
144
|
+
else
|
|
145
|
+
super
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Success status result
|
|
151
|
+
class Success < Status
|
|
152
|
+
def initialize
|
|
153
|
+
super('complete')
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Text result
|
|
158
|
+
class Text < Result
|
|
159
|
+
def initialize(data)
|
|
160
|
+
Aspera.assert_type(data, String, Integer, Symbol, type: ArgumentError){'text result data'}
|
|
161
|
+
super(data: data)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def format(formatter)
|
|
165
|
+
data = @data.is_a?(String) && formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
|
|
166
|
+
case formatter.format_type
|
|
167
|
+
when :text, :nagios
|
|
168
|
+
formatter.display_message(:data, data.to_s)
|
|
169
|
+
when :ruby
|
|
170
|
+
formatter.display_message(:data, PP.pp(data, +''))
|
|
171
|
+
when :json
|
|
172
|
+
formatter.display_message(:data, JSON.generate(data))
|
|
173
|
+
when :jsonpp
|
|
174
|
+
formatter.display_message(:data, JSON.pretty_generate(data))
|
|
175
|
+
when :yaml
|
|
176
|
+
formatter.display_message(:data, YAML.dump(data))
|
|
177
|
+
when :table, :csv
|
|
178
|
+
formatter.display_message(:data, data)
|
|
179
|
+
else
|
|
180
|
+
super
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Image result (URL or blob)
|
|
186
|
+
class Image < Result
|
|
187
|
+
def initialize(data)
|
|
188
|
+
Aspera.assert_type(data, String, NilClass){'image result data'}
|
|
189
|
+
super(data: data)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def format(formatter)
|
|
193
|
+
# Force image format for Image results
|
|
194
|
+
formatter.format_type = :image
|
|
195
|
+
super
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Single object result (Hash)
|
|
200
|
+
class SingleObject < Result
|
|
201
|
+
def initialize(data, fields: nil)
|
|
202
|
+
Aspera.assert_type(data, Hash){'single object result data'}
|
|
203
|
+
super(data: data, fields: fields)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def format(formatter)
|
|
207
|
+
case formatter.format_type
|
|
208
|
+
when :image
|
|
209
|
+
# Extract single field for image display
|
|
210
|
+
data_array = [@data]
|
|
211
|
+
fields = formatter.compute_fields(data_array, @fields)
|
|
212
|
+
Aspera.assert(fields.length == 1, 'select a single field to display', type: Cli::BadArgument)
|
|
213
|
+
Aspera.assert(@data.key?(fields.first), 'no such field', type: Cli::BadArgument)
|
|
214
|
+
# Create an Image result and format it
|
|
215
|
+
Image.new(@data[fields.first]).format(formatter)
|
|
216
|
+
when :table, :csv
|
|
217
|
+
Aspera.assert_type(@data, Hash){'result'}
|
|
218
|
+
if @data.empty?
|
|
219
|
+
formatter.display_message(:data, formatter.special_format('empty dict'))
|
|
220
|
+
else
|
|
221
|
+
data = formatter.flat_hash? ? DotContainer.new(@data).to_dotted : @data
|
|
222
|
+
formatter.display_table([data], formatter.compute_fields([data], @fields), single: true)
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
super
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Object list result (Array of Hash)
|
|
231
|
+
# @note The +total+ parameter is used to display pagination information (e.g., "Items: 10/100")
|
|
232
|
+
class ObjectList < Result
|
|
233
|
+
attr_reader :total
|
|
234
|
+
|
|
235
|
+
# @param data [Array<Hash>] Array of hash objects to display
|
|
236
|
+
# @param fields [Array<String>, Proc, nil] Fields to display in table/csv format
|
|
237
|
+
# @param total [Integer, nil] Total number of items available (for pagination display)
|
|
238
|
+
def initialize(data, fields: nil, total: nil)
|
|
239
|
+
Aspera.assert_array_all(data, Hash, type: ArgumentError){'object list result data'}
|
|
240
|
+
Aspera.assert_type(total, Integer, NilClass){'total'}
|
|
241
|
+
super(data: data, fields: fields)
|
|
242
|
+
@total = total
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def format(formatter)
|
|
246
|
+
formatter.display_item_count(@data.length, @total)
|
|
247
|
+
case formatter.format_type
|
|
248
|
+
when :image
|
|
249
|
+
# Extract single field for image display
|
|
250
|
+
Aspera.assert(@data.length == 1, 'image display requires a single result', type: Cli::BadArgument)
|
|
251
|
+
SingleObject.new(@data.first).format(formatter)
|
|
252
|
+
when :table, :csv
|
|
253
|
+
Aspera.assert_array_all(@data, Hash){'result'}
|
|
254
|
+
data = formatter.flat_hash? ? @data.map{ |obj| DotContainer.new(obj).to_dotted} : @data
|
|
255
|
+
formatter.display_table(data, formatter.compute_fields(data, @fields), single: false)
|
|
256
|
+
else
|
|
257
|
+
super
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Value list result (Array of values with a name)
|
|
263
|
+
class ValueList < Result
|
|
264
|
+
attr_reader :name
|
|
265
|
+
|
|
266
|
+
def initialize(data, name: 'id')
|
|
267
|
+
Aspera.assert_type(data, Array){'value list result data'}
|
|
268
|
+
Aspera.assert_type(name, String){'value list name'}
|
|
269
|
+
super(data: data)
|
|
270
|
+
@name = name
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def format(formatter)
|
|
274
|
+
case formatter.format_type
|
|
275
|
+
when :table, :csv
|
|
276
|
+
formatter.display_table(@data.map{ |i| {@name => i}}, [@name])
|
|
277
|
+
when :text
|
|
278
|
+
formatter.display_message(:data, @data.join("\n"))
|
|
279
|
+
when :ruby
|
|
280
|
+
formatter.display_message(:data, PP.pp(@data, +''))
|
|
281
|
+
when :json
|
|
282
|
+
formatter.display_message(:data, JSON.generate(@data))
|
|
283
|
+
when :jsonpp
|
|
284
|
+
formatter.display_message(:data, JSON.pretty_generate(@data))
|
|
285
|
+
when :yaml
|
|
286
|
+
formatter.display_message(:data, YAML.dump(@data))
|
|
287
|
+
else
|
|
288
|
+
super
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
class << self
|
|
294
|
+
# Execute a bulk operation over a list of already-resolved items and return a CLI result.
|
|
295
|
+
# This is a pure helper: it reads nothing from the CLI.
|
|
296
|
+
# @param items [Array] List of items to process (one element when non-bulk)
|
|
297
|
+
# @param is_bulk [Boolean] Whether the caller is in bulk mode
|
|
298
|
+
# @param command [Symbol] Operation name (:create, :delete, …) used to build the past-tense status string
|
|
299
|
+
# @param id_result [String] Key in the result Hash used as item identifier
|
|
300
|
+
# @param fields [Object] Fields hint passed to the Result constructor (non-bulk only, when not :default)
|
|
301
|
+
# @param bfail [Boolean] When true, re-raise errors; when false, capture them as a status string
|
|
302
|
+
# @yieldparam item [Object] Each item in +items+
|
|
303
|
+
# @yieldreturn [Hash, Array, nil] REST response; a Hash replaces the default id-keyed result
|
|
304
|
+
# @return [Result::ObjectList, Result::SingleObject]
|
|
305
|
+
def bulk(items, is_bulk:, command:, id_result: 'id', fields: :default, bfail: true)
|
|
306
|
+
Aspera.assert(block_given?, 'missing block')
|
|
307
|
+
Log.log.warn('Empty list given for bulk operation') if items.empty?
|
|
308
|
+
Log.dump(:bulk_operation, items)
|
|
309
|
+
result_list = []
|
|
310
|
+
items.each do |item|
|
|
311
|
+
result = {id_result => item}
|
|
312
|
+
begin
|
|
313
|
+
res = yield(item)
|
|
314
|
+
result = res if res.is_a?(Hash)
|
|
315
|
+
# TODO: remove when faspio gw api fixes this
|
|
316
|
+
result = res.first if res.is_a?(Array) && res.first.is_a?(Hash)
|
|
317
|
+
result['status'] = "#{command}#{'e' unless command.to_s.end_with?('e')}d".gsub(/yed$/, 'ied')
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
raise e if bfail
|
|
320
|
+
result['status'] = e.to_s
|
|
321
|
+
end
|
|
322
|
+
result_list.push(result)
|
|
323
|
+
end
|
|
324
|
+
display_fields = [id_result, 'status']
|
|
325
|
+
if is_bulk
|
|
326
|
+
return ObjectList.new(result_list, fields: display_fields)
|
|
327
|
+
else
|
|
328
|
+
display_fields = fields unless fields.eql?(:default)
|
|
329
|
+
return SingleObject.new(result_list.first, fields: display_fields)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Class method to automatically determine result type from data
|
|
334
|
+
# @param data [Object] the data to analyze and format
|
|
335
|
+
# @return [Result]
|
|
336
|
+
def auto(data)
|
|
337
|
+
case data
|
|
338
|
+
when NilClass
|
|
339
|
+
Null.new
|
|
340
|
+
when Hash
|
|
341
|
+
SingleObject.new(data)
|
|
342
|
+
when Array
|
|
343
|
+
all_types = data.map(&:class).uniq
|
|
344
|
+
return ObjectList.new(data) if all_types.eql?([Hash])
|
|
345
|
+
|
|
346
|
+
scalar_types = [String, Integer, Symbol]
|
|
347
|
+
unsupported_types = all_types - scalar_types
|
|
348
|
+
return ValueList.new(data, name: 'list') if unsupported_types.empty?
|
|
349
|
+
|
|
350
|
+
Aspera.error_unexpected_value(unsupported_types){'list item types'}
|
|
351
|
+
when String, Integer, Symbol
|
|
352
|
+
Text.new(data)
|
|
353
|
+
else
|
|
354
|
+
Aspera.error_unexpected_value(data.class.name){'result type'}
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|