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
@@ -26,7 +26,7 @@ module Aspera
26
26
  login_page = Rest
27
27
  .new(base_url: url, redirect_max: 2)
28
28
  .read('', headers: {'Accept'=>'text/html'})
29
- raise 'not Shares' unless login_page.include?('aspera-Shares')
29
+ Aspera.assert(login_page.include?('aspera-Shares')) { 'not Shares' }
30
30
  if (m = login_page.match(/\(v([0-9a-f\.]+)\)/))
31
31
  version = m[1]
32
32
  if (m = login_page.match(/Patch level ([0-9]+)/))
@@ -91,129 +91,362 @@ module Aspera
91
91
 
92
92
  def initialize(**_)
93
93
  super
94
+ @api_shares_admin = nil
94
95
  end
95
96
 
96
97
  SAML_IMPORT_MANDATORY = %w[id name_id].freeze
97
- SAML_IMPORT_ALLOWED = %w[email given_name surname].concat(SAML_IMPORT_MANDATORY).freeze
98
+ SAML_IMPORT_ALLOWED = (%w[email given_name surname] + SAML_IMPORT_MANDATORY).freeze
98
99
 
99
- ACTIONS = %i[health info files admin].freeze
100
100
  # common to users and groups
101
101
  USR_GRP_SETTINGS = %i[transfer_settings app_authorizations share_permissions].freeze
102
+ # share_permissions is read-only (Rails only exposes index+show)
103
+ SHARE_PERMISSIONS_OPS = %i[list show].freeze
104
+ # group users: Rails only exposes index+show+update+destroy (no create)
105
+ GROUP_USERS_OPS = (Operations::ALL - %i[create]).freeze
102
106
 
103
- def execute_action
104
- command = options.get_next_command(ACTIONS, aliases: {repository: :files})
105
- case command
106
- when :health
107
- nagios = Nagios.new
108
- shares_url = options.get_option(:url, mandatory: true)
109
- health = self.class.health_check(shares_url)
110
- nagios.add_ok('version', health[:version]) if health[:version].is_a?(String)
111
- if health[:ping].is_a?(String)
112
- nagios.add_ok('ping', health[:ping])
113
- else
114
- nagios.add_critical('ping', health[:ping].to_s)
107
+ # --- DSL ---
108
+
109
+ command :health, description: 'Check Shares health'
110
+ command :info, description: 'Show server information', action: -> { Result::SingleObject.new(basic_auth_api(NODE_API_PATH).read('info', headers: {'Content-Type'=>'application/json'})) }
111
+ command :files, description: 'Browse and transfer files on Shares', aliases: [:repository], setup: :setup_shares_node
112
+ command :admin, description: 'Administer Shares', setup: :setup_admin
113
+
114
+ commands_under :admin do
115
+ commands_under :node do
116
+ crud_commands entity: 'data/nodes', api: :@api_shares_admin
117
+ end
118
+ commands_under :ldap_config, description: 'LDAP configuration' do
119
+ crud_commands entity: 'data/ldap_configs', api: :@api_shares_admin
120
+ end
121
+ command :share, description: 'Manage shares'
122
+ command :transfer_settings, description: 'Manage transfer settings'
123
+ command :user, description: 'Manage users'
124
+ command :group, description: 'Manage groups'
125
+ commands_under :smtp_server, description: 'SMTP server' do
126
+ crud_commands entity: 'data/smtp_server',
127
+ api: :@api_shares_admin,
128
+ is_singleton: true,
129
+ body_component: Schema::Registry::SHARES,
130
+ operations: %i[show create modify]
131
+ end
132
+ # user / group: sub-commands per location, generated for both entity types
133
+ %i[user group].each do |entity_type|
134
+ commands_under entity_type do
135
+ command :all, description: "#{entity_type.capitalize}s from all sources"
136
+ command :local, description: "Local #{entity_type}s"
137
+ command :ldap, description: "LDAP #{entity_type}s"
138
+ command :saml, description: "SAML #{entity_type}s"
139
+
140
+ # all: list/show/delete only (no create, no modify — users/groups/:all has no update route)
141
+ lookup_method_all = :"lookup_shares_#{entity_type}_all_id"
142
+ commands_under :all do
143
+ (Operations::ALL - %i[create modify]).each do |op|
144
+ id_args = Operations::GLOBAL.include?(op) ? {} : {arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_all}]}
145
+ command op, description: "#{op.capitalize} #{entity_type}s", **id_args
146
+ end
147
+ USR_GRP_SETTINGS.each do |setting|
148
+ # share_permissions: Rails only exposes index+show (read-only)
149
+ setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
150
+ command setting,
151
+ description: "Manage #{setting} for a #{entity_type}",
152
+ arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_all}]
153
+ commands_under setting do
154
+ setting_ops.each do |op|
155
+ # share_permissions > show needs a permission identifier (list does not)
156
+ perm_id_arg = if setting.eql?(:share_permissions) && op.eql?(:show)
157
+ [{name: :permission_id, type: :identifier, lookup: :lookup_share_id}]
158
+ else
159
+ []
160
+ end
161
+ command op,
162
+ description: "#{op.capitalize} #{setting} for a #{entity_type}",
163
+ arguments: perm_id_arg.empty? ? nil : perm_id_arg
164
+ end
165
+ end
166
+ end
167
+ if entity_type.eql?(:group)
168
+ command :users,
169
+ description: 'Manage users of a group',
170
+ arguments: [{name: :group_id, type: :identifier, lookup: :lookup_shares_group_all_id}]
171
+ commands_under :users do
172
+ GROUP_USERS_OPS.each do |op|
173
+ command op, description: "#{op.capitalize} users of a group"
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ # local: full CRUD only — no nested settings (Rails does not nest transfer_settings/app_authorizations/share_permissions under local_users/local_groups)
180
+ commands_under :local do
181
+ lookup_method_local = :"lookup_shares_#{entity_type}_local_id"
182
+ Operations::ALL.each do |op|
183
+ id_args = Operations::GLOBAL.include?(op) ? {} : {arguments: [{name: :"#{entity_type}_id", type: :identifier, lookup: lookup_method_local}]}
184
+ command op, description: "#{op.capitalize} #{entity_type}s", **id_args
185
+ end
186
+ if entity_type.eql?(:group)
187
+ command :users,
188
+ description: 'Manage users of a group',
189
+ arguments: [{name: :group_id, type: :identifier, lookup: :lookup_shares_group_local_id}]
190
+ commands_under :users do
191
+ GROUP_USERS_OPS.each do |op|
192
+ command op, description: "#{op.capitalize} users of a group"
193
+ end
194
+ end
195
+ end
196
+ end
197
+
198
+ # ldap: add only
199
+ commands_under :ldap do
200
+ command :add, description: "Add a LDAP #{entity_type}",
201
+ arguments: [{name: :entity_name, type: String, bulk: true}]
202
+ end
203
+
204
+ # saml: import only
205
+ commands_under :saml do
206
+ command :import, description: "Import a SAML #{entity_type}",
207
+ arguments: [{name: :entity_parameters, type: Hash, bulk: true}]
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ ENTITY_LOCATIONS = %i[all local].freeze
214
+ SHARE_DISPLAY_FIELDS = %w[id name node_id directory percent_free].freeze
215
+ private_constant :ENTITY_LOCATIONS, :SHARE_DISPLAY_FIELDS
216
+
217
+ # Lookup methods used by arguments:(:identifier, lookup:) on setting nodes.
218
+ # One method per entity_type/location combination; each returns the entity id.
219
+ %i[user group].each do |entity_type|
220
+ ENTITY_LOCATIONS.each do |location|
221
+ lookup_method = :"lookup_shares_#{entity_type}_#{location}_id"
222
+ define_method(lookup_method) do |field, value, **|
223
+ path = admin_entity_path(entity_type, location)
224
+ RestList.lookup_entity_generic(entity: entity_type, field: field, value: value) { @api_shares_admin.read(path) }['id']
115
225
  end
116
- if health[:api].is_a?(String)
117
- nagios.add_ok('API', health[:api])
118
- else
119
- nagios.add_critical('API', health[:api].to_s)
226
+ private lookup_method
227
+ end
228
+ end
229
+
230
+ commands_under %i[admin share] do
231
+ crud_commands entity: 'data/shares',
232
+ api: :@api_shares_admin,
233
+ display_fields: SHARE_DISPLAY_FIELDS,
234
+ body_component: Schema::Registry::SHARES,
235
+ lookup: :lookup_share_id
236
+ command :user_permissions,
237
+ description: 'Manage user permissions on a share',
238
+ arguments: [{name: :share_id, type: :identifier, lookup: :lookup_share_id}]
239
+ command :group_permissions,
240
+ description: 'Manage group permissions on a share',
241
+ arguments: [{name: :share_id, type: :identifier, lookup: :lookup_share_id}]
242
+ end
243
+
244
+ # share user_permissions and group_permissions: Rails only exposes index+show (read-only)
245
+ %i[user_permissions group_permissions].each do |perm_type|
246
+ commands_under [:admin, :share, perm_type] do
247
+ SHARE_PERMISSIONS_OPS.each do |op|
248
+ command op, description: "#{op.capitalize} #{perm_type}"
120
249
  end
121
- Result::ObjectList.new(nagios.status_list)
122
- when :info
123
- return Result::SingleObject.new(basic_auth_api(NODE_API_PATH).read('info', headers: {'Content-Type'=>'application/json'}))
124
- when :files
125
- api_shares_node = basic_auth_api(NODE_API_PATH)
126
- repo_command = options.get_next_command(Node::COMMANDS_SHARES)
127
- return Node
128
- .new(context: context, api: api_shares_node)
129
- .execute_action(repo_command)
130
- when :admin
131
- api_shares_admin = basic_auth_api(ADMIN_API_PATH)
132
- admin_command = options.get_next_command(%i[node share transfer_settings user group].freeze)
133
- lookup_share = ->(field, value){RestList.lookup_entity_generic(entity: 'share', field: field, value: value){api_shares_admin.read('data/shares')}['id']}
134
- case admin_command
135
- when :node
136
- return entity_execute(api: api_shares_admin, entity: 'data/nodes')
137
- when :share
138
- share_command = options.get_next_command(%i[user_permissions group_permissions].concat(Operations::ALL))
139
- case share_command
140
- when *Operations::ALL
141
- return entity_execute(
142
- api: api_shares_admin,
143
- entity: 'data/shares',
144
- command: share_command,
145
- display_fields: %w[id name node_id directory percent_free],
146
- &lookup_share
147
- )
148
- when :user_permissions, :group_permissions
149
- share_id = options.instance_identifier(&lookup_share)
150
- return entity_execute(api: api_shares_admin, entity: "data/shares/#{share_id}/#{share_command}")
151
- end
152
- when :transfer_settings
153
- xfer_settings_command = options.get_next_command(%i[show modify])
154
- return entity_execute(
155
- api: api_shares_admin,
156
- entity: 'data/transfer_settings',
157
- command: xfer_settings_command,
158
- is_singleton: true
159
- )
160
- when :user, :group
161
- entity_type = admin_command
162
- entities_location = options.get_next_command(%i[all local ldap saml])
163
- entities_prefix = entities_location.eql?(:all) ? '' : "#{entities_location}_"
164
- entities_path = "data/#{entities_prefix}#{entity_type}s"
165
- entity_commands = nil
166
- case entities_location
167
- when :all
168
- entity_commands = %i[list show delete]
169
- entity_commands.concat(USR_GRP_SETTINGS)
170
- entity_commands.push(:users) if entity_type.eql?(:group)
171
- entity_commands.freeze
172
- when :local
173
- entity_commands = %i[list show delete create modify]
174
- entity_commands.push(:users) if entity_type.eql?(:group)
175
- entity_commands.freeze
176
- when :ldap
177
- entity_commands = %i[add].freeze
178
- when :saml
179
- entity_commands = %i[import].freeze
250
+ end
251
+ end
252
+
253
+ commands_under %i[admin transfer_settings] do
254
+ crud_commands entity: 'data/transfer_settings',
255
+ api: :@api_shares_admin,
256
+ is_singleton: true,
257
+ operations: %i[show modify]
258
+ end
259
+
260
+ # --- setup ---
261
+
262
+ # Build the Shares node API plugin and inject into ctx.
263
+ # @return [Hash] context hash containing :shares_node_plugin
264
+ def setup_shares_node(**)
265
+ api_shares_node = basic_auth_api(NODE_API_PATH)
266
+ {shares_node_plugin: Node.new(context: context, api: api_shares_node)}
267
+ end
268
+
269
+ # Build the admin REST API and store in ivar.
270
+ # @return [Hash] empty ctx (state stored in @api_shares_admin)
271
+ def setup_admin(**)
272
+ @api_shares_admin = basic_auth_api(ADMIN_API_PATH)
273
+ {}
274
+ end
275
+
276
+ # Lookup a share id by field/value using the admin API.
277
+ def lookup_share_id(field, value, **)
278
+ RestList.lookup_entity_generic(entity: 'share', field: field, value: value) { @api_shares_admin.read('data/shares') }['id']
279
+ end
280
+
281
+ # --- health ---
282
+
283
+ def action_health
284
+ nagios = Nagios.new
285
+ shares_url = options.get_option(:url, mandatory: true)
286
+ health = self.class.health_check(shares_url)
287
+ nagios.add_ok('version', health[:version]) if health[:version].is_a?(String)
288
+ if health[:ping].is_a?(String)
289
+ nagios.add_ok('ping', health[:ping])
290
+ else
291
+ nagios.add_critical('ping', health[:ping].to_s)
292
+ end
293
+ if health[:api].is_a?(String)
294
+ nagios.add_ok('API', health[:api])
295
+ else
296
+ nagios.add_critical('API', health[:api].to_s)
297
+ end
298
+ Result::ObjectList.new(nagios.status_list)
299
+ end
300
+
301
+ # --- files sub-commands (restricted to COMMANDS_SHARES, delegated to Node) ---
302
+
303
+ commands_under :files do
304
+ Node::COMMANDS_SHARES.each do |cmd|
305
+ next if cmd.eql?(:sync) # intermediate node with sub-commands, declared separately below
306
+ spec = Node::COMMANDS_GEN3_SPEC[cmd] || {description: "Node #{cmd} command"}
307
+ command cmd, **spec
308
+ end
309
+ command :sync, description: 'Synchronize folders (Gen3)'
310
+ commands_under :sync do
311
+ Sync::Operations::DIRECTIONS.each do |dir|
312
+ command dir, description: "#{dir.capitalize}-sync (Gen3)", transfer_paths: :send, arguments: SyncActions::PATH_AND_INFO_ARGS
313
+ end
314
+ command :admin, description: 'Manage sync database (admin operations)'
315
+ SyncActions.register_sync_admin_commands(self, :admin)
316
+ end
317
+ end
318
+
319
+ # One handler per COMMANDS_SHARES command (except :sync which is an intermediate node).
320
+ Node::COMMANDS_SHARES.reject { |cmd| cmd.eql?(:sync) }.each do |cmd|
321
+ define_action_method([:files, cmd]) do |shares_node_plugin:, **ctx|
322
+ shares_node_plugin.dispatch_v3_command(cmd, **ctx)
323
+ end
324
+ end
325
+
326
+ # Handlers for files > sync > <direction>: delegate to Node's [:sync, dir] leaf.
327
+ Sync::Operations::DIRECTIONS.each do |dir|
328
+ define_action_method([:files, :sync, dir]) do |shares_node_plugin:, **ctx|
329
+ shares_node_plugin.dispatch_from_registry([:sync, dir], ctx, skip_setup: true)
330
+ end
331
+ end
332
+ # Handlers for files > sync > admin > <op>: delegate to Node's [:sync, :admin, op] leaf.
333
+ SyncActions::ADMIN_COMMANDS.each do |op|
334
+ define_action_method([:files, :sync, :admin, op]) do |shares_node_plugin:, **ctx|
335
+ shares_node_plugin.dispatch_from_registry([:sync, :admin, op], ctx, skip_setup: true)
336
+ end
337
+ end
338
+
339
+ private
340
+
341
+ # @return [String] admin API path for the given entity_type and location
342
+ def admin_entity_path(entity_type, location)
343
+ prefix = location.eql?(:all) ? '' : "#{location}_"
344
+ "data/#{prefix}#{entity_type}s"
345
+ end
346
+
347
+ # --- admin > user|group handlers ---
348
+
349
+ # Shared handler for list/show/delete/create/modify on user or group.
350
+ # @param entity_type [Symbol] :user or :group
351
+ # @param location [Symbol] :all or :local
352
+ # @param op [Symbol] CRUD operation
353
+ def action_admin_entity_crud(entity_type, location, op)
354
+ path = admin_entity_path(entity_type, location)
355
+ lookup = ->(f, v) { RestList.lookup_entity_generic(entity: entity_type, field: f, value: v) { @api_shares_admin.read(path) }['id'] }
356
+ display_fields = entity_type.eql?(:user) ? %w[id user_id username first_name last_name email] : nil
357
+ display_fields&.push('directory_user') if entity_type.eql?(:user) && location.eql?(:all)
358
+ # :all excludes :create; :local has a documented POST+PUT requestBody for both users and groups
359
+ body_component = location.eql?(:local) ? Schema::Registry::SHARES : nil
360
+ id = Operations::INSTANCE.include?(op) ? options.instance_identifier(&lookup) : nil
361
+ send(:"entity_#{op}", api: @api_shares_admin, entity: path, id: id, display_fields: display_fields, body_component: body_component)
362
+ end
363
+
364
+ # Shared handler for USR_GRP_SETTINGS (transfer_settings, app_authorizations, share_permissions)
365
+ # @param entity_type [Symbol] :user or :group
366
+ # @param location [Symbol] :all or :local
367
+ # @param setting [Symbol] one of USR_GRP_SETTINGS
368
+ # @param op [Symbol] CRUD operation
369
+ # entity_id: resolved via arguments:(:identifier) on each setting leaf command
370
+ def action_admin_entity_setting(entity_type, location, setting, op, entity_id:, permission_id: nil, **)
371
+ path = admin_entity_path(entity_type, location)
372
+ entity_path = "#{path}/#{entity_id}/#{setting}"
373
+ is_singleton = !setting.eql?(:share_permissions)
374
+ id = permission_id || (Operations::INSTANCE.include?(op) && !is_singleton ? options.instance_identifier { |f, v| lookup_share_id(f, v) } : nil)
375
+ send(:"entity_#{op}", api: @api_shares_admin, entity: entity_path, id: id, is_singleton: is_singleton)
376
+ end
377
+
378
+ # Shared handler for :users (group only)
379
+ # group_id: resolved by Phase A via arguments:(:identifier) on the :users node
380
+ def action_admin_entity_users(entity_type, location, op, group_id:, **)
381
+ path = admin_entity_path(entity_type, location)
382
+ prefix = location.eql?(:all) ? '' : "#{location}_"
383
+ id = Operations::INSTANCE.include?(op) ? options.instance_identifier : nil
384
+ send(:"entity_#{op}", api: @api_shares_admin, entity: "#{path}/#{group_id}/#{prefix}users", id: id)
385
+ end
386
+
387
+ # Generate action_admin_<user|group>_<location>_<verb> for all combinations
388
+ %i[user group].each do |entity_type|
389
+ # all: list/show/delete only (no create, no modify — no update route on /users and /groups)
390
+ # local: full CRUD only — no nested settings under local_users/local_groups
391
+ ENTITY_LOCATIONS.each do |location|
392
+ ops = location.eql?(:all) ? (Operations::ALL - %i[create modify]) : Operations::ALL
393
+ ops.each do |op|
394
+ define_action_method([:admin, entity_type, location, op]) do
395
+ action_admin_entity_crud(entity_type, location, op)
180
396
  end
181
- entity_verb = options.get_next_command(entity_commands)
182
- lookup_block = ->(field, value){RestList.lookup_entity_generic(entity: entity_type, field: field, value: value){api_shares_admin.read(entities_path)}['id']}
183
- case entity_verb
184
- when *Operations::ALL
185
- display_fields = entity_type.eql?(:user) ? %w[id user_id username first_name last_name email] : nil
186
- display_fields.push('directory_user') if entity_type.eql?(:user) && entities_location.eql?(:all)
187
- return entity_execute(
188
- api: api_shares_admin,
189
- entity: entities_path,
190
- command: entity_verb,
191
- display_fields: display_fields,
192
- &lookup_block
193
- )
194
- when *USR_GRP_SETTINGS # transfer_settings, app_authorizations, share_permissions
195
- group_id = options.instance_identifier(&lookup_block)
196
- entities_path = "#{entities_path}/#{group_id}/#{entity_verb}"
197
- return entity_execute(api: api_shares_admin, entity: entities_path, is_singleton: !entity_verb.eql?(:share_permissions), &lookup_share)
198
- when :import # saml
199
- return do_bulk_operation(command: entity_verb, descr: 'user information') do |entity_parameters|
200
- entity_parameters = entity_parameters.transform_keys{ |k| k.gsub(/\s+/, '_').downcase}
201
- Aspera.assert_type(entity_parameters, Hash)
202
- SAML_IMPORT_MANDATORY.each{ |p| raise "missing mandatory field: #{p}" if entity_parameters[p].nil?}
203
- entity_parameters.each_key do |p|
204
- raise "unsupported field: #{p}, use: #{SAML_IMPORT_ALLOWED.join(',')}" unless SAML_IMPORT_ALLOWED.include?(p)
397
+ end
398
+ unless location.eql?(:local)
399
+ USR_GRP_SETTINGS.each do |setting|
400
+ # share_permissions: Rails only exposes index+show (read-only)
401
+ setting_ops = setting.eql?(:share_permissions) ? SHARE_PERMISSIONS_OPS : %i[show modify]
402
+ setting_ops.each do |op|
403
+ define_action_method([:admin, entity_type, location, setting, op]) do |**kwargs|
404
+ action_admin_entity_setting(entity_type, location, setting, op, entity_id: kwargs[:"#{entity_type}_id"], permission_id: kwargs[:permission_id])
205
405
  end
206
- api_shares_admin.create("#{entities_path}/import", entity_parameters)
207
- end
208
- when :add # ldap
209
- return do_bulk_operation(command: entity_verb, descr: "#{entity_type} name", values: String) do |entity_name|
210
- api_shares_admin.create(entities_path, {entity_type=>entity_name})
211
406
  end
212
- when :users # group
213
- return entity_execute(api: api_shares_admin, entity: "#{entities_path}/#{options.instance_identifier(&lookup_block)}/#{entities_prefix}users")
214
- else Aspera.error_unexpected_value(entity_verb)
215
407
  end
216
408
  end
409
+ next unless entity_type.eql?(:group)
410
+ # group users: no create route (Rails only exposes index+show+update+destroy)
411
+ GROUP_USERS_OPS.each do |op|
412
+ define_action_method([:admin, entity_type, location, :users, op]) do |group_id:, **|
413
+ action_admin_entity_users(entity_type, location, op, group_id: group_id)
414
+ end
415
+ end
416
+ end
417
+
418
+ # ldap: add
419
+ define_action_method([:admin, entity_type, :ldap, :add]) do |entity_name:, **|
420
+ path = admin_entity_path(entity_type, :ldap)
421
+ bulk_result(entity_name, command: :add, id_result: entity_type.to_s) do |entity_name|
422
+ @api_shares_admin.create(path, {entity_type => entity_name})
423
+ end
424
+ end
425
+
426
+ # saml: import
427
+ define_action_method([:admin, entity_type, :saml, :import]) do |entity_parameters:, **|
428
+ path = admin_entity_path(entity_type, :saml)
429
+ bulk_result(entity_parameters, command: :import) do |entity_parameters|
430
+ entity_parameters = entity_parameters.transform_keys { |k| k.gsub(/\s+/, '_').downcase }
431
+ Aspera.assert_type(entity_parameters, Hash)
432
+ SAML_IMPORT_MANDATORY.each { |p| raise "missing mandatory field: #{p}" if entity_parameters[p].nil? }
433
+ entity_parameters.each_key do |p|
434
+ Aspera.assert_values(p, SAML_IMPORT_ALLOWED) { 'SAML field' }
435
+ end
436
+ @api_shares_admin.create("#{path}/import", entity_parameters)
437
+ end
438
+ end
439
+ end
440
+
441
+ # Handlers for admin > share > user_permissions|group_permissions > op
442
+ # share_id: resolved by Phase A via arguments:(:identifier, lookup: :lookup_share_id) on the perm_type node
443
+ # Rails only exposes index+show for share permissions (read-only)
444
+ %i[user_permissions group_permissions].each do |perm_type|
445
+ SHARE_PERMISSIONS_OPS.each do |op|
446
+ define_action_method([:admin, :share, perm_type, op]) do |share_id:, **|
447
+ id = Operations::INSTANCE.include?(op) ? options.instance_identifier : nil
448
+ send(:"entity_#{op}", api: @api_shares_admin, entity: "data/shares/#{share_id}/#{perm_type}", id: id)
449
+ end
217
450
  end
218
451
  end
219
452
  end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/assert'
4
+
5
+ module Aspera
6
+ module Cli
7
+ # Mixin for Config plugin: preset CRUD actions
8
+ module PresetActions
9
+ # Used to identify the global default preset keyword
10
+ GLOBAL_DEFAULT_KEYWORD = 'GLOBAL'
11
+ # Display columns for preset overview
12
+ CONF_OVERVIEW_KEYS = %w[preset parameter value].freeze
13
+
14
+ def action_preset_list(**)
15
+ Result::ValueList.new(presets.config_presets.keys, name: 'name')
16
+ end
17
+
18
+ def action_preset_overview(**)
19
+ cp = presets.config_presets
20
+ # Display process modifies the value (hide secrets): we do not want to save removed secrets
21
+ data = PresetManager.deep_clone(cp)
22
+ formatter.hide_secrets(data)
23
+ result = []
24
+ data.each do |config, preset|
25
+ preset.each do |parameter, value|
26
+ result.push(CONF_OVERVIEW_KEYS.zip([config, parameter, value]).to_h)
27
+ end
28
+ end
29
+ Result::ObjectList.new(result, fields: CONF_OVERVIEW_KEYS)
30
+ end
31
+
32
+ def action_preset_lookup(**)
33
+ Plugins::BasicAuth.declare_options(options, parse: true)
34
+ url = options.get_option(:url, mandatory: true)
35
+ user = options.get_option(:username, mandatory: true)
36
+ result = presets.lookup_preset(url: url, username: user)
37
+ Aspera.assert(!result.nil?, type: Error) { 'no such config found' }
38
+ Result::SingleObject.new(result)
39
+ end
40
+
41
+ def action_preset_secure(config_name: nil, **)
42
+ cp = presets.config_presets
43
+ preset_names = config_name.nil? ? cp.keys : [config_name]
44
+ preset_names.each do |preset_name|
45
+ preset = cp[preset_name]
46
+ next unless preset.is_a?(Hash)
47
+ preset.each_key do |option_name|
48
+ secure_preset_option(preset, preset_name, option_name)
49
+ end
50
+ end
51
+ Result::Status.new('Secrets secured in vault: Make sure to save the vault password securely.')
52
+ end
53
+
54
+ private
55
+
56
+ SECRET_KEYWORDS = %w[password secret].freeze
57
+
58
+ # If +option_name+ ends with a secret keyword and the vault is configured,
59
+ # move the clear-text value into the vault and replace it with a @vault: reference.
60
+ # @param preset [Hash] the preset hash (modified in place)
61
+ # @param preset_name [String] name used as base for the vault label
62
+ # @param option_name [String] option key to inspect
63
+ def secure_preset_option(preset, preset_name, option_name)
64
+ return unless SECRET_KEYWORDS.any? { |kw| option_name.end_with?(kw) }
65
+ # Never auto-secure the global preset: it holds vault credentials themselves
66
+ return if preset_name.eql?(presets.global_default_preset)
67
+ return if vault.nil?
68
+ value = preset[option_name]
69
+ # Already a vault reference or nil — nothing to do
70
+ return if value.nil? || value.to_s.start_with?('@vault:')
71
+ vault_label = preset_name
72
+ incr = 0
73
+ until vault.get(label: vault_label, exception: false).nil?
74
+ vault_label = "#{preset_name}#{incr}"
75
+ incr += 1
76
+ end
77
+ Log.log.info { "Securing #{preset_name}.#{option_name} -> vault label: #{vault_label}" }
78
+ vault.set({label: vault_label, password: value})
79
+ preset[option_name] = "@vault:#{vault_label}.password"
80
+ end
81
+
82
+ public
83
+
84
+ def action_preset_show(name:, **)
85
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
86
+ cp = presets.config_presets
87
+ raise "no such preset: #{name}" unless cp.key?(name)
88
+ Result::SingleObject.new(PresetManager.deep_clone(cp[name]))
89
+ end
90
+
91
+ def action_preset_delete(name:, **)
92
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
93
+ cp = presets.config_presets
94
+ raise "no such preset: #{name}" unless cp.key?(name)
95
+ cp.delete(name)
96
+ Result::Status.new("Deleted: #{name}")
97
+ end
98
+
99
+ def action_preset_get(name:, param_name:, **)
100
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
101
+ cp = presets.config_presets
102
+ raise "no such preset: #{name}" unless cp.key?(name)
103
+ value = cp[name][param_name]
104
+ raise "no such option in preset #{name} : #{param_name}" if value.nil?
105
+ case value
106
+ when Numeric, String then return Result::Text.new(ExtendedValue.instance.evaluate(value.to_s, context: 'preset'))
107
+ end
108
+ Result::SingleObject.new(value)
109
+ end
110
+
111
+ def action_preset_unset(name:, param_name:, **)
112
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
113
+ cp = presets.config_presets
114
+ raise "no such preset: #{name}" unless cp.key?(name)
115
+ cp[name].delete(param_name)
116
+ Result::Status.new("Removed: #{name}: #{param_name}")
117
+ end
118
+
119
+ def action_preset_set(name:, param_name:, param_value:, **)
120
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
121
+ param_name = Parser.option_line_to_name(param_name)
122
+ presets.set_key(name, param_name, Parser.smart_convert(param_value))
123
+ secure_preset_option(presets.config_presets[name], name, param_name)
124
+ Result::Nothing.new
125
+ end
126
+
127
+ def action_preset_initialize(name:, config_value:, **)
128
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
129
+ cp = presets.config_presets
130
+ Log.log.warn { "configuration already exists: #{name}, overwriting" } if cp.key?(name)
131
+ cp[name] = config_value
132
+ Result::Status.new("Modified: #{@option_config_file}")
133
+ end
134
+
135
+ def action_preset_update(name:, **)
136
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
137
+ unprocessed_options = options.unprocessed_options_with_value
138
+ Log.dump(:opts, unprocessed_options)
139
+ cp = presets.config_presets
140
+ cp[name] ||= {}
141
+ cp[name].merge!(unprocessed_options)
142
+ unprocessed_options.each_key { |k| secure_preset_option(cp[name], name, k) }
143
+ Result::Status.new("Updated: #{name}")
144
+ end
145
+
146
+ def action_preset_ask(name:, option_names:, **)
147
+ name = presets.global_default_preset if name.eql?(GLOBAL_DEFAULT_KEYWORD)
148
+ cp = presets.config_presets
149
+ cp[name] ||= {}
150
+ option_names.each do |option_name|
151
+ option_value = options.get_interactive(option_name, check_option: true)
152
+ cp[name][option_name] = option_value
153
+ secure_preset_option(cp[name], name, option_name)
154
+ end
155
+ Result::Status.new("Updated: #{name}")
156
+ end
157
+ end
158
+ end
159
+ end