aspera-cli 4.26.2 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # cspell:ignore snid fnid bidi ssync asyncs rund asnodeadmin mkfile mklink asperabrowser asperabrowserurl watchfolders watchfolderd entsrv
4
+ require 'aspera/schema/registry'
4
5
  require 'aspera/cli/plugins/basic_auth'
5
6
  require 'aspera/cli/sync_actions'
6
7
  require 'aspera/cli/special_values'
@@ -22,39 +23,16 @@ module Aspera
22
23
  class Node < BasicAuth
23
24
  include SyncActions
24
25
 
25
- # Processing of paths in arguments and results
26
- # Used only by Faspex4 to browse packages
27
- class NodePathPrefix
28
- def initialize(path)
29
- @root = path
30
- end
31
-
32
- # get next path argument from command line, and add prefix
33
- def add_to_path(path_arg)
34
- File.join(@root, path_arg)
35
- end
36
-
37
- # get remaining path arguments from command line, and add prefix
38
- def add_to_paths!(path_args)
39
- path_args.map!{ |p| add_to_path(p)}
40
- end
26
+ application_name 'HSTS Node API'
41
27
 
42
- def remove_in_object_list!(obj_list)
43
- obj_list.each do |item|
44
- item['path'] = item['path'].delete_prefix(@root) if item['path'].start_with?(@root)
45
- end
46
- end
47
- end
28
+ SESSION_TIME_FIELDS = %i[start end].freeze
29
+ private_constant :SESSION_TIME_FIELDS
48
30
 
49
31
  class << self
50
32
  # directory: node, container: shares
51
33
  FOLDER_TYPES = %w[directory container].freeze
52
34
  private_constant :FOLDER_TYPES
53
35
 
54
- def application_name
55
- 'HSTS Node API'
56
- end
57
-
58
36
  # @return [Hash,NilClass]
59
37
  def detect(address_or_url)
60
38
  urls = if address_or_url.match?(%r{^[a-z]{1,6}://})
@@ -68,7 +46,7 @@ module Aspera
68
46
  end
69
47
  error = nil
70
48
  urls.each do |base_url|
71
- next unless base_url.match?('https?://')
49
+ next unless base_url.match?(%r{^https?://})
72
50
  api = Rest.new(base_url: base_url)
73
51
  test_endpoint = 'ping'
74
52
  http = api.read(test_endpoint, ret: :resp)
@@ -81,37 +59,29 @@ module Aspera
81
59
  }
82
60
  rescue StandardError => e
83
61
  error = e
84
- Log.log.debug{"detect error: #{e}"}
62
+ Log.log.debug { "detect error: #{e}" }
85
63
  end
86
64
  raise error if error
87
65
  return
88
66
  end
89
67
 
90
- def declare_options(options)
91
- return if @options_declared
92
- @options_declared = true
93
- @dynamic_key = nil
94
- options.declare(:validator, 'Identifier of validator (optional for central)')
95
- options.declare(:asperabrowserurl, 'URL for simple aspera web ui', default: 'https://asperabrowser.mybluemix.net')
96
- options.declare(
97
- :node_api, 'Gen4: standard_ports: Use standard FASP ports (true) or get from node API (false). cache: Set to false to force actual file system read',
98
- allowed: Hash,
99
- handler: {o: Api::Node, m: :api_options}
100
- )
101
- options.declare(:root_id, 'Gen4: File id of top folder when using access key (override AK root id)')
102
- options.declare(:dynamic_key, 'Private key PEM to use for dynamic key auth', handler: {o: Api::Node, m: :use_dynamic_key})
103
- SyncActions.declare_options(options)
104
- options.parse_options!
105
- end
106
-
107
68
  # Using /files/browse: is it a folder (node and shares)
108
69
  def gen3_entry_folder?(entry)
109
70
  FOLDER_TYPES.include?(entry['type'])
110
71
  end
111
72
  end
112
73
 
74
+ # DSL option declarations - at class level, picked up by Base#initialize via ancestor chain.
75
+ # Included by other plugins (Ats, Cos, Aoc) via `use_options Node`.
76
+ option :validator, description: 'Identifier of validator (optional for central)'
77
+ option :asperabrowserurl, description: 'URL for simple aspera web ui', default: 'https://asperabrowser.mybluemix.net'
78
+ option :node_api, description: 'Gen4: standard_ports: Use standard FASP ports (true) or get from node API (false). cache: Set to false to force actual file system read',
79
+ allowed: Hash, handler: {o: Api::Node, m: :api_options}
80
+ option :root_id, description: 'Gen4: File id of top folder when using access key (override AK root id)'
81
+ option :dynamic_key, description: 'Private key PEM to use for dynamic key auth', handler: {o: Api::Node, m: :use_dynamic_key}
82
+
113
83
  # @param wizard [Wizard] The wizard object
114
- # @param app_url [Wizard] The wizard object
84
+ # @param app_url [String] Tested URL
115
85
  # @return [Hash] :preset_value, :test_args
116
86
  def wizard(wizard, app_url)
117
87
  return {
@@ -137,40 +107,83 @@ module Aspera
137
107
  SEARCH_REMOVE_FIELDS = %w[basename permissions].freeze
138
108
 
139
109
  # Actions in execute_command_gen3
140
- COMMANDS_GEN3 = %i[search space mkdir mklink mkfile rename delete browse upload download cat sync transport spec]
110
+ COMMANDS_GEN3 = %i[search space mkdir mklink mkfile rename delete ls upload download cat sync transport spec]
141
111
 
142
- BASE_ACTIONS = %i[api_details].concat(COMMANDS_GEN3).freeze
112
+ # Shared DSL metadata for Gen3 commands (description:, arguments:, transfer_paths:, aliases:).
113
+ # Consumed by commands_under(:files) in shares.rb and commands_under %i[source node]) in faspex.rb.
114
+ # :sync is excluded (intermediate node with sub-commands, handled separately).
115
+ # action: entries are node-specific and intentionally omitted.
116
+ COMMANDS_GEN3_SPEC = {
117
+ search: {description: 'Search for files', arguments: [{name: :search_root, type: String}]},
118
+ space: {description: 'Show space information', arguments: [{name: :path_list, multiple: true}]},
119
+ mkdir: {description: 'Create a folder (Gen3)', arguments: [{name: :path_list, multiple: true}]},
120
+ mklink: {description: 'Create a symbolic link (Gen3)', arguments: [{name: :target, type: String}, {name: :link_path, type: String}]},
121
+ mkfile: {description: 'Create a file (Gen3)', arguments: [{name: :file_path, type: String}, {name: :contents, mandatory: false, default: nil}]},
122
+ rename: {description: 'Rename a file or folder (Gen3)', arguments: [{name: :path_base, type: String}, {name: :path_src, type: String}, {name: :path_dst, type: String}]},
123
+ delete: {description: 'Delete files or folders (Gen3)', arguments: [{name: :paths, multiple: true}]},
124
+ ls: {description: 'List files (Gen3)', arguments: [{name: :path, type: String}], aliases: [:browse]},
125
+ upload: {description: 'Upload files (Gen3)', transfer_paths: :send},
126
+ download: {description: 'Download files (Gen3)', transfer_paths: :receive},
127
+ cat: {description: 'Show file contents (Gen3)', arguments: [{name: :remote_path, type: String}]},
128
+ transport: {description: 'Show transport parameters'},
129
+ spec: {description: 'Show transfer spec base'},
130
+ api_details: {description: 'Show API details'},
131
+ health: {description: 'Check node health'},
132
+ events: {description: 'List events'},
133
+ info: {description: 'Show node info'},
134
+ slash: {description: 'Show root info'},
135
+ license: {description: 'Show license'},
136
+ access_keys: {description: 'Manage access keys'}
137
+ }.freeze
138
+
139
+ BASE_ACTIONS = (%i[api_details] + COMMANDS_GEN3).freeze
143
140
 
144
141
  SPECIAL_ACTIONS = %i[health events info slash license].freeze
145
142
 
146
143
  # commands for : `execute_simple_common`: actions used commonly when a node is involved
147
- COMMON_ACTIONS = %i[access_keys].concat(BASE_ACTIONS).concat(SPECIAL_ACTIONS).freeze
148
-
149
- # actions available in v3 in gen4
150
- V3_IN_V4_ACTIONS = %i[transfer].concat(COMMON_ACTIONS).freeze
144
+ COMMON_ACTIONS = (%i[access_keys] + BASE_ACTIONS + SPECIAL_ACTIONS).freeze
151
145
 
152
- private_constant :CENTRAL_SOAP_API_TEST, :SEARCH_REMOVE_FIELDS, :BASE_ACTIONS, :SPECIAL_ACTIONS, :V3_IN_V4_ACTIONS, :COMMON_ACTIONS
146
+ private_constant :CENTRAL_SOAP_API_TEST, :SEARCH_REMOVE_FIELDS, :BASE_ACTIONS, :SPECIAL_ACTIONS, :COMMON_ACTIONS
153
147
 
154
148
  # used in aoc
155
- NODE4_READ_ACTIONS = %i[bearer_token_node node_info browse find].freeze
149
+ NODE4_READ_ACTIONS = %i[bearer_token_node node_info ls find].freeze
156
150
 
157
151
  # commands for execute_command_gen4
158
- COMMANDS_GEN4 = %i[mkdir mklink mkfile rename delete upload download sync cat show modify permission thumbnail v3].concat(NODE4_READ_ACTIONS).freeze
152
+ COMMANDS_GEN4 = (%i[mkdir mklink mkfile rename delete upload download sync cat show modify permission thumbnail v3] + NODE4_READ_ACTIONS).freeze
153
+
154
+ # Shared DSL metadata for all Gen4 commands (description:, arguments:, transfer_paths:, aliases:).
155
+ # Consumed by commands_under %i[access_keys do]) in node.rb and by aoc.rb.
156
+ # :sync and :permission are excluded: they are intermediate nodes handled separately.
157
+ SINGLE_PATH_ARG = [{name: :path, type: String}].freeze
158
+ COMMANDS_GEN4_SPEC = {
159
+ mkdir: {description: 'Create folder', arguments: SINGLE_PATH_ARG},
160
+ mklink: {description: 'Create symbolic link', arguments: SINGLE_PATH_ARG},
161
+ mkfile: {description: 'Create file', arguments: [{name: :path, type: String}, {name: :contents, mandatory: false, default: nil}]},
162
+ rename: {description: 'Rename entry', arguments: [{name: :source_path, type: String}, {name: :new_name, type: String}]},
163
+ delete: {description: 'Delete entry', arguments: [{name: :paths, type: String, bulk: true}]},
164
+ upload: {description: 'Upload files', transfer_paths: :send},
165
+ download: {description: 'Download files', transfer_paths: :receive},
166
+ modify: {description: 'Modify file', arguments: [{name: :path, type: String}, {name: :update_value, type: Hash, schema: 'node:components.schemas.files-id-put-request'}]},
167
+ cat: {description: 'Show file contents', arguments: SINGLE_PATH_ARG},
168
+ show: {description: 'Show file info', arguments: SINGLE_PATH_ARG},
169
+ thumbnail: {description: 'Show file thumbnail', arguments: SINGLE_PATH_ARG},
170
+ bearer_token_node: {description: 'Show bearer token for file node', arguments: SINGLE_PATH_ARG},
171
+ node_info: {description: 'Show node info for file', arguments: SINGLE_PATH_ARG},
172
+ ls: {description: 'List files', arguments: SINGLE_PATH_ARG, aliases: [:browse]},
173
+ find: {description: 'Find files', arguments: SINGLE_PATH_ARG + FILTER_ARGS},
174
+ v3: {description: 'Legacy v3 commands on files'}
175
+ }.freeze
176
+ private_constant :SINGLE_PATH_ARG
159
177
 
160
178
  # commands supported in ATS for COS
161
179
  COMMANDS_COS = %i[upload download info access_keys api_details transfer].freeze
162
180
  COMMANDS_SHARES = (BASE_ACTIONS - %i[search]).freeze
163
- COMMANDS_FASPEX = COMMON_ACTIONS
164
-
165
181
  # `browse` display fields for gen4
166
182
  GEN4_LS_FIELDS = %w[name type recursive_size size modified_time access_level].freeze
167
183
 
168
184
  # @param api [Rest] an existing API object for the Node API
169
- # @param prefix_path [String,nil] for Faspex 4, allows browsing a package without full path in node (removes storage prefix)
170
- def initialize(context:, api: nil, prefix_path: nil)
171
- @node_path_prefix = prefix_path ? NodePathPrefix.new(prefix_path) : nil
185
+ def initialize(context:, api: nil)
172
186
  super(context: context, basic_options: api.nil?)
173
- Node.declare_options(options)
174
187
  return if context.only_manual?
175
188
  @api_node =
176
189
  if !api.nil?
@@ -196,9 +209,9 @@ module Aspera
196
209
  end
197
210
 
198
211
  # Gen3 API
199
- def browse_gen3
200
- folders_to_process = options.get_next_argument('path', validation: String)
201
- folders_to_process = @node_path_prefix.add_to_path(folders_to_process) unless @node_path_prefix.nil?
212
+ # @param path [String] starting path
213
+ def browse_gen3(path)
214
+ folders_to_process = path
202
215
  folders_to_process = [folders_to_process]
203
216
  query = options.get_option(:query) || {}
204
217
  # special parameter: max number of entries in result
@@ -211,7 +224,7 @@ module Aspera
211
224
  single_call = query.key?('skip')
212
225
  # API default is 100, so use 1000 for default
213
226
  query['count'] ||= 1000
214
- raise Cli::BadArgument, 'options `recursive` and `skip` cannot be used together' if recursive && single_call
227
+ Aspera.assert(!(recursive && single_call), type: Cli::BadArgument) { 'options `recursive` and `skip` cannot be used together' }
215
228
  all_items = []
216
229
  until folders_to_process.empty?
217
230
  path = folders_to_process.shift
@@ -222,10 +235,7 @@ module Aspera
222
235
  # example: send_result={'items'=>[{'file'=>"filename1","permissions"=>[{'name'=>'read'},{'name'=>'write'}]}]}
223
236
  response = @api_node.create('files/browse', query)
224
237
  # 'file','symbolic_link'
225
- if !Node.gen3_entry_folder?(response['self']) || only_path
226
- @node_path_prefix&.remove_in_object_list!([response['self']])
227
- return Result::SingleObject.new(response['self'])
228
- end
238
+ return Result::SingleObject.new(response['self']) if !Node.gen3_entry_folder?(response['self']) || only_path
229
239
  items = response['items']
230
240
  total_count ||= response['total_count']
231
241
  all_items.concat(items)
@@ -233,7 +243,7 @@ module Aspera
233
243
  formatter.display_item_count(response['item_count'], total_count)
234
244
  break
235
245
  end
236
- folders_to_process.concat(items.select{ |i| Node.gen3_entry_folder?(i)}.map{ |i| i['path']}) if recursive
246
+ folders_to_process.concat(items.select { |i| Node.gen3_entry_folder?(i) }.map { |i| i['path'] }) if recursive
237
247
  if !max_items.nil? && (all_items.count >= max_items)
238
248
  all_items = all_items.slice(0, max_items) if all_items.count > max_items
239
249
  break
@@ -245,16 +255,15 @@ module Aspera
245
255
  end
246
256
  query.delete('skip')
247
257
  end
248
- @node_path_prefix&.remove_in_object_list!(all_items)
249
258
  return Result::ObjectList.new(all_items)
250
259
  ensure
251
260
  RestParameters.instance.spinner_cb.call(action: :success)
252
261
  end
253
262
 
254
263
  # Create async transfer spec request from direction and folders
255
- # @param sync_direction one of push pull bidi
256
- # @param local_path local folder to sync
257
- # @param remote_path remote folder to sync
264
+ # @param sync_direction [Symbol] one of push pull bidi
265
+ # @param local_path [String] local folder to sync
266
+ # @param remote_path [String] remote folder to sync
258
267
  def sync_spec_request(sync_direction, local_path, remote_path)
259
268
  case sync_direction
260
269
  when :push then {
@@ -282,382 +291,28 @@ module Aspera
282
291
  end
283
292
  end
284
293
 
285
- # Commands based on Gen3 API for file and folder
286
- def execute_command_gen3(command)
287
- case command
288
- when :delete
289
- # TODO: add query for recursive
290
- paths_to_delete = options.get_next_argument('file list', multiple: true)
291
- @node_path_prefix&.add_to_paths!(paths_to_delete)
292
- resp = @api_node.create('files/delete', {paths: paths_to_delete.map{ |i| {'path' => i.start_with?('/') ? i : "/#{i}"}}})
293
- return cli_result_from_paths_response(resp, 'file deleted')
294
- when :search
295
- search_root = options.get_next_argument('search root', validation: String)
296
- search_root = @node_path_prefix.add_to_path(search_root) unless @node_path_prefix.nil?
297
- parameters = {'path' => search_root}
298
- other_options = options.get_option(:query)
299
- parameters.merge!(other_options) unless other_options.nil?
300
- resp = @api_node.create('files/search', parameters)
301
- return Result::Empty.new if resp['items'].empty?
302
- fields = resp['items'].first.keys.reject{ |i| SEARCH_REMOVE_FIELDS.include?(i)}
303
- formatter.display_item_count(resp['item_count'], resp['total_count'])
304
- formatter.display_status("params: #{resp['parameters'].keys.map{ |k| "#{k}:#{resp['parameters'][k]}"}.join(',')}")
305
- @node_path_prefix&.remove_in_object_list!(resp['items'])
306
- return Result::ObjectList.new(resp['items'], fields: fields)
307
- when :space
308
- path_list = options.get_next_argument('folder path or ext.val. list', multiple: true)
309
- @node_path_prefix&.add_to_paths!(path_list)
310
- resp = @api_node.create('space', {'paths' => path_list.map{ |i| {path: i}}})
311
- @node_path_prefix&.remove_in_object_list!(resp['paths'])
312
- return Result::ObjectList.new(resp['paths'])
313
- when :mkdir
314
- path_list = options.get_next_argument('folder path or ext.val. list', multiple: true)
315
- @node_path_prefix&.add_to_paths!(path_list)
316
- resp = @api_node.create('files/create', {'paths' => path_list.map{ |i| {type: :directory, path: i}}})
317
- return cli_result_from_paths_response(resp, 'folder created')
318
- when :mklink
319
- target = options.get_next_argument('target', validation: String)
320
- target = @node_path_prefix.add_to_path(target) unless @node_path_prefix.nil?
321
- one_path = options.get_next_argument('link path', validation: String)
322
- one_path = @node_path_prefix.add_to_path(one_path) unless @node_path_prefix.nil?
323
- resp = @api_node.create('files/create', {'paths' => [{type: :symbolic_link, path: one_path, target: {path: target}}]})
324
- return cli_result_from_paths_response(resp, 'link created')
325
- when :mkfile
326
- one_path = options.get_next_argument('file path', validation: String)
327
- one_path = @node_path_prefix.add_to_path(one_path) unless @node_path_prefix.nil?
328
- contents64 = Base64.strict_encode64(options.get_next_argument('contents'))
329
- resp = @api_node.create('files/create', {'paths' => [{type: :file, path: one_path, contents: contents64}]})
330
- return cli_result_from_paths_response(resp, 'file created')
331
- when :rename
332
- # TODO: multiple ?
333
- path_base = options.get_next_argument('path_base', validation: String)
334
- path_base = @node_path_prefix.add_to_path(path_base) unless @node_path_prefix.nil?
335
- path_src = options.get_next_argument('path_src', validation: String)
336
- path_src = @node_path_prefix.add_to_path(path_src) unless @node_path_prefix.nil?
337
- path_dst = options.get_next_argument('path_dst', validation: String)
338
- path_dst = @node_path_prefix.add_to_path(path_dst) unless @node_path_prefix.nil?
339
- resp = @api_node.create('files/rename', {'paths' => [{'path' => path_base, 'source' => path_src, 'destination' => path_dst}]})
340
- return cli_result_from_paths_response(resp, 'entry moved')
341
- when :browse
342
- return browse_gen3
343
- when :sync
344
- return execute_sync_action do |sync_direction, local_path, remote_path|
345
- # Gen3 API
346
- # empty transfer spec for authorization request
347
- request_transfer_spec = sync_spec_request(sync_direction, local_path, remote_path)
348
- # add fixed parameters if any (for COS)
349
- @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
350
- # prepare payload for single request
351
- setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
352
- # only one request, so only one answer
353
- transfer_spec = @api_node.create('files/sync_setup', setup_payload)['transfer_specs'].first['transfer_spec']
354
- # API returns null tag... but async does not like it
355
- transfer_spec.delete_if{ |_k, v| v.nil?}
356
- # delete this part, as the returned value contains only destination, and not sources
357
- # transfer_spec.delete('paths') if command.eql?(:upload)
358
- Log.dump(:ts, transfer_spec)
359
- transfer_spec
360
- end
361
- when :upload, :download
362
- # empty transfer spec for authorization request
363
- request_transfer_spec = {}
364
- # set requested paths depending on direction
365
- request_transfer_spec[:paths] = if command.eql?(:download)
366
- transfer.ts_source_paths
367
- else
368
- [{destination: transfer.destination_folder(Transfer::Spec::DIRECTION_SEND)}]
369
- end
370
- # add fixed parameters if any (for COS)
371
- @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
372
- Api::Node.add_public_key(request_transfer_spec)
373
- # prepare payload for single request
374
- setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
375
- # only one request, so only one answer
376
- transfer_spec = @api_node.create("files/#{command}_setup", setup_payload)['transfer_specs'].first['transfer_spec']
377
- Api::Node.add_private_key(transfer_spec)
378
- # delete this part, as the returned value contains only destination, and not sources
379
- transfer_spec.delete('paths') if command.eql?(:upload)
380
- return Runner.result_transfer(transfer.start(transfer_spec))
381
- when :cat
382
- remote_path = options.get_next_argument('remote path', validation: String)
383
- remote_path = @node_path_prefix.add_to_path(remote_path) unless @node_path_prefix.nil?
384
- File.basename(remote_path)
385
- http = @api_node.read("files/#{URI.encode_www_form_component(remote_path)}/contents", ret: :resp)
386
- return Result::Text.new(http.body)
387
- when :transport
388
- return Result::SingleObject.new(@api_node.transport_params)
389
- when :spec
390
- return Result::SingleObject.new(@api_node.base_spec, fields: Formatter.all_but(Transfer::Spec::SPECIFIC))
391
- end
392
- Aspera.error_unreachable_line
393
- end
394
-
395
- # common API to node and Shares
396
- def execute_simple_common(command)
397
- case command
398
- when *COMMANDS_GEN3
399
- execute_command_gen3(command)
400
- when :access_keys
401
- ak_command = options.get_next_command(%i[do set_bearer_key].concat(Operations::ALL))
402
- case ak_command
403
- when *Operations::ALL
404
- return entity_execute(
405
- api: @api_node,
406
- entity: 'access_keys',
407
- command: ak_command
408
- ) do |field, value|
409
- raise BadArgument, 'only selector: %id:self' unless field.eql?('id') && value.eql?('self')
410
- @api_node.read('access_keys/self')['id']
411
- end
412
- when :do
413
- access_key_id = options.get_next_argument('access key id')
414
- root_file_id = options.get_option(:root_id)
415
- if root_file_id.nil?
416
- ak_info = @api_node.read("access_keys/#{access_key_id}")
417
- ak_secret = config.lookup_secret(url: @api_node.base_url, username: ak_info['id'])
418
- # change API credentials if different access key
419
- if !access_key_id.eql?('self')
420
- Aspera.assert(ak_secret, type: Cli::MissingArgument){"Please provide secret for #{ak_info['id']} using option: secret or by setting a preset for #{ak_info['id']}@#{@api_node.base_url}."}
421
- @api_node.auth_params[:username] = ak_info['id']
422
- @api_node.auth_params[:password] = ak_secret
423
- end
424
- root_file_id = ak_info['root_file_id']
425
- end
426
- command_repo = options.get_next_command(COMMANDS_GEN4)
427
- return execute_command_gen4(command_repo, root_file_id)
428
- when :set_bearer_key
429
- access_key_id = options.get_next_argument('access key id')
430
- access_key_id = @api_node.read('access_keys/self')['id'] if access_key_id.eql?('self')
431
- bearer_key_pem = options.get_next_argument('public or private RSA key PEM value', validation: String)
432
- key = OpenSSL::PKey.read(bearer_key_pem)
433
- key = key.public_key if key.private?
434
- bearer_key_pem = key.to_pem
435
- @api_node.update("access_keys/#{access_key_id}", {token_verification_key: bearer_key_pem})
436
- return Result::Status.new('public key updated')
437
- end
438
- when :health
439
- nagios = Nagios.new
440
- begin
441
- info = @api_node.read('info')
442
- nagios.add_ok('node api', 'accessible')
443
- nagios.check_time_offset(info['current_time'], 'node api')
444
- nagios.check_product_version('node api', 'entsrv', info['version'])
445
- rescue StandardError => e
446
- nagios.add_critical('node api', e.to_s)
447
- end
448
- begin
449
- @api_node.call(
450
- operation: 'POST',
451
- subpath: 'services/soap/Transfer-201210',
452
- content_type: Mime::TEXT,
453
- body: CENTRAL_SOAP_API_TEST,
454
- headers: {'Content-Type' => 'text/xml;charset=UTF-8', 'SOAPAction' => 'FASPSessionNET-200911#GetSessionInfo'},
455
- ret: :resp
456
- ).body
457
- nagios.add_ok('central', 'accessible by node')
458
- rescue StandardError => e
459
- nagios.add_critical('central', e.to_s)
460
- end
461
- Result::ObjectList.new(nagios.status_list)
462
- when :events
463
- events = @api_node.read('events', query_read_delete)
464
- return Result::ObjectList.new(events, fields: ->(f){!f.start_with?('data')})
465
- when :info
466
- nd_info = @api_node.read('info')
467
- return Result::SingleObject.new(nd_info)
468
- when :slash
469
- nd_info = @api_node.read('')
470
- return Result::SingleObject.new(nd_info)
471
- when :license
472
- # requires: asnodeadmin -mu <node user> --acl-add=internal --internal
473
- return Result::SingleObject.new(@api_node.read('license'))
474
- when :api_details
475
- return Result::SingleObject.new({base_url: @api_node.base_url}.merge(@api_node.params))
294
+ # Resolve a NodeFileId from a path argument.
295
+ # Supports %id:<file_id> syntax (returns NodeFileId for that id) or a plain path.
296
+ # @param top_file_id [String] root file id for path resolution
297
+ # @param path [String] plain path or %id:<file_id> selector
298
+ def apifid_from_path(top_file_id, path)
299
+ if (m = Parser.percent_selector(path))
300
+ Aspera.assert_values(m[:field], ['id'], type: BadArgument) { 'file id' }
301
+ val = m[:value]
302
+ return Api::NodeFileId.new(@api_node, val.nil? || val.empty? ? top_file_id : val)
476
303
  end
304
+ @api_node.resolve_api_fid(top_file_id, path)
477
305
  end
478
306
 
479
- # Allows to specify a file by its path or by its id on the node in command line
480
- # @return [NodeFileId] api and main file id for given path or id in next argument
307
+ # Legacy helper: resolve a NodeFileId by reading path from the CLI argument stream.
308
+ # Used only by setup_access_key_do_permission and action_access_keys_do_permission
309
+ # (intermediate nodes whose path cannot yet be injected via execute_leaf).
481
310
  def apifid_from_next_arg(top_file_id)
482
311
  file_path = options.instance_identifier(description: 'path or %id:<id> or %id:') do |attribute, value|
483
- raise BadArgument, 'Only selection "id" is supported (file id)' unless attribute.eql?('id')
484
- # Directly return result for method
485
- return Api::NodeFileId.new(@api_node, value)
486
- end
487
- # There was no selector, so it is a path
488
- return @api_node.resolve_api_fid(top_file_id, file_path)
489
- end
490
-
491
- def execute_command_gen4(command_repo, top_file_id)
492
- override_file_id = options.get_option(:root_id)
493
- top_file_id = override_file_id unless override_file_id.nil?
494
- raise Cli::Error, 'Specify root file id with option root_id' if top_file_id.nil?
495
- case command_repo
496
- when :v3
497
- # NOTE: other common actions are unauthorized with user scope
498
- command_legacy = options.get_next_command(V3_IN_V4_ACTIONS)
499
- # TODO: shall we support all methods here ? what if there is a link ?
500
- apifid = @api_node.resolve_api_fid(top_file_id, '')
501
- return Node.new(context: context, api: apifid.node_api).execute_action(command_legacy)
502
- when :node_info, :bearer_token_node
503
- apifid = apifid_from_next_arg(top_file_id)
504
- result = {
505
- url: apifid.node_api.base_url,
506
- root_id: apifid.file_id
507
- }
508
- case apifid.node_api.auth_params[:type]
509
- when :basic
510
- result[:username] = apifid.node_api.auth_params[:username]
511
- result[:password] = apifid.node_api.auth_params[:password]
512
- when :oauth2
513
- result[:username] = apifid.node_api.params[:headers][Api::Node::HEADER_X_ASPERA_ACCESS_KEY]
514
- result[:password] = apifid.node_api.oauth.authorization
515
- else Aspera.error_unexpected_value(apifid.node_api.auth_params[:type]){'Node API Auth type'}
516
- end
517
- return Result::SingleObject.new(result) if command_repo.eql?(:node_info)
518
- Log.dump(:result, result)
519
- raise BadArgument, "Cannot get bearer token if authenticating with secret (#{apifid.node_api.auth_params[:type]})" unless apifid.node_api.auth_params[:type].eql?(:oauth2)
520
- Aspera.assert(OAuth::Factory.bearer_auth?(result[:password]), 'Not using bearer token auth')
521
- return Result::Text.new(result[:password])
522
- when :browse
523
- apifid = apifid_from_next_arg(top_file_id)
524
- file_info = apifid.node_api.read("files/#{apifid.file_id}", headers: Api::Node.add_cache_control)
525
- # a single file
526
- return Result::ObjectList.new([file_info], fields: GEN4_LS_FIELDS) unless file_info['type'].eql?('folder')
527
- return Result::ObjectList.new(apifid.node_api.list_files(apifid.file_id, query: query_read_delete), fields: GEN4_LS_FIELDS)
528
- when :find
529
- apifid = apifid_from_next_arg(top_file_id)
530
- find_lambda = Api::Node.file_matcher_from_argument(options)
531
- return Result::ObjectList.new(@api_node.find_files(apifid.file_id, find_lambda), fields: ['path'])
532
- when :mkdir, :mklink, :mkfile
533
- containing_folder_path, new_item = Api::Node.split_folder(options.get_next_argument('path'))
534
- apifid = @api_node.resolve_api_fid(top_file_id, containing_folder_path, true)
535
- query = options.get_option(:query)
536
- check_exists = true
537
- payload = {name: new_item}
538
- if query
539
- check_exists = !query.delete('check').eql?(false)
540
- target = query.delete('target')
541
- if target
542
- target_apifid = @api_node.resolve_api_fid(top_file_id, target, true)
543
- payload[:target_id] = target_apifid.file_id
544
- end
545
- payload.merge!(query.symbolize_keys)
546
- end
547
- if check_exists
548
- folder_content = apifid.node_api.read("files/#{apifid.file_id}/files")
549
- link_name = ".#{new_item}.asp-lnk"
550
- found = folder_content.find{ |i| i['name'].eql?(new_item) || i['name'].eql?(link_name)}
551
- raise "A #{found['type']} already exists with name #{new_item}" if found
552
- end
553
- case command_repo
554
- when :mkdir
555
- payload[:type] = :folder
556
- when :mklink
557
- payload[:type] = :link
558
- Aspera.assert(payload[:target_id], 'Missing target_id')
559
- Aspera.assert(payload[:target_node_id], 'Missing target_node_id')
560
- when :mkfile
561
- payload[:type] = :file
562
- payload[:contents] = Base64.strict_encode64(options.get_next_argument('contents'))
563
- end
564
- result = apifid.node_api.create("files/#{apifid.file_id}/files", payload)
565
- return Result::SingleObject.new(result)
566
- when :rename
567
- file_path = options.get_next_argument('source path')
568
- apifid = @api_node.resolve_api_fid(top_file_id, file_path)
569
- newname = options.get_next_argument('new name')
570
- result = apifid.node_api.update("files/#{apifid.file_id}", {name: newname})
571
- return Result::Status.new("renamed to #{newname}")
572
- when :delete
573
- return do_bulk_operation(command: command_repo, descr: 'path', values: String, id_result: 'path') do |l_path|
574
- apifid = if (m = Manager.percent_selector(l_path))
575
- Aspera.assert_values(m[:field], ['id'], type: BadIdentifier)
576
- Api::NodeFileId.new(@api_node, m[:value])
577
- else
578
- @api_node.resolve_api_fid(top_file_id, l_path)
579
- end
580
- result = apifid.node_api.delete("files/#{apifid.file_id}")
581
- {'path' => l_path}
582
- end
583
- when :sync
584
- return execute_sync_action do |sync_direction, _local_path, remote_path|
585
- # Gen4 API
586
- Aspera.assert_values(sync_direction, %i[push pull bidi])
587
- ts_direction = case sync_direction
588
- when :push, :bidi then Transfer::Spec::DIRECTION_SEND
589
- when :pull then Transfer::Spec::DIRECTION_RECEIVE
590
- else Aspera.error_unreachable_line
591
- end
592
- # remote is specified by option: `to_folder`
593
- apifid = @api_node.resolve_api_fid(top_file_id, remote_path)
594
- apifid.node_api.transfer_spec_gen4(apifid.file_id, ts_direction)
595
- end
596
- when :upload
597
- apifid = @api_node.resolve_api_fid(top_file_id, transfer.destination_folder(Transfer::Spec::DIRECTION_SEND), true)
598
- return Runner.result_transfer(transfer.start(apifid.node_api.transfer_spec_gen4(apifid.file_id, Transfer::Spec::DIRECTION_SEND)))
599
- when :download
600
- apifid, source_paths = @api_node.resolve_api_fid_paths(top_file_id, transfer.ts_source_paths)
601
- return Runner.result_transfer(transfer.start(apifid.node_api.transfer_spec_gen4(apifid.file_id, Transfer::Spec::DIRECTION_RECEIVE, {'paths'=>source_paths})))
602
- when :cat
603
- apifid = apifid_from_next_arg(top_file_id)
604
- http = apifid.node_api.read("files/#{apifid.file_id}/content", ret: :resp)
605
- return Result::Text.new(http.body)
606
- when :show
607
- apifid = apifid_from_next_arg(top_file_id)
608
- items = apifid.node_api.read("files/#{apifid.file_id}")
609
- return Result::SingleObject.new(items)
610
- when :modify
611
- apifid = apifid_from_next_arg(top_file_id)
612
- update_param = options.get_next_argument('update data', validation: Hash)
613
- apifid.node_api.update("files/#{apifid.file_id}", update_param)
614
- return Result::Status.new('Done')
615
- when :thumbnail
616
- apifid = apifid_from_next_arg(top_file_id)
617
- http = apifid.node_api.read("files/#{apifid.file_id}/preview", headers: {'Accept' => 'image/png'}, ret: :resp)
618
- return Result::Image.new(http.body)
619
- when :permission
620
- apifid = apifid_from_next_arg(top_file_id)
621
- command_perm = options.get_next_command(%i[list show create delete modify])
622
- case command_perm
623
- when :modify
624
- apifid.node_api.update("permissions/#{options.instance_identifier}", value_create_modify(command: 'permission modify'))
625
- return Result::Status.new('Updated')
626
- when :list
627
- list_query = query_read_delete(default: Rest.php_style({'include' => %w[access_level permission_count]}))
628
- # Specify file to get permissions for unless not specified (then, get all permissions)
629
- list_query['file_id'] = apifid.file_id unless apifid.file_id.to_s.empty?
630
- list_query['inherited'] = false if list_query.key?('file_id') && !list_query.key?('inherited')
631
- items = apifid.node_api.read_with_pages('permissions', list_query)
632
- return Result::ObjectList.new(items)
633
- when :show
634
- return Result::SingleObject.new(apifid.node_api.read("permissions/#{options.instance_identifier}"))
635
- when :delete
636
- return do_bulk_operation(command: command_perm, values: :identifier) do |one_id|
637
- apifid.node_api.delete("permissions/#{one_id}")
638
- # notify application of deletion
639
- the_app = apifid.node_api.app_info
640
- the_app&.api&.permissions_send_event(event_data: {}, app_info: the_app, types: ['permission.deleted'])
641
- {'id' => one_id}
642
- end
643
- when :create
644
- create_param = options.get_next_argument('creation data', validation: Hash)
645
- raise Cli::BadArgument, 'no file_id' if create_param.key?('file_id')
646
- create_param['file_id'] = apifid.file_id
647
- create_param['access_levels'] = Api::Node::ACCESS_LEVELS unless create_param.key?('access_levels')
648
- # add application specific tags (AoC)
649
- the_app = apifid.node_api.app_info
650
- the_app&.api&.permissions_set_create_params(perm_data: create_param, app_info: the_app)
651
- # create permission
652
- created_data = apifid.node_api.create('permissions', create_param)
653
- # notify application of creation
654
- the_app&.api&.permissions_send_event(event_data: created_data, app_info: the_app)
655
- return Result::SingleObject.new(created_data)
656
- else Aspera.error_unreachable_line
657
- end
658
- else Aspera.error_unreachable_line
312
+ Aspera.assert_values(attribute, ['id'], type: BadArgument) { 'file id' }
313
+ return Api::NodeFileId.new(@api_node, value.nil? || value.empty? ? top_file_id : value)
659
314
  end
660
- Aspera.error_unreachable_line
315
+ @api_node.resolve_api_fid(top_file_id, file_path)
661
316
  end
662
317
 
663
318
  # Search /async by name
@@ -666,81 +321,21 @@ module Aspera
666
321
  # @return [Integer] id of the sync
667
322
  # @raise [Cli::BadArgument] if no such sync, or not by name
668
323
  def async_lookup(field, value)
669
- raise Cli::BadArgument, "Only search by name is supported (#{field})" unless field.eql?('name')
324
+ Aspera.assert_values(field, ['name'], type: Cli::BadArgument) { 'search field' }
670
325
  async_ids = @api_node.read('async/list')['sync_ids']
671
326
  summaries = @api_node.create('async/summary', {'syncs' => async_ids})['sync_summaries']
672
- selected = summaries.find{ |s| s['name'].eql?(value)}
327
+ selected = summaries.find { |s| s['name'].eql?(value) }
673
328
  raise Cli::BadIdentifier.new('sync', value, field: field) if selected.nil?
674
329
  return selected['snid']
675
330
  end
676
331
 
677
- # Node API: /async (stats only)
678
- def execute_async
679
- command = options.get_next_command(%i[list delete files show counters bandwidth])
680
- unless command.eql?(:list)
681
- async_id = options.instance_identifier{ |field, value| async_lookup(field, value)}
682
- if async_id.eql?(SpecialValues::ALL)
683
- raise Cli::BadArgument, 'ALL only for show and delete' unless %i[show delete].include?(command)
684
- async_ids = @api_node.read('async/list')['sync_ids']
685
- else
686
- Integer(async_id) # must be integer
687
- async_ids = [async_id]
688
- end
689
- post_data = {'syncs' => async_ids}
690
- end
691
- case command
692
- when :list
693
- resp = @api_node.read('async/list')['sync_ids']
694
- return Result::ValueList.new(resp)
695
- when :show
696
- resp = @api_node.create('async/summary', post_data)['sync_summaries']
697
- return Result::Empty.new if resp.empty?
698
- return Result::ObjectList.new(resp, fields: %w[snid name local_dir remote_dir]) if async_id.eql?(SpecialValues::ALL)
699
- return Result::SingleObject.new(resp.first)
700
- when :delete
701
- resp = @api_node.create('async/delete', post_data)
702
- return Result::SingleObject.new(resp)
703
- when :bandwidth
704
- post_data['seconds'] = 100 # TODO: as parameter with --query
705
- resp = @api_node.create('async/bandwidth', post_data)
706
- data = resp['bandwidth_data']
707
- return Result::Empty.new if data.empty?
708
- data = data.first[async_id]['data']
709
- return Result::ObjectList.new(data)
710
- when :files
711
- # count int
712
- # filename str
713
- # skip int
714
- # status int
715
- filter = options.get_option(:query)
716
- post_data.merge!(filter) unless filter.nil?
717
- resp = @api_node.create('async/files', post_data)
718
- data = resp['sync_files']
719
- data = data.first[async_id] unless data.empty?
720
- iteration_data = []
721
- skip_ids_persistency = nil
722
- if options.get_option(:once_only, mandatory: true)
723
- skip_ids_persistency = PersistencyActionOnce.new(
724
- manager: persistency,
725
- data: iteration_data,
726
- id: IdGenerator.from_list(
727
- 'sync_files',
728
- options.get_option(:url, mandatory: true),
729
- options.get_option(:username, mandatory: true),
730
- async_id
731
- )
732
- )
733
- data.select!{ |l| l['fnid'].to_i > iteration_data.first} unless iteration_data.first.nil?
734
- iteration_data[0] = data.last['fnid'].to_i unless data.empty?
735
- end
736
- return Result::Empty.new if data.empty?
737
- skip_ids_persistency&.save
738
- return Result::ObjectList.new(data)
739
- when :counters
740
- resp = @api_node.create('async/counters', post_data)['sync_counters'].first[async_id].last
741
- return Result::Empty.new if resp.nil?
742
- return Result::SingleObject.new(resp)
743
- end
332
+ # Lookup for access_keys: only supports %id:self selector.
333
+ # @param field [String] must be 'id'
334
+ # @param value [String] must be 'self'
335
+ # @return [String] the resolved access key id
336
+ def lookup_access_key_self_id(field, value, **)
337
+ Aspera.assert(field.eql?('id') && value.eql?('self'), type: BadArgument) { 'only selector: %id:self' }
338
+ @api_node.read('access_keys/self')['id']
744
339
  end
745
340
 
746
341
  # Search /asyncs by name
@@ -749,7 +344,7 @@ module Aspera
749
344
  # @return [Integer] id of the sync
750
345
  # @raise [Cli::BadArgument] if no such sync, or not by name
751
346
  def ssync_lookup(field, value)
752
- raise Cli::BadArgument, "Only search by name is supported (#{field})" unless field.eql?('name')
347
+ Aspera.assert_values(field, ['name'], type: Cli::BadArgument) { 'search field' }
753
348
  @api_node.read('asyncs')['ids'].each do |id|
754
349
  sync_info = @api_node.read("asyncs/#{id}")['configuration']
755
350
  # name is unique, so we can return
@@ -758,376 +353,929 @@ module Aspera
758
353
  raise Cli::BadIdentifier.new('ssync', value, field: field)
759
354
  end
760
355
 
761
- WATCH_FOLDER_MUL = %i[create list].freeze
762
- WATCH_FOLDER_SING = %i[show modify delete state].freeze
763
- private_constant :WATCH_FOLDER_MUL, :WATCH_FOLDER_SING
356
+ # --- DSL command declarations ---
357
+
358
+ # Gen3 leaf commands — metadata from COMMANDS_GEN3_SPEC; action: added where node-specific.
359
+ # :sync is declared separately (intermediate node with sub-commands).
360
+ GEN3_NODE_ACTIONS = {
361
+ ls: ->(path:, **) { browse_gen3(path) },
362
+ transport: ->(**) { Result::SingleObject.new(@api_node.transport_params) },
363
+ spec: ->(**) { Result::SingleObject.new(@api_node.base_spec, fields: Formatter.all_but(Transfer::Spec::SPECIFIC)) },
364
+ api_details: ->(**) { Result::SingleObject.new({base_url: @api_node.base_url}.merge(@api_node.params)) },
365
+ events: ->(**) { Result::ObjectList.new(@api_node.read('events', query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::NODE, 'events'))), fields: ->(f) { !f.start_with?('data') }) },
366
+ info: ->(**) { Result::SingleObject.new(@api_node.read('info')) },
367
+ slash: ->(**) { Result::SingleObject.new(@api_node.read('')) },
368
+ license: ->(**) { Result::SingleObject.new(@api_node.read('license')) }
369
+ }.freeze
370
+ private_constant :GEN3_NODE_ACTIONS
371
+ COMMANDS_GEN3_SPEC.each do |cmd, spec|
372
+ next if cmd.eql?(:sync) # intermediate node with sub-commands
373
+ next if cmd.eql?(:access_keys) # intermediate node declared separately below
374
+ action = GEN3_NODE_ACTIONS[cmd]
375
+ command cmd, **spec, **(action ? {action: action} : {})
376
+ end
377
+ command :sync, description: 'Synchronize folders (Gen3)'
378
+ commands_under :sync do
379
+ Sync::Operations::DIRECTIONS.each do |dir|
380
+ command dir, description: "#{dir.capitalize}-sync (Gen3)", transfer_paths: :send, arguments: SyncActions::PATH_AND_INFO_ARGS
381
+ end
382
+ command :admin, description: 'Manage sync database (admin operations)'
383
+ SyncActions.register_sync_admin_commands(self, :admin)
384
+ end
385
+ # access_keys sub-tree
386
+ command :access_keys, description: 'Manage access keys'
387
+ commands_under :access_keys do
388
+ command :do, description: 'Execute Gen4 command via access key',
389
+ arguments: [{name: :access_key_id, type: :identifier}],
390
+ setup: :setup_access_key_do
391
+ command :set_bearer_key, description: 'Set bearer key on access key',
392
+ arguments: [{name: :access_key_id}, {name: :bearer_key_pem, type: String}]
393
+ crud_commands entity: 'access_keys',
394
+ api: :@api_node,
395
+
396
+ body_component: Schema::Registry::NODE,
397
+ lookup: :lookup_access_key_self_id
398
+ end
399
+
400
+ commands_under %i[access_keys do] do
401
+ COMMANDS_GEN4_SPEC.each do |cmd, spec|
402
+ command cmd, **spec
403
+ end
404
+ command :permission, description: 'Manage permissions', setup: :setup_access_key_do_permission
405
+ command :sync, description: 'Synchronize folders'
406
+ commands_under :sync do
407
+ Sync::Operations::DIRECTIONS.each do |dir|
408
+ command dir, description: "#{dir.capitalize}-sync", transfer_paths: :send, arguments: SyncActions::PATH_AND_INFO_ARGS
409
+ end
410
+ command :admin, description: 'Manage sync database (admin operations)'
411
+ SyncActions.register_sync_admin_commands(self, :admin)
412
+ end
413
+ end
414
+ commands_under %i[access_keys do permission] do
415
+ command :list, description: 'List permissions on a file'
416
+ command :show, description: 'Show a permission',
417
+ arguments: [{name: :perm_id, type: :identifier}],
418
+ action: ->(apifid:, perm_id:, **) { Result::SingleObject.new(apifid.node_api.read("permissions/#{perm_id}")) }
419
+ command :create, description: 'Create a permission',
420
+ arguments: [{name: :data, type: Hash, schema: 'node:components.schemas.permissions-post-request'}]
421
+ command :modify, description: 'Modify a permission',
422
+ arguments: [{name: :perm_id, type: :identifier}, {name: :data, type: Hash, schema: 'node:components.schemas.permissions-id-put-request'}]
423
+ command :delete, description: 'Delete permissions',
424
+ arguments: [{name: :perm_id, bulk: true}]
425
+ end
426
+ # async (legacy /async)
427
+ commands_under :async, description: 'synchronization (legacy /async)' do
428
+ command :list, description: 'List async sync IDs', action: -> { Result::ValueList.new(@api_node.read('async/list')['sync_ids']) }
429
+ command :show, description: 'Show async summary',
430
+ arguments: [{name: :async_id, type: :identifier, lookup: :async_lookup}]
431
+ command :delete, description: 'Delete async',
432
+ arguments: [{name: :async_id, type: :identifier, lookup: :async_lookup}]
433
+ command :bandwidth, description: 'Show async bandwidth',
434
+ arguments: [{name: :async_id, type: :identifier, lookup: :async_lookup}]
435
+ command :files, description: 'List async files',
436
+ arguments: [{name: :async_id, type: :identifier, lookup: :async_lookup}]
437
+ command :counters, description: 'Show async counters',
438
+ arguments: [{name: :async_id, type: :identifier, lookup: :async_lookup}]
439
+ end
440
+ # ssync (/asyncs)
441
+ # command :ssync, description: 'Manage sync operations (/asyncs)'
442
+ commands_under :ssync, description: 'synchronization (/asyncs)' do
443
+ crud_commands entity: 'asyncs',
444
+ api: :@api_node,
445
+ operations: %i[create list show delete],
446
+ items_key: 'ids',
447
+ lookup: :ssync_lookup
448
+ command :start, description: 'Start a sync',
449
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
450
+ action: :action_ssync_start
451
+ command :stop, description: 'Stop a sync',
452
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
453
+ action: :action_ssync_stop
454
+ command :bandwidth, description: 'Show sync bandwidth',
455
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
456
+ action: ->(ssync_id:, **) { Result::SingleObject.new(@api_node.read("asyncs/#{ssync_id}/bandwidth", options.get_option(:query) || {})) }
457
+ command :counters, description: 'Show sync counters',
458
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
459
+ action: ->(ssync_id:, **) { Result::SingleObject.new(@api_node.read("asyncs/#{ssync_id}/counters", options.get_option(:query) || {})) }
460
+ command :files, description: 'List sync files',
461
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
462
+ action: ->(ssync_id:, **) { Result::SingleObject.new(@api_node.read("asyncs/#{ssync_id}/files", options.get_option(:query) || {})) }
463
+ command :state, description: 'Show sync state',
464
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
465
+ action: ->(ssync_id:, **) { Result::SingleObject.new(@api_node.read("asyncs/#{ssync_id}/state")) }
466
+ command :summary, description: 'Show sync summary',
467
+ arguments: [{name: :ssync_id, type: :identifier, lookup: :ssync_lookup}],
468
+ action: ->(ssync_id:, **) { Result::SingleObject.new(@api_node.read("asyncs/#{ssync_id}/summary")) }
469
+ end
470
+ # stream
471
+ command :stream, description: 'Manage stream operations'
472
+ commands_under :stream do
473
+ command :list, description: 'List streams', action: -> { Result::ObjectList.new(@api_node.read('ops/transfers', query_read_delete), fields: %w[id status]) }
474
+ command :create, description: 'Create a stream',
475
+ arguments: [{name: :data, type: Hash, schema: 'node:components.schemas.transferPostRequest'}],
476
+ action: ->(data:, **) { Result::SingleObject.new(@api_node.create('streams', data)) }
477
+ command :show, description: 'Show a stream',
478
+ arguments: [{name: :transfer_id, type: :identifier}],
479
+ action: ->(transfer_id:, **) { Result::SingleObject.new(@api_node.read("ops/transfers/#{transfer_id}")) }
480
+ command :modify, description: 'Modify a stream',
481
+ arguments: [{name: :transfer_id, type: :identifier}, {name: :data, type: Hash, schema: 'node:components.schemas.transferPutRequest'}],
482
+ action: ->(data:, transfer_id:, **) { Result::SingleObject.new(@api_node.update("streams/#{transfer_id}", data)) }
483
+ command :cancel, description: 'Cancel a stream',
484
+ arguments: [{name: :transfer_id, type: :identifier}],
485
+ action: ->(transfer_id:, **) { Result::SingleObject.new(@api_node.cancel("streams/#{transfer_id}")) }
486
+ end
487
+ # transfer
488
+ command :transfer, description: 'Manage transfer operations'
489
+ commands_under :transfer do
490
+ command :list, description: 'List transfers'
491
+ command :cancel, description: 'Cancel a transfer',
492
+ arguments: [{name: :transfer_id, type: :identifier}]
493
+ command :show, description: 'Show a transfer',
494
+ arguments: [{name: :transfer_id, type: :identifier}],
495
+ action: ->(transfer_id:, **) { Result::SingleObject.new(@api_node.read("ops/transfers/#{transfer_id}")) }
496
+ command :modify, description: 'Modify a transfer',
497
+ arguments: [{name: :transfer_id, type: :identifier}, {name: :update_value, type: Hash, schema: 'node:components.schemas.transferPutRequest'}]
498
+ command :bandwidth_average, description: 'Show average bandwidth per period'
499
+ command :sessions, description: 'List transfer sessions'
500
+ end
501
+ # service
502
+ command :service, description: 'Manage services'
503
+ commands_under :service do
504
+ command :list, description: 'List services', action: -> { Result::ObjectList.new(@api_node.read('rund/services')['services']) }
505
+ command :create, description: 'Create a service',
506
+ arguments: [{name: :creation_data, type: Hash}]
507
+ command :delete, description: 'Delete a service',
508
+ arguments: [{name: :service_id, type: :identifier}]
509
+ end
510
+ # watch_folder
511
+ command :watch_folder, description: 'Manage watch folders', setup: :setup_watch_folder
512
+ commands_under :watch_folder do
513
+ command :create, description: 'Create a watch folder',
514
+ arguments: [{name: :data, type: Hash}],
515
+ action: ->(data:, **) { Result::Status.new("#{@api_node.create('v3/watchfolders', data)['id']} created") }
516
+ command :list, description: 'List watch folders',
517
+ action: -> { Result::ValueList.new(@api_node.read('v3/watchfolders', query_read_delete)['ids']) }
518
+ command :show, description: 'Show a watch folder',
519
+ arguments: [{name: :watch_folder_id, type: :identifier}],
520
+ action: ->(watch_folder_id:, **) { Result::SingleObject.new(@api_node.read("v3/watchfolders/#{watch_folder_id}")) }
521
+ command :modify, description: 'Modify a watch folder',
522
+ arguments: [{name: :watch_folder_id, type: :identifier}, {name: :data, type: Hash}]
523
+ command :delete, description: 'Delete a watch folder',
524
+ arguments: [{name: :watch_folder_id, type: :identifier}]
525
+ command :state, description: 'Show watch folder state',
526
+ arguments: [{name: :watch_folder_id, type: :identifier}],
527
+ action: ->(watch_folder_id:, **) { Result::SingleObject.new(@api_node.read("v3/watchfolders/#{watch_folder_id}/state")) }
528
+ end
529
+ # central
530
+ command :central, description: 'Query Central service'
531
+ commands_under :central do
532
+ command :session, description: 'Query sessions'
533
+ command :file, description: 'Query files'
534
+ end
535
+ commands_under %i[central session] do
536
+ command :list, description: 'List sessions',
537
+ arguments: [{name: :request_data, type: Hash, mandatory: false, default: nil}]
538
+ end
539
+ commands_under %i[central file] do
540
+ command :list, description: 'List file transfers',
541
+ arguments: [{name: :request_data, type: Hash, mandatory: false, default: nil}]
542
+ command :modify, description: 'Modify file transfer validation',
543
+ arguments: [{name: :request_data, type: Hash, mandatory: false, default: nil}]
544
+ end
545
+ # Standalone leaf commands
546
+ command :asperabrowser, description: 'Open Aspera browser'
547
+ command :basic_token, description: 'Generate basic auth token', action: -> { Result::Text.new(Rest.basic_authorization(options.get_option(:username, mandatory: true), options.get_option(:password, mandatory: true))) }
548
+ command :bearer_token, description: 'Generate bearer token',
549
+ arguments: [{name: :private_key_pem, type: String}, {name: :user_group_id, type: Hash, schema: 'opts:components.schemas.NodeBearerTokenOptions'}]
550
+ command :simulator, description: 'Start node simulator',
551
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}, schema: 'opts:components.schemas.NodeSimulatorOptions'}]
552
+ command :telemetry, description: 'Report telemetry to external system',
553
+ arguments: [{name: :parameters, type: Hash, mandatory: false, default: {}, schema: 'opts:components.schemas.NodeTelemetryOptions'}]
554
+
555
+ # --- Handler methods (Gen3) ---
556
+
557
+ def action_delete(paths:, **)
558
+ # TODO: add query for recursive
559
+ paths_to_delete = Array(paths)
560
+ resp = @api_node.create('files/delete', {paths: paths_to_delete.map { |i| {'path' => i.start_with?('/') ? i : "/#{i}"} }})
561
+ cli_result_from_paths_response(resp, 'file deleted')
562
+ end
563
+
564
+ def action_search(search_root:, **)
565
+ parameters = {'path' => search_root}
566
+ other_options = options.get_option(:query)
567
+ parameters.merge!(other_options) unless other_options.nil?
568
+ resp = @api_node.create('files/search', parameters)
569
+ return Result::Empty.new if resp['items'].empty?
570
+ fields = resp['items'].first.keys.reject { |i| SEARCH_REMOVE_FIELDS.include?(i) }
571
+ formatter.display_item_count(resp['item_count'], resp['total_count'])
572
+ formatter.display_status("params: #{resp['parameters'].keys.map { |k| "#{k}:#{resp['parameters'][k]}" }.join(',')}")
573
+ Result::ObjectList.new(resp['items'], fields: fields)
574
+ end
575
+
576
+ def action_space(path_list:, **)
577
+ path_list = Array(path_list)
578
+ resp = @api_node.create('space', {'paths' => path_list.map { |i| {path: i} }})
579
+ Result::ObjectList.new(resp['paths'])
580
+ end
581
+
582
+ def action_mkdir(path_list:, **)
583
+ path_list = Array(path_list)
584
+ resp = @api_node.create('files/create', {'paths' => path_list.map { |i| {type: :directory, path: i} }})
585
+ cli_result_from_paths_response(resp, 'folder created')
586
+ end
587
+
588
+ def action_mklink(target:, link_path:, **)
589
+ resp = @api_node.create('files/create', {'paths' => [{type: :symbolic_link, path: link_path, target: {path: target}}]})
590
+ cli_result_from_paths_response(resp, 'link created')
591
+ end
592
+
593
+ def action_mkfile(file_path:, contents:, **)
594
+ contents64 = contents.nil? ? '' : Base64.strict_encode64(contents)
595
+ resp = @api_node.create('files/create', {'paths' => [{type: :file, path: file_path, contents: contents64}]})
596
+ cli_result_from_paths_response(resp, 'file created')
597
+ end
598
+
599
+ def action_rename(path_base:, path_src:, path_dst:, **)
600
+ # TODO: multiple ?
601
+ resp = @api_node.create('files/rename', {'paths' => [{'path' => path_base, 'source' => path_src, 'destination' => path_dst}]})
602
+ cli_result_from_paths_response(resp, 'entry moved')
603
+ end
604
+
605
+ # Obtains a transfer spec via the Node API for the given direction/folders.
606
+ def sync_gen3_block
607
+ lambda do |direction, local_path, remote_path|
608
+ request_transfer_spec = sync_spec_request(direction, local_path, remote_path)
609
+ @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
610
+ transfer_spec = @api_node.create(
611
+ 'files/sync_setup',
612
+ {transfer_requests: [{transfer_request: request_transfer_spec}]}
613
+ )['transfer_specs'].first['transfer_spec']
614
+ transfer_spec.delete_if { |_k, v| v.nil? }
615
+ Log.dump(:ts, transfer_spec)
616
+ transfer_spec
617
+ end
618
+ end
619
+
620
+ Sync::Operations::DIRECTIONS.each do |dir|
621
+ define_method(:"action_sync_#{dir}") { |path:, sync_info: {}, **| run_sync_transfer(dir, path: path, sync_info: sync_info, &sync_gen3_block) }
622
+ end
623
+
624
+ def action_upload
625
+ # empty transfer spec for authorization request
626
+ request_transfer_spec = {}
627
+ request_transfer_spec[:paths] = [{destination: transfer.destination_folder(Transfer::Spec::DIRECTION_SEND)}]
628
+ # add fixed parameters if any (for COS)
629
+ @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
630
+ Api::Node.add_public_key(request_transfer_spec)
631
+ setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
632
+ transfer_spec = @api_node.create('files/upload_setup', setup_payload)['transfer_specs'].first['transfer_spec']
633
+ Api::Node.add_private_key(transfer_spec)
634
+ transfer_spec.delete('paths')
635
+ Runner.result_transfer(transfer.start(transfer_spec))
636
+ end
637
+
638
+ def action_download
639
+ # empty transfer spec for authorization request
640
+ request_transfer_spec = {}
641
+ request_transfer_spec[:paths] = transfer.ts_source_paths
642
+ # add fixed parameters if any (for COS)
643
+ @api_node.add_tspec_info(request_transfer_spec) if @api_node.respond_to?(:add_tspec_info)
644
+ Api::Node.add_public_key(request_transfer_spec)
645
+ setup_payload = {transfer_requests: [{transfer_request: request_transfer_spec}]}
646
+ transfer_spec = @api_node.create('files/download_setup', setup_payload)['transfer_specs'].first['transfer_spec']
647
+ Api::Node.add_private_key(transfer_spec)
648
+ Runner.result_transfer(transfer.start(transfer_spec))
649
+ end
650
+
651
+ def action_cat(remote_path:, **)
652
+ http = @api_node.read("files/#{URI.encode_www_form_component(remote_path)}/contents", ret: :resp)
653
+ Result::Text.new(http.body)
654
+ end
764
655
 
765
- def watch_folder_action
766
- res_class_path = 'v3/watchfolders'
767
- command = options.get_next_command(WATCH_FOLDER_MUL + WATCH_FOLDER_SING)
768
- if WATCH_FOLDER_SING.include?(command)
769
- one_res_id = options.instance_identifier
770
- one_res_path = "#{res_class_path}/#{one_res_id}"
656
+ def action_health
657
+ nagios = Nagios.new
658
+ begin
659
+ info = @api_node.read('info')
660
+ nagios.add_ok('node api', 'accessible')
661
+ nagios.check_time_offset(info['current_time'], 'node api')
662
+ nagios.check_product_version('node api', 'entsrv', info['version'])
663
+ rescue StandardError => e
664
+ nagios.add_critical('node api', e.to_s)
771
665
  end
772
- # hum, to avoid: Unable to convert 2016_09_14 configuration
666
+ begin
667
+ @api_node.call(
668
+ operation: 'POST',
669
+ subpath: 'services/soap/Transfer-201210',
670
+ content_type: Mime::TEXT,
671
+ body: CENTRAL_SOAP_API_TEST,
672
+ headers: {'Content-Type' => 'text/xml;charset=UTF-8', 'SOAPAction' => 'FASPSessionNET-200911#GetSessionInfo'},
673
+ ret: :resp
674
+ ).body
675
+ nagios.add_ok('central', 'accessible by node')
676
+ rescue StandardError => e
677
+ nagios.add_critical('central', e.to_s)
678
+ end
679
+ Result::ObjectList.new(nagios.status_list)
680
+ end
681
+
682
+ # watch_folder setup: inject required API header (avoids "Unable to convert 2016_09_14 configuration")
683
+ def setup_watch_folder(**)
773
684
  @api_node.params[:headers] ||= {}
774
685
  @api_node.params[:headers]['X-aspera-WF-version'] = '2017_10_23'
775
- case command
776
- when :create
777
- resp = @api_node.create(res_class_path, value_create_modify(command: command))
778
- return Result::Status.new("#{resp['id']} created")
779
- when :list
780
- resp = @api_node.read(res_class_path, query_read_delete)
781
- return Result::ValueList.new(resp['ids'])
782
- when :show
783
- return Result::SingleObject.new(@api_node.read(one_res_path))
784
- when :modify
785
- @api_node.update(one_res_path, value_create_modify(command: 'watch_folder'))
786
- return Result::Status.new("#{one_res_id} updated")
787
- when :delete
788
- @api_node.delete(one_res_path)
789
- return Result::Status.new("#{one_res_id} deleted")
790
- when :state
791
- return Result::SingleObject.new(@api_node.read("#{one_res_path}/state"))
686
+ {}
687
+ end
688
+
689
+ def action_access_keys_do_permission_modify(data:, apifid:, perm_id:, **)
690
+ apifid.node_api.update("permissions/#{perm_id}", data)
691
+ Result::Status.new('Updated')
692
+ end
693
+
694
+ def action_watch_folder_modify(data:, watch_folder_id:, **)
695
+ @api_node.update("v3/watchfolders/#{watch_folder_id}", data)
696
+ Result::Status.new("#{watch_folder_id} updated")
697
+ end
698
+
699
+ def action_watch_folder_delete(watch_folder_id:, **)
700
+ @api_node.delete("v3/watchfolders/#{watch_folder_id}")
701
+ Result::Status.new("#{watch_folder_id} deleted")
702
+ end
703
+
704
+ # access_keys > do - setup: resolve access key and root file id
705
+ # access_key_id is resolved by execute_leaf (via instance_arg in CommandSpec)
706
+ # @return [Hash] context hash containing :do_root_file_id
707
+ def setup_access_key_do(access_key_id:, **)
708
+ @do_root_file_id = options.get_option(:root_id)
709
+ if @do_root_file_id.nil?
710
+ ak_info = @api_node.read("access_keys/#{access_key_id}")
711
+ ak_secret = context.secret_finder.lookup(url: @api_node.base_url, username: ak_info['id'])
712
+ if !access_key_id.eql?('self')
713
+ Aspera.assert(ak_secret, type: Cli::MissingArgument) { "Please provide secret for #{ak_info['id']} using option: secret or by setting a preset for #{ak_info['id']}@#{@api_node.base_url}." }
714
+ @api_node.auth_params[:username] = ak_info['id']
715
+ @api_node.auth_params[:password] = ak_secret
716
+ end
717
+ @do_root_file_id = ak_info['root_file_id']
792
718
  end
719
+ {do_root_file_id: @do_root_file_id}
720
+ end
721
+
722
+ # access_keys > do > permission - setup: resolve apifid from CLI path argument
723
+ # (path is read from CLI here as 'permission' is an intermediate node)
724
+ # @return [Hash] context hash containing :apifid
725
+ def setup_access_key_do_permission(**)
726
+ {apifid: apifid_from_next_arg(@do_root_file_id)}
793
727
  end
794
728
 
795
- ACTIONS = %i[
796
- async
797
- ssync
798
- stream
799
- transfer
800
- service
801
- watch_folder
802
- central
803
- asperabrowser
804
- basic_token
805
- bearer_token
806
- simulator
807
- telemetry
808
- ].concat(COMMON_ACTIONS).freeze
809
-
810
- def execute_action(command = nil)
811
- command ||= options.get_next_command(ACTIONS)
812
- case command
813
- when *COMMON_ACTIONS then return execute_simple_common(command)
814
- when :async then return execute_async # former API
815
- when :ssync
816
- # Node API: /asyncs (newer)
817
- sync_command = options.get_next_command(%i[start stop bandwidth counters files state summary] + Operations::ALL - %i[modify])
818
- case sync_command
819
- when *Operations::ALL
820
- return entity_execute(
821
- api: @api_node,
822
- entity: :asyncs,
823
- command: sync_command,
824
- items_key: 'ids'
825
- ){ |field, value| ssync_lookup(field, value)}
729
+ # access_keys > do > ls
730
+ def action_access_keys_do_ls(path:, do_root_file_id:, **)
731
+ apifid = apifid_from_path(do_root_file_id, path)
732
+ file_info = apifid.node_api.read("files/#{apifid.file_id}", headers: Api::Node.add_cache_control)
733
+ return Result::ObjectList.new([file_info], fields: GEN4_LS_FIELDS) unless file_info['type'].eql?('folder')
734
+ Result::ObjectList.new(apifid.node_api.list_files(apifid.file_id, query: query_read_delete), fields: GEN4_LS_FIELDS)
735
+ end
736
+
737
+ # access_keys > do > find
738
+ def action_access_keys_do_find(path:, filter: nil, do_root_file_id:, **)
739
+ apifid = apifid_from_path(do_root_file_id, path)
740
+ Result::ObjectList.new(@api_node.find_files(apifid.file_id, Base.file_matcher(filter)), fields: ['path'])
741
+ end
742
+
743
+ # access_keys > do > cat
744
+ def action_access_keys_do_cat(path:, do_root_file_id:, **)
745
+ apifid = apifid_from_path(do_root_file_id, path)
746
+ Result::Text.new(apifid.node_api.read("files/#{apifid.file_id}/content", ret: :resp).body)
747
+ end
748
+
749
+ # access_keys > do > show
750
+ def action_access_keys_do_show(path:, do_root_file_id:, **)
751
+ apifid = apifid_from_path(do_root_file_id, path)
752
+ Result::SingleObject.new(apifid.node_api.read("files/#{apifid.file_id}"))
753
+ end
754
+
755
+ # access_keys > do > modify
756
+ def action_access_keys_do_modify(path:, update_value:, do_root_file_id:, **)
757
+ apifid = apifid_from_path(do_root_file_id, path)
758
+ apifid.node_api.update("files/#{apifid.file_id}", update_value)
759
+ Result::Status.new('Done')
760
+ end
761
+
762
+ # access_keys > do > thumbnail
763
+ def action_access_keys_do_thumbnail(path:, do_root_file_id:, **)
764
+ apifid = apifid_from_path(do_root_file_id, path)
765
+ Result::Image.new(apifid.node_api.read("files/#{apifid.file_id}/preview", headers: {'Accept' => 'image/png'}, ret: :resp).body)
766
+ end
767
+
768
+ # access_keys > do > rename
769
+ def action_access_keys_do_rename(source_path:, new_name:, do_root_file_id:, **)
770
+ apifid = @api_node.resolve_api_fid(do_root_file_id, source_path)
771
+ apifid.node_api.update("files/#{apifid.file_id}", {name: new_name})
772
+ Result::Status.new("renamed to #{new_name}")
773
+ end
774
+
775
+ # access_keys > do > delete
776
+ def action_access_keys_do_delete(paths:, do_root_file_id:, **)
777
+ bulk_result(paths, command: :delete, id_result: 'path') do |l_path|
778
+ apifid = if (m = Parser.percent_selector(l_path))
779
+ Aspera.assert_values(m[:field], ['id'], type: BadIdentifier)
780
+ Api::NodeFileId.new(@api_node, m[:value])
826
781
  else
827
- asyncs_id = options.instance_identifier{ |field, value| ssync_lookup(field, value)}
828
- if %i[start stop].include?(sync_command)
829
- @api_node.call(
830
- operation: 'POST',
831
- subpath: "asyncs/#{asyncs_id}/#{sync_command}",
832
- content_type: Mime::TEXT,
833
- body: '',
834
- ret: :resp
835
- ).body
836
- return Result::Status.new('Done')
837
- end
838
- parameters = options.get_option(:query) || {} if %i[bandwidth counters files].include?(sync_command)
839
- return Result::SingleObject.new(@api_node.read("asyncs/#{asyncs_id}/#{sync_command}", parameters))
782
+ @api_node.resolve_api_fid(do_root_file_id, l_path)
783
+ end
784
+ apifid.node_api.delete("files/#{apifid.file_id}")
785
+ {'path' => l_path}
786
+ end
787
+ end
788
+
789
+ # Shared Gen4 sync block: obtains a transfer spec via the Gen4 API for the given direction/remote_path.
790
+ def sync_gen4_block(do_root_file_id)
791
+ lambda do |direction, _local_path, remote_path|
792
+ ts_direction = direction.eql?(:pull) ? Transfer::Spec::DIRECTION_RECEIVE : Transfer::Spec::DIRECTION_SEND
793
+ apifid = @api_node.resolve_api_fid(do_root_file_id, remote_path)
794
+ apifid.node_api.transfer_spec_gen4(apifid.file_id, ts_direction)
795
+ end
796
+ end
797
+
798
+ Sync::Operations::DIRECTIONS.each do |dir|
799
+ define_method(:"action_access_keys_do_sync_#{dir}") do |path:, sync_info: {}, do_root_file_id:, **|
800
+ run_sync_transfer(dir, path: path, sync_info: sync_info, &sync_gen4_block(do_root_file_id))
801
+ end
802
+ end
803
+
804
+ # access_keys > do > upload
805
+ def action_access_keys_do_upload(do_root_file_id:, **)
806
+ apifid = @api_node.resolve_api_fid(do_root_file_id, transfer.destination_folder(Transfer::Spec::DIRECTION_SEND), true)
807
+ Runner.result_transfer(transfer.start(apifid.node_api.transfer_spec_gen4(apifid.file_id, Transfer::Spec::DIRECTION_SEND)))
808
+ end
809
+
810
+ # access_keys > do > download
811
+ def action_access_keys_do_download(do_root_file_id:, **)
812
+ apifid, source_paths = @api_node.resolve_api_fid_paths(do_root_file_id, transfer.ts_source_paths)
813
+ Runner.result_transfer(transfer.start(apifid.node_api.transfer_spec_gen4(apifid.file_id, Transfer::Spec::DIRECTION_RECEIVE, {'paths'=>source_paths})))
814
+ end
815
+
816
+ # access_keys > do > permission (intermediate node: re-enter DSL at permission sub-tree)
817
+ def action_access_keys_do_permission(do_root_file_id:, **)
818
+ apifid = apifid_from_next_arg(do_root_file_id)
819
+ dispatch_from_registry(%i[access_keys do permission], {apifid: apifid}, skip_setup: true)
820
+ end
821
+
822
+ # access_keys > do > v3
823
+ def action_access_keys_do_v3(do_root_file_id:, **)
824
+ # Build a fresh Node for the v3 root, then let the DSL dispatcher consume the next command.
825
+ v3_node = Node.new(context: context, api: @api_node.resolve_api_fid(do_root_file_id, '').node_api)
826
+ v3_node.execute_action
827
+ end
828
+
829
+ # access_keys > do > node_info / bearer_token_node — shared helper builds the result hash
830
+ def gen4_apifid_info(do_root_file_id, path)
831
+ apifid = apifid_from_path(do_root_file_id, path)
832
+ result = {url: apifid.node_api.base_url, root_id: apifid.file_id}
833
+ case apifid.node_api.auth_params[:type]
834
+ when :basic
835
+ result[:username] = apifid.node_api.auth_params[:username]
836
+ result[:password] = apifid.node_api.auth_params[:password]
837
+ when :oauth2
838
+ result[:username] = apifid.node_api.params[:headers][Api::Node::HEADER_X_ASPERA_ACCESS_KEY]
839
+ result[:password] = apifid.node_api.oauth.authorization
840
+ else Aspera.error_unexpected_value(apifid.node_api.auth_params[:type]) { 'Node API Auth type' }
841
+ end
842
+ [apifid, result]
843
+ end
844
+
845
+ def action_access_keys_do_node_info(path:, do_root_file_id:, **)
846
+ _apifid, result = gen4_apifid_info(do_root_file_id, path)
847
+ Result::SingleObject.new(result)
848
+ end
849
+
850
+ def action_access_keys_do_bearer_token_node(path:, do_root_file_id:, **)
851
+ apifid, result = gen4_apifid_info(do_root_file_id, path)
852
+ Log.dump(:result, result)
853
+ Aspera.assert(apifid.node_api.auth_params[:type].eql?(:oauth2), type: BadArgument) { "Cannot get bearer token if authenticating with secret (#{apifid.node_api.auth_params[:type]})" }
854
+ Aspera.assert(OAuth::Factory.bearer_auth?(result[:password]), 'Not using bearer token auth')
855
+ Result::Text.new(result[:password])
856
+ end
857
+
858
+ # access_keys > do > mkdir / mklink / mkfile — shared helper: resolve parent folder,
859
+ # build payload from query option, optionally check for name collision.
860
+ # @return [Array(NodeFileId, Hash)] parent apifid and payload with :name set
861
+ # @param path [String] full path (folder/name)
862
+ def gen4_mk_resolve(top_file_id, path)
863
+ containing_folder_path, new_item = Api::Node.split_folder(path)
864
+ apifid = @api_node.resolve_api_fid(top_file_id, containing_folder_path, true)
865
+ query = options.get_option(:query)
866
+ check_exists = true
867
+ payload = {name: new_item}
868
+ if query
869
+ check_exists = !query.delete('check').eql?(false)
870
+ target = query.delete('target')
871
+ if target
872
+ target_apifid = @api_node.resolve_api_fid(top_file_id, target, true)
873
+ payload[:target_id] = target_apifid.file_id
840
874
  end
841
- when :stream
842
- command = options.get_next_command(%i[list create show modify cancel])
843
- case command
844
- when :list
845
- resp = @api_node.read('ops/transfers', query_read_delete)
846
- return Result::ObjectList.new(resp, fields: %w[id status]) # TODO: useful?
847
- when :create
848
- resp = @api_node.create('streams', value_create_modify(command: command))
849
- return Result::SingleObject.new(resp)
850
- when :show
851
- resp = @api_node.read("ops/transfers/#{options.get_next_argument('transfer id')}")
852
- return Result::SingleObject.new(resp)
853
- when :modify
854
- resp = @api_node.update("streams/#{options.get_next_argument('transfer id')}", value_create_modify(command: command))
855
- return Result::SingleObject.new(resp)
856
- when :cancel
857
- resp = @api_node.cancel("streams/#{options.get_next_argument('transfer id')}")
858
- return Result::SingleObject.new(resp)
859
- else Aspera.error_unexpected_value(command)
875
+ payload.merge!(query.symbolize_keys)
876
+ end
877
+ if check_exists
878
+ folder_content = apifid.node_api.read("files/#{apifid.file_id}/files")
879
+ link_name = ".#{new_item}.asp-lnk"
880
+ found = folder_content.find { |i| i['name'].eql?(new_item) || i['name'].eql?(link_name) }
881
+ Aspera.assert(!found, type: Cli::Error) { "A #{found['type']} already exists with name #{new_item}" }
882
+ end
883
+ [apifid, payload]
884
+ end
885
+
886
+ def action_access_keys_do_mkdir(path:, do_root_file_id:, **)
887
+ apifid, payload = gen4_mk_resolve(do_root_file_id, path)
888
+ payload[:type] = :folder
889
+ Result::SingleObject.new(apifid.node_api.create("files/#{apifid.file_id}/files", payload))
890
+ end
891
+
892
+ def action_access_keys_do_mklink(path:, do_root_file_id:, **)
893
+ apifid, payload = gen4_mk_resolve(do_root_file_id, path)
894
+ payload[:type] = :link
895
+ Aspera.assert(payload[:target_id], 'Missing target_id')
896
+ Aspera.assert(payload[:target_node_id], 'Missing target_node_id')
897
+ Result::SingleObject.new(apifid.node_api.create("files/#{apifid.file_id}/files", payload))
898
+ end
899
+
900
+ def action_access_keys_do_mkfile(path:, contents:, do_root_file_id:, **)
901
+ apifid, payload = gen4_mk_resolve(do_root_file_id, path)
902
+ payload[:type] = :file
903
+ payload[:contents] = contents.nil? ? '' : Base64.strict_encode64(contents)
904
+ Result::SingleObject.new(apifid.node_api.create("files/#{apifid.file_id}/files", payload))
905
+ end
906
+
907
+ # access_keys > do > permission > list/show/create/modify/delete
908
+ def action_access_keys_do_permission_list(apifid:, **)
909
+ list_query = query_read_delete(default: Rest.php_style({'include' => %w[access_level permission_count]}))
910
+ # Specify file to get permissions for unless not specified (then, get all permissions)
911
+ list_query['file_id'] = apifid.file_id unless apifid.file_id.to_s.empty?
912
+ list_query['inherited'] = false if list_query.key?('file_id') && !list_query.key?('inherited')
913
+ Result::ObjectList.new(apifid.node_api.read_with_pages('permissions', list_query))
914
+ end
915
+
916
+ def action_access_keys_do_permission_delete(perm_id:, apifid:, **)
917
+ bulk_result(perm_id, command: :delete) do |one_id|
918
+ apifid.node_api.delete("permissions/#{one_id}")
919
+ the_app = apifid.node_api.app_info
920
+ the_app&.api&.permissions_send_event(event_data: {}, app_info: the_app, types: ['permission.deleted'])
921
+ {'id' => one_id}
922
+ end
923
+ end
924
+
925
+ def action_access_keys_do_permission_create(data:, apifid:, **)
926
+ create_param = data
927
+ Aspera.assert(!create_param.key?('file_id'), type: Cli::BadArgument) { 'no file_id' }
928
+ create_param['file_id'] = apifid.file_id
929
+ create_param['access_levels'] = Api::Node::ACCESS_LEVELS unless create_param.key?('access_levels')
930
+ the_app = apifid.node_api.app_info
931
+ the_app&.api&.permissions_set_create_params(perm_data: create_param, app_info: the_app)
932
+ created_data = apifid.node_api.create('permissions', create_param)
933
+ the_app&.api&.permissions_send_event(event_data: created_data, app_info: the_app)
934
+ Result::SingleObject.new(created_data)
935
+ end
936
+
937
+ # access_keys > set_bearer_key
938
+ def action_access_keys_set_bearer_key(access_key_id:, bearer_key_pem:, **)
939
+ access_key_id = @api_node.read('access_keys/self')['id'] if access_key_id.eql?('self')
940
+ key = OpenSSL::PKey.read(bearer_key_pem)
941
+ key = key.public_key if key.private?
942
+ @api_node.update("access_keys/#{access_key_id}", {token_verification_key: key.to_pem})
943
+ Result::Status.new('public key updated')
944
+ end
945
+
946
+ # async sub-commands: individual handlers
947
+ def action_async_show(async_id:, **)
948
+ async_ids = @api_node.read('async/list')['sync_ids']
949
+ if async_id.eql?(SpecialValues::ALL)
950
+ resp = @api_node.create('async/summary', {'syncs' => async_ids})['sync_summaries']
951
+ return Result::Empty.new if resp.empty?
952
+ return Result::ObjectList.new(resp, fields: %w[snid name local_dir remote_dir])
953
+ end
954
+ Integer(async_id)
955
+ resp = @api_node.create('async/summary', {'syncs' => [async_id]})['sync_summaries']
956
+ return Result::Empty.new if resp.empty?
957
+ Result::SingleObject.new(resp.first)
958
+ end
959
+
960
+ def action_async_delete(async_id:, **)
961
+ async_ids = async_id.eql?(SpecialValues::ALL) ? @api_node.read('async/list')['sync_ids'] : [async_id]
962
+ Result::SingleObject.new(@api_node.create('async/delete', {'syncs' => async_ids}))
963
+ end
964
+
965
+ def action_async_bandwidth(async_id:, **)
966
+ Integer(async_id)
967
+ post_data = {'syncs' => [async_id], 'seconds' => 100}
968
+ resp = @api_node.create('async/bandwidth', post_data)
969
+ data = resp['bandwidth_data']
970
+ return Result::Empty.new if data.empty?
971
+ Result::ObjectList.new(data.first[async_id]['data'])
972
+ end
973
+
974
+ def action_async_files(async_id:, **)
975
+ Integer(async_id)
976
+ post_data = {'syncs' => [async_id]}
977
+ filter = options.get_option(:query)
978
+ post_data.merge!(filter) unless filter.nil?
979
+ resp = @api_node.create('async/files', post_data)
980
+ data = resp['sync_files']
981
+ data = data.first[async_id] unless data.empty?
982
+ iteration_data = []
983
+ skip_ids_persistency = nil
984
+ if options.get_option(:once_only, mandatory: true)
985
+ skip_ids_persistency = PersistencyActionOnce.new(
986
+ manager: persistency,
987
+ data: iteration_data,
988
+ id: IdGenerator.from_list('sync_files', options.get_option(:url, mandatory: true), options.get_option(:username, mandatory: true), async_id)
989
+ )
990
+ data.select! { |l| l['fnid'].to_i > iteration_data.first } unless iteration_data.first.nil?
991
+ iteration_data[0] = data.last['fnid'].to_i unless data.empty?
992
+ end
993
+ return Result::Empty.new if data.empty?
994
+ skip_ids_persistency&.save
995
+ Result::ObjectList.new(data)
996
+ end
997
+
998
+ def action_async_counters(async_id:, **)
999
+ Integer(async_id)
1000
+ resp = @api_node.create('async/counters', {'syncs' => [async_id]})['sync_counters'].first[async_id].last
1001
+ return Result::Empty.new if resp.nil?
1002
+ Result::SingleObject.new(resp)
1003
+ end
1004
+
1005
+ def action_ssync_start(ssync_id:, **)
1006
+ @api_node.call(operation: 'POST', subpath: "asyncs/#{ssync_id}/start", content_type: Mime::TEXT, body: '', ret: :resp).body
1007
+ Result::Status.new('Done')
1008
+ end
1009
+
1010
+ def action_ssync_stop(ssync_id:, **)
1011
+ @api_node.call(operation: 'POST', subpath: "asyncs/#{ssync_id}/stop", content_type: Mime::TEXT, body: '', ret: :resp).body
1012
+ Result::Status.new('Done')
1013
+ end
1014
+
1015
+ # transfer sub-commands
1016
+ def action_transfer_list
1017
+ transfer_filter = query_read_delete(default: {}, schema: Schema::Registry.query_params(Schema::Registry::NODE, 'ops/transfers'))
1018
+ iteration_persistency = nil
1019
+ if options.get_option(:once_only, mandatory: true)
1020
+ iteration_persistency = PersistencyActionOnce.new(
1021
+ manager: persistency,
1022
+ data: [],
1023
+ id: IdGenerator.from_list('node_transfers', options.get_option(:url, mandatory: true), options.get_option(:username, mandatory: true))
1024
+ )
1025
+ if transfer_filter.delete('reset')
1026
+ iteration_persistency.data.clear
1027
+ iteration_persistency.save
1028
+ return Result::Status.new('Persistency reset')
860
1029
  end
861
- when :transfer
862
- command = options.get_next_command(%i[list cancel show modify bandwidth_average sessions])
863
- case command
864
- when :list
865
- transfer_filter = query_read_delete(default: {})
866
- iteration_persistency = nil
867
- if options.get_option(:once_only, mandatory: true)
868
- iteration_persistency = PersistencyActionOnce.new(
869
- manager: persistency,
870
- data: [],
871
- id: IdGenerator.from_list(
872
- 'node_transfers',
873
- options.get_option(:url, mandatory: true),
874
- options.get_option(:username, mandatory: true)
875
- )
876
- )
877
- if transfer_filter.delete('reset')
878
- iteration_persistency.data.clear
879
- iteration_persistency.save
880
- return Result::Status.new('Persistency reset')
881
- end
882
- else
883
- Aspera.assert(!transfer_filter.key?('reset'), 'reset only with once_only', type: Cli::BadArgument)
884
- end
885
- transfers_data = @api_node.read_with_paging('ops/transfers', transfer_filter, iteration: iteration_persistency&.data)
886
- iteration_persistency&.save
887
- return Result::ObjectList.new(transfers_data, fields: %w[id status start_spec.direction start_spec.remote_user start_spec.remote_host start_spec.destination_path])
888
- when :sessions
889
- transfers_data = @api_node.read('ops/transfers', query_read_delete)
890
- sessions = transfers_data.flat_map{ |t| t['sessions']}
891
- start_end = %i[start end].freeze
892
- sessions.each do |session|
893
- start_end.each do |what|
894
- session["#{what}_time"] = session["#{what}_time_usec"] ? Time.at(session["#{what}_time_usec"] / 1_000_000.0).utc.iso8601(0) : nil
895
- end
896
- end
897
- return Result::ObjectList.new(sessions, fields: %w[id status start_time end_time target_rate_kbps])
898
- when :cancel
899
- @api_node.cancel("ops/transfers/#{options.instance_identifier}")
900
- return Result::Status.new('Cancelled')
901
- when :show
902
- resp = @api_node.read("ops/transfers/#{options.instance_identifier}")
903
- return Result::SingleObject.new(resp)
904
- when :modify
905
- @api_node.update("ops/transfers/#{options.instance_identifier}", options.get_next_argument('update value', validation: Hash))
906
- return Result::Status.new('Modified')
907
- when :bandwidth_average
908
- transfers_data = @api_node.read('ops/transfers', query_read_delete)
909
- # collect all key dates
910
- bandwidth_period = {}
911
- dir_info = %i[avg_kbps sessions].freeze
912
- transfers_data.each do |transfer|
913
- session = transfer
914
- # transfer['sessions'].each do |session|
915
- next if session['avg_rate_kbps'].zero?
916
- bandwidth_period[session['start_time_usec']] = 0
917
- bandwidth_period[session['end_time_usec']] = 0
918
- # end
919
- end
920
- result = []
921
- # all dates sorted numerically
922
- all_dates = bandwidth_period.keys.sort
923
- all_dates.each_with_index do |start_date, index|
924
- end_date = all_dates[index + 1]
925
- # do not process last one
926
- break if end_date.nil?
927
- # init data for this period
928
- period_bandwidth = Transfer::Spec::DIRECTION_ENUM_VALUES.map(&:to_sym).to_h do |direction|
929
- [direction, dir_info.to_h{ |k2| [k2, 0]}]
930
- end
931
- # find all transfers that were active at this time
932
- transfers_data.each do |transfer|
933
- session = transfer
934
- # transfer['sessions'].each do |session|
935
- # skip if not information for this period
936
- next if session['avg_rate_kbps'].zero?
937
- # skip if not in this period
938
- next if session['start_time_usec'] >= end_date || session['end_time_usec'] <= start_date
939
- info = period_bandwidth[transfer['start_spec']['direction'].to_sym]
940
- info[:avg_kbps] += session['avg_rate_kbps']
941
- info[:sessions] += 1
942
- # end
943
- end
944
- next if Transfer::Spec::DIRECTION_ENUM_VALUES.map(&:to_sym).all? do |dir|
945
- period_bandwidth[dir][:sessions].zero?
946
- end
947
- result.push({start: Time.at(start_date / 1_000_000), end: Time.at(end_date / 1_000_000)}.merge(period_bandwidth))
948
- end
949
- return Result::ObjectList.new(result)
950
- else Aspera.error_unexpected_value(command)
1030
+ else
1031
+ Aspera.assert(!transfer_filter.key?('reset'), 'reset only with once_only', type: Cli::BadArgument)
1032
+ end
1033
+ transfers_data = @api_node.read_with_paging('ops/transfers', transfer_filter, iteration: iteration_persistency&.data)
1034
+ iteration_persistency&.save
1035
+ Result::ObjectList.new(transfers_data, fields: %w[id status start_spec.direction start_spec.remote_user start_spec.remote_host start_spec.destination_path])
1036
+ end
1037
+
1038
+ def action_transfer_sessions
1039
+ transfers_data = @api_node.read('ops/transfers', query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::NODE, 'ops/transfers')))
1040
+ sessions = transfers_data.flat_map { |t| t['sessions'] }
1041
+ sessions.each do |session|
1042
+ SESSION_TIME_FIELDS.each do |what|
1043
+ session["#{what}_time"] = session["#{what}_time_usec"] ? Time.at(session["#{what}_time_usec"] / 1_000_000.0).utc.iso8601(0) : nil
951
1044
  end
952
- when :service
953
- command = options.get_next_command(%i[list create delete])
954
- service_id = options.instance_identifier if [:delete].include?(command)
955
- case command
956
- when :list
957
- resp = @api_node.read('rund/services')
958
- return Result::ObjectList.new(resp['services'])
959
- when :create
960
- # @json:'{"type":"WATCHFOLDERD","run_as":{"user":"user1"}}'
961
- params = options.get_next_argument('creation data', validation: Hash)
962
- resp = @api_node.create('rund/services', params)
963
- return Result::Status.new("#{resp['id']} created")
964
- when :delete
965
- @api_node.delete("rund/services/#{service_id}")
966
- return Result::Status.new("#{service_id} deleted")
1045
+ end
1046
+ Result::ObjectList.new(sessions, fields: %w[id status start_time end_time target_rate_kbps])
1047
+ end
1048
+
1049
+ def action_transfer_cancel(transfer_id:, **)
1050
+ @api_node.cancel("ops/transfers/#{transfer_id}")
1051
+ Result::Status.new('Cancelled')
1052
+ end
1053
+
1054
+ def action_transfer_modify(update_value:, transfer_id:, **)
1055
+ @api_node.update("ops/transfers/#{transfer_id}", update_value)
1056
+ Result::Status.new('Modified')
1057
+ end
1058
+
1059
+ def action_transfer_bandwidth_average
1060
+ transfers_data = @api_node.read('ops/transfers', query_read_delete(schema: Schema::Registry.query_params(Schema::Registry::NODE, 'ops/transfers')))
1061
+ bandwidth_period = {}
1062
+ dir_info = %i[avg_kbps sessions].freeze
1063
+ transfers_data.each do |t|
1064
+ next if t['avg_rate_kbps'].zero?
1065
+ bandwidth_period[t['start_time_usec']] = 0
1066
+ bandwidth_period[t['end_time_usec']] = 0
1067
+ end
1068
+ result = []
1069
+ all_dates = bandwidth_period.keys.sort
1070
+ all_dates.each_with_index do |start_date, index|
1071
+ end_date = all_dates[index + 1]
1072
+ break if end_date.nil?
1073
+ period_bandwidth = Transfer::Spec::DIRECTION_ENUM_VALUES.map(&:to_sym).to_h do |dir|
1074
+ [dir, dir_info.to_h { |k2| [k2, 0] }]
967
1075
  end
968
- when :watch_folder
969
- return watch_folder_action
970
- when :central
971
- command = options.get_next_command(%i[session file])
972
- validator_id = options.get_option(:validator)
973
- validation = {'validator_id' => validator_id} unless validator_id.nil?
974
- request_data = options.get_option(:query) || {}
975
- case command
976
- when :session
977
- command = options.get_next_command([:list])
978
- case command
979
- when :list
980
- request_data = options.get_next_argument('request data', mandatory: false, validation: Hash, default: {})
981
- request_data.deep_merge!({'validation' => validation}) unless validation.nil?
982
- resp = @api_node.create('services/rest/transfers/v1/sessions', request_data)
983
- return Result::ObjectList.new(resp['session_info_result']['session_info'], fields: %w[session_uuid status transport direction bytes_transferred])
984
- end
985
- when :file
986
- command = options.get_next_command(%i[list modify])
987
- case command
988
- when :list
989
- request_data = options.get_next_argument('request data', mandatory: false, validation: Hash, default: {})
990
- request_data.deep_merge!({'validation' => validation}) unless validation.nil?
991
- resp = @api_node.create('services/rest/transfers/v1/files', request_data)
992
- resp = JSON.parse(resp) if resp.is_a?(String)
993
- Log.dump(:resp, resp)
994
- return Result::ObjectList.new(resp['file_transfer_info_result']['file_transfer_info'], fields: %w[session_uuid file_id status path])
995
- when :modify
996
- request_data = options.get_next_argument('request data', mandatory: false, validation: Hash, default: {})
997
- request_data.deep_merge!(validation) unless validation.nil?
998
- @api_node.update('services/rest/transfers/v1/files', request_data)
999
- return Result::Status.new('updated')
1000
- end
1076
+ transfers_data.each do |t|
1077
+ next if t['avg_rate_kbps'].zero?
1078
+ next if t['start_time_usec'] >= end_date || t['end_time_usec'] <= start_date
1079
+ info = period_bandwidth[t['start_spec']['direction'].to_sym]
1080
+ info[:avg_kbps] += t['avg_rate_kbps']
1081
+ info[:sessions] += 1
1001
1082
  end
1002
- when :asperabrowser
1003
- browse_params = {
1004
- 'nodeUser' => options.get_option(:username, mandatory: true),
1005
- 'nodePW' => options.get_option(:password, mandatory: true),
1006
- 'nodeURL' => options.get_option(:url, mandatory: true)
1083
+ next if Transfer::Spec::DIRECTION_ENUM_VALUES.map(&:to_sym).all? { |dir| period_bandwidth[dir][:sessions].zero? }
1084
+ result.push({start: Time.at(start_date / 1_000_000), end: Time.at(end_date / 1_000_000)}.merge(period_bandwidth))
1085
+ end
1086
+ Result::ObjectList.new(result)
1087
+ end
1088
+
1089
+ # service sub-commands
1090
+ def action_service_create(creation_data:, **)
1091
+ resp = @api_node.create('rund/services', creation_data)
1092
+ Result::Status.new("#{resp['id']} created")
1093
+ end
1094
+
1095
+ def action_service_delete(service_id:, **)
1096
+ @api_node.delete("rund/services/#{service_id}")
1097
+ Result::Status.new("#{service_id} deleted")
1098
+ end
1099
+
1100
+ # central: shared helper
1101
+ def central_validation
1102
+ validator_id = options.get_option(:validator)
1103
+ validator_id ? {'validator_id' => validator_id} : nil
1104
+ end
1105
+
1106
+ # central > session > list
1107
+ def action_central_session_list(request_data:, **)
1108
+ request_data ||= {}
1109
+ validation = central_validation
1110
+ request_data.deep_merge!({'validation' => validation}) unless validation.nil?
1111
+ resp = @api_node.create('services/rest/transfers/v1/sessions', request_data)
1112
+ Result::ObjectList.new(resp['session_info_result']['session_info'], fields: %w[session_uuid status transport direction bytes_transferred])
1113
+ end
1114
+
1115
+ # central > file > list
1116
+ def action_central_file_list(request_data:, **)
1117
+ request_data ||= {}
1118
+ validation = central_validation
1119
+ request_data.deep_merge!({'validation' => validation}) unless validation.nil?
1120
+ resp = @api_node.create('services/rest/transfers/v1/files', request_data)
1121
+ resp = JSON.parse(resp) if resp.is_a?(String)
1122
+ Log.dump(:resp, resp)
1123
+ Result::ObjectList.new(resp['file_transfer_info_result']['file_transfer_info'], fields: %w[session_uuid file_id status path])
1124
+ end
1125
+
1126
+ # central > file > modify
1127
+ def action_central_file_modify(request_data:, **)
1128
+ request_data ||= {}
1129
+ validation = central_validation
1130
+ request_data.deep_merge!(validation) unless validation.nil?
1131
+ @api_node.update('services/rest/transfers/v1/files', request_data)
1132
+ Result::Status.new('updated')
1133
+ end
1134
+
1135
+ def action_asperabrowser
1136
+ browse_params = {
1137
+ 'nodeUser' => options.get_option(:username, mandatory: true),
1138
+ 'nodePW' => options.get_option(:password, mandatory: true),
1139
+ 'nodeURL' => options.get_option(:url, mandatory: true)
1140
+ }
1141
+ # encode parameters so that it looks good in url
1142
+ encoded_params = Base64.strict_encode64(Zlib::Deflate.deflate(JSON.generate(browse_params))).gsub(/=+$/, '').tr('+/', '-_').reverse
1143
+ Environment.instance.open_uri("#{options.get_option(:asperabrowserurl)}?goto=#{encoded_params}")
1144
+ return Result::Status.new('done')
1145
+ end
1146
+
1147
+ def action_bearer_token(private_key_pem:, user_group_id:, **)
1148
+ private_key = OpenSSL::PKey::RSA.new(private_key_pem)
1149
+ access_key = options.get_option(:username, mandatory: true)
1150
+ Result::Text.new(Api::Node.bearer_token(payload: user_group_id, access_key: access_key, private_key: private_key))
1151
+ end
1152
+
1153
+ def action_simulator(parameters: {}, **)
1154
+ require 'aspera/node_simulator'
1155
+ parameters = parameters.symbolize_keys
1156
+ uri = URI.parse(parameters.delete(:url) { WebServerSimple::DEFAULT_URL })
1157
+ server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
1158
+ server.mount(uri.path, NodeSimulatorServlet, parameters.except(*WebServerSimple::PARAMS), NodeSimulator.new)
1159
+ server.start
1160
+ return Result::Status.new('Simulator terminated')
1161
+ end
1162
+
1163
+ def action_telemetry(parameters: {}, **)
1164
+ parameters = parameters.symbolize_keys
1165
+ %i[url key].each do |psym|
1166
+ Aspera.assert(parameters.key?(psym), type: Cli::BadArgument) { "Missing parameter: #{psym}" }
1167
+ end
1168
+ require 'socket'
1169
+ parameters[:interval] = 10 unless parameters.key?(:interval)
1170
+ parameters[:hostname] = Socket.gethostname unless parameters.key?(:hostname)
1171
+ interval = parameters[:interval].to_f
1172
+ Aspera.assert(interval > 0, type: Cli::BadArgument) { 'Interval must be a positive number in seconds' }
1173
+ otel_api = Rest.new(
1174
+ base_url: "#{parameters[:url]}/v1",
1175
+ headers: {
1176
+ # 'Authorization' => "apiToken #{parameters[:key]}",
1177
+ 'x-instana-key' => parameters[:key],
1178
+ 'x-instana-host' => parameters[:hostname]
1007
1179
  }
1008
- # encode parameters so that it looks good in url
1009
- encoded_params = Base64.strict_encode64(Zlib::Deflate.deflate(JSON.generate(browse_params))).gsub(/=+$/, '').tr('+/', '-_').reverse
1010
- Environment.instance.open_uri("#{options.get_option(:asperabrowserurl)}?goto=#{encoded_params}")
1011
- return Result::Status.new('done')
1012
- when :basic_token
1013
- return Result::Text.new(Rest.basic_authorization(options.get_option(:username, mandatory: true), options.get_option(:password, mandatory: true)))
1014
- when :bearer_token
1015
- private_key = OpenSSL::PKey::RSA.new(options.get_next_argument('private RSA key PEM value', validation: String))
1016
- token_info = options.get_next_argument('user and group identification', validation: Hash)
1017
- access_key = options.get_option(:username, mandatory: true)
1018
- return Result::Text.new(Api::Node.bearer_token(payload: token_info, access_key: access_key, private_key: private_key))
1019
- when :simulator
1020
- require 'aspera/node_simulator'
1021
- parameters = value_create_modify(command: command, default: {}).symbolize_keys
1022
- uri = URI.parse(parameters.delete(:url){WebServerSimple::DEFAULT_URL})
1023
- server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
1024
- server.mount(uri.path, NodeSimulatorServlet, parameters.except(*WebServerSimple::PARAMS), NodeSimulator.new)
1025
- server.start
1026
- return Result::Status.new('Simulator terminated')
1027
- when :telemetry
1028
- parameters = value_create_modify(command: command, default: {}).symbolize_keys
1029
- %i[url key].each do |psym|
1030
- raise Cli::BadArgument, "Missing parameter: #{psym}" unless parameters.key?(psym)
1031
- end
1032
- require 'socket'
1033
- parameters[:interval] = 10 unless parameters.key?(:interval)
1034
- parameters[:hostname] = Socket.gethostname unless parameters.key?(:hostname)
1035
- interval = parameters[:interval].to_f
1036
- raise Cli::BadArgument, 'Interval must be a positive number in seconds' if interval <= 0
1037
- otel_api = Rest.new(
1038
- base_url: "#{parameters[:url]}/v1",
1039
- headers: {
1040
- # 'Authorization' => "apiToken #{parameters[:key]}",
1041
- 'x-instana-key' => parameters[:key],
1042
- 'x-instana-host' => parameters[:hostname]
1043
- }
1044
- )
1045
- datapoint = {
1046
- attributes: [
1047
- {
1048
- key: 'server.name',
1049
- value: {
1050
- stringValue: 'HSTS1'
1051
- }
1180
+ )
1181
+ datapoint = {
1182
+ attributes: [
1183
+ {
1184
+ key: 'server.name',
1185
+ value: {
1186
+ stringValue: 'HSTS1'
1052
1187
  }
1053
- ],
1054
- asInt: nil,
1055
- timeUnixNano: nil
1056
- }
1057
- # https://opentelemetry.io/docs/specs/otel/metrics/data-model/#gauge
1058
- metrics = {
1059
- resourceMetrics: [
1060
- {
1061
- resource: {
1062
- attributes: [
1188
+ }
1189
+ ],
1190
+ asInt: nil,
1191
+ timeUnixNano: nil
1192
+ }
1193
+ # https://opentelemetry.io/docs/specs/otel/metrics/data-model/#gauge
1194
+ metrics = {
1195
+ resourceMetrics: [
1196
+ {
1197
+ resource: {
1198
+ attributes: [
1199
+ {
1200
+ key: 'service.name',
1201
+ value: {
1202
+ stringValue: 'IBMAspera'
1203
+ }
1204
+ }
1205
+ ]
1206
+ },
1207
+ scopeMetrics: [
1208
+ {
1209
+ metrics: [
1063
1210
  {
1064
- key: 'service.name',
1065
- value: {
1066
- stringValue: 'IBMAspera'
1211
+ name: 'active.transfers',
1212
+ description: 'Number of active transfers',
1213
+ unit: '1',
1214
+ gauge: {
1215
+ dataPoints: [
1216
+ datapoint
1217
+ ]
1067
1218
  }
1068
1219
  }
1069
1220
  ]
1070
- },
1071
- scopeMetrics: [
1072
- {
1073
- metrics: [
1074
- {
1075
- name: 'active.transfers',
1076
- description: 'Number of active transfers',
1077
- unit: '1',
1078
- gauge: {
1079
- dataPoints: [
1080
- datapoint
1081
- ]
1082
- }
1083
- }
1084
- ]
1085
- }
1086
- ]
1087
- }
1088
- ]
1089
- }
1090
- loop do
1091
- timestamp = Time.now
1092
- transfers_data = @api_node.read_with_paging('ops/transfers', {active_only: true})
1093
- datapoint[:asInt] = transfers_data.length
1094
- datapoint[:timeUnixNano] = timestamp.to_i * 1_000_000_000 + timestamp.nsec
1095
- Log.log.info("#{datapoint[:asInt]} active transfers")
1096
- # https://www.ibm.com/docs/en/instana-observability/current?topic=instana-backend
1097
- otel_api.create('metrics', metrics)
1098
- break if interval.eql?(0.0)
1099
- sleep([0.0, interval - (Time.now - timestamp)].max)
1100
- end
1221
+ }
1222
+ ]
1223
+ }
1224
+ ]
1225
+ }
1226
+ loop do
1227
+ timestamp = Time.now
1228
+ transfers_data = @api_node.read_with_paging('ops/transfers', {active_only: true})
1229
+ datapoint[:asInt] = transfers_data.length
1230
+ datapoint[:timeUnixNano] = timestamp.to_i * 1_000_000_000 + timestamp.nsec
1231
+ Log.log.info("#{datapoint[:asInt]} active transfers")
1232
+ # https://www.ibm.com/docs/en/instana-observability/current?topic=instana-backend
1233
+ otel_api.create('metrics', metrics)
1234
+ break if interval.eql?(0.0)
1235
+ sleep([0.0, interval - (Time.now - timestamp)].max)
1101
1236
  end
1102
- Aspera.error_unreachable_line
1237
+ end
1238
+
1239
+ # Dispatch a Gen3 command that has already been read from the argument stream.
1240
+ # Used when a new Node instance is created by an external plugin (shares, faspex, cos, :v3)
1241
+ # and the command symbol was already consumed before the Node was instantiated.
1242
+ # Re-enters the DSL registry so Proc actions and intermediate nodes (sync, access_keys)
1243
+ # are resolved normally.
1244
+ # Pre-resolved CLI arguments (e.g. path:) can be forwarded via +ctx+ to avoid
1245
+ # re-consuming tokens that were already resolved by the calling plugin's DSL.
1246
+ # @param command [Symbol] command already consumed from the argument stream
1247
+ # @param ctx [Hash] pre-resolved context (keyword args forwarded to dispatch)
1248
+ # @return [Object] CLI result
1249
+ def dispatch_v3_command(command, **ctx)
1250
+ Aspera.assert_values(command, COMMANDS_GEN3 + %i[health events info slash license api_details access_keys transfer]) { 'v3 command' }
1251
+ dispatch_from_registry([command], ctx)
1103
1252
  end
1104
1253
 
1105
1254
  private
1106
1255
 
1107
1256
  # Response has key `paths`.
1108
1257
  # From those, check if there is an error
1109
- # @return [Array] of Hash with 2 keys: `path` and `result`
1258
+ # @return [Array<Hash>] list of hashes with 2 keys: `path` and `result`
1110
1259
  def response_to_result(response, success_msg)
1111
1260
  errors = []
1112
1261
  obj_list = []
1113
1262
  response['paths'].each do |p|
1114
1263
  result = success_msg
1115
1264
  if p.key?('error')
1116
- Log.log.error{"#{p['error']['user_message']} : #{p['path']}"}
1265
+ Log.log.error { "#{p['error']['user_message']} : #{p['path']}" }
1117
1266
  result = p['error']['user_message']
1118
1267
  errors.push([p['path'], p['error']['user_message']])
1119
1268
  end
1120
1269
  obj_list.push({'path' => p['path'], 'result' => result})
1121
1270
  end
1122
1271
  # one error make all fail
1123
- raise errors.map{ |i| "#{i.first}: #{i.last}"}.join(', ') unless errors.empty?
1272
+ raise errors.map { |i| "#{i.first}: #{i.last}" }.join(', ') unless errors.empty?
1124
1273
  obj_list
1125
1274
  end
1126
1275
 
1127
1276
  # Translates paths results into CLI result, and removes prefix
1128
1277
  def cli_result_from_paths_response(response, success_msg)
1129
1278
  obj_list = response_to_result(response, success_msg)
1130
- @node_path_prefix&.remove_in_object_list!(obj_list)
1131
1279
  return Result::ObjectList.new(obj_list, fields: %w[path result])
1132
1280
  end
1133
1281
  end