aspera-cli 4.27.5 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. data/lib/aspera/web_auth/index.html +0 -147
@@ -0,0 +1,561 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+ require 'rexml/document'
5
+ require 'stringio'
6
+ require 'zlib'
7
+ require 'rubygems/package'
8
+
9
+ module Aspera
10
+ # Check of an Orchestrator workflow file (`.yml`) or package (`.wkf`), before import.
11
+ #
12
+ # Orchestrator 4.1.6 does not validate the graph on import (`import_workflow`, `import_with_constraints`): an invalid workflow is imported,
13
+ # then fails to publish, or at run time. These checks follow the rules of the designer (`Whiteboard#parse_workflow`).
14
+ # With the specifications of plugins (`plugins.yml` of the plugin documentation), action templates and inputs of steps are also checked:
15
+ # attributes and their values, required inputs, inputs and outputs that the plugin does not have with its template.
16
+ # Not checked: availability of plugins on the server.
17
+ #
18
+ # The interface is that of a REST operation, so that it can be replaced by a call to Orchestrator when one exists:
19
+ # - request: the fields of `import_workflow`: `file_name` (`import_file_name`) and `content` (`import_file`, bytes),
20
+ # and the specifications of plugins (`specs`), that Orchestrator would not need
21
+ # - response: a Hash (JSON compatible)
22
+ # `valid`: `true` if there is no issue with severity `error`
23
+ # `file_name`: name of the checked file
24
+ # `issues`: list of Hash
25
+ # `severity`: `error` (the workflow will not import, publish or run correctly) or `warning`
26
+ # `code`: stable identifier of the rule, for programs, e.g. `graph.step_orphan`
27
+ # `file`: file of the package where the issue is (absent for a single `.yml` file)
28
+ # `path`: where in the file, e.g. `graph/Step[Say hello]`
29
+ # `message`: description for humans
30
+ #
31
+ # The file is parsed without instantiating Ruby objects.
32
+ class OrchestratorWorkflowChecker
33
+ ERROR = 'error'
34
+ WARNING = 'warning'
35
+ # Columns of tables `workflows` and `whiteboards` (Orchestrator 4.1.6), and attributes ignored by the import
36
+ WORKFLOW_ATTRIBUTES = %w[
37
+ id name portable_id created_by folder_id generated_from revision_id label run_as source_revision comments description tags templates
38
+ max_running cleanup_after_days purge_after_days user_lock locked_by created_at updated_at global_variables hide_workflow
39
+ ].freeze
40
+ WHITEBOARD_ATTRIBUTES = %w[id created_by revision_id workflow_id error_free revisions_log xml_export created_at updated_at].freeze
41
+ VALUE_TYPES = %w[string int float flag date array hash object pwd attachment].freeze
42
+ # Element 6 (plugin versions) is informational: Orchestrator 4.1.6 only reads elements 1 to 5
43
+ ELEMENT_COUNTS = [5, 6].freeze
44
+ TRUE_VALUES = %w[true 1 t].freeze
45
+ UNNAMED = ['', 'undefined', 'null'].freeze
46
+ SUCCESS_STATUSES = %w[complete true].freeze
47
+ BOOLEAN_TEXT = %w[true false].freeze
48
+ STEP_CHILDREN = %w[Inputs Outputs].freeze
49
+ PARAMETER_MAPPINGS = %w[parameter global].freeze
50
+ # Columns of every action template, besides those of the plugin
51
+ TEMPLATE_COLUMNS = %w[id name comments created_at updated_at].freeze
52
+ # Texts that the database layer (ActiveRecord) reads as false
53
+ FALSE_VALUES = %w[false 0 f off].freeze
54
+ INTEGER_TYPES = %w[integer bigint].freeze
55
+ # Template attribute with Ruby code that computes the inputs or the outputs: they are then unknown
56
+ SPEC_CODE = {inputs: 'inputs_spec_code', outputs: 'outputs_spec_code'}.freeze
57
+ # Note of a plugin specification: some inputs or outputs come from the template, other than through declared lists
58
+ PARTLY_DEFINED = {inputs: /\AInputs are partly defined/, outputs: /\AOutputs are partly defined/}.freeze
59
+ # Placeholder that creates a required input: <%= variable %>
60
+ PLACEHOLDER = /<%=\s*([A-Za-z_]\w*)\s*%>/
61
+ # Value of a template attribute that is a single name (of an input or an output): no line break, no structure (type map, list)
62
+ SINGLE_NAME = /\A[^\r\n{}\[\],:=]+\z/
63
+ # Column separator of a table (attribute `parameters_associations` of `SimpleParameterLookup`), whose first line holds the column names
64
+ TABLE_SEPARATOR = ':::'
65
+ # Elements of a step that a `Map_to` of type `output` gives as attributes (in lower case)
66
+ STEP_IDENTITY = %w[Step_name Step_type Action_id].freeze
67
+ private_constant :WORKFLOW_ATTRIBUTES, :WHITEBOARD_ATTRIBUTES, :VALUE_TYPES, :ELEMENT_COUNTS, :TRUE_VALUES, :UNNAMED, :SUCCESS_STATUSES,
68
+ :BOOLEAN_TEXT, :STEP_CHILDREN, :PARAMETER_MAPPINGS, :TEMPLATE_COLUMNS, :FALSE_VALUES, :INTEGER_TYPES, :SPEC_CODE, :PARTLY_DEFINED, :PLACEHOLDER,
69
+ :STEP_IDENTITY, :SINGLE_NAME, :TABLE_SEPARATOR
70
+
71
+ class << self
72
+ # @param file_name [String] Name of the file, ending with `yml` or `wkf`
73
+ # @param content [String] Bytes of the file
74
+ # @param specs [Hash, nil] Specifications of plugins (`plugins.yml`), to check templates and inputs of steps, or nil
75
+ # @return [Hash] Response, see class description
76
+ def check(file_name:, content:, specs: nil)
77
+ checker = new(file_name, specs)
78
+ checker.run(content)
79
+ {
80
+ 'valid' => checker.issues.none? { |issue| issue['severity'].eql?(ERROR) },
81
+ 'file_name' => file_name,
82
+ 'issues' => checker.issues
83
+ }
84
+ end
85
+ end
86
+
87
+ attr_reader :issues
88
+
89
+ # @param file_name [String] Name of the file
90
+ # @param specs [Hash, nil] Specifications of plugins: class name => attributes, inputs, outputs
91
+ def initialize(file_name, specs = nil)
92
+ @file_name = file_name
93
+ @specs = specs
94
+ @issues = []
95
+ # file of the package being checked
96
+ @member = nil
97
+ # attributes of the action templates of the workflow being checked, by key <StepType>__<N>
98
+ @template_attributes = {}
99
+ end
100
+
101
+ # @param content [String] Bytes of the file
102
+ def run(content)
103
+ case File.extname(@file_name)
104
+ when '.yml' then check_yaml(content)
105
+ when '.wkf' then check_package(content)
106
+ else add(ERROR, 'file.extension', 'file', "extension must be yml or wkf: #{@file_name}")
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def add(severity, code, path, message)
113
+ @issues.push({'severity' => severity, 'code' => code, 'file' => @member, 'path' => path, 'message' => message}.compact)
114
+ end
115
+
116
+ # @param content [String] Bytes of a `.wkf` file: gzipped tar with a manifest (list of files, the first one is the main workflow)
117
+ def check_package(content)
118
+ files = {}
119
+ begin
120
+ Zlib::GzipReader.wrap(StringIO.new(content)) do |gzip|
121
+ Gem::Package::TarReader.new(gzip) do |tar|
122
+ tar.each { |entry| files[entry.full_name] = entry.read if entry.file? }
123
+ end
124
+ end
125
+ rescue Zlib::Error, Gem::Package::Error => e
126
+ return add(ERROR, 'package.archive', 'file', "not a gzipped tar archive: #{e.message}")
127
+ end
128
+ manifest = files.keys.find { |name| File.basename(name).match?(/\AWorkflow_.*\.txt\z/) }
129
+ return add(ERROR, 'package.manifest_missing', 'file', 'no manifest file (Workflow_*.txt) in package') if manifest.nil?
130
+ list = Psych.safe_load(files[manifest])
131
+ return add(ERROR, 'package.manifest_invalid', manifest, 'manifest must be a list of file names') unless list.is_a?(Array) && !list.empty? && list.all?(String)
132
+ list.each_with_index do |name, index|
133
+ member = files.find { |key, _| File.basename(key).eql?(File.basename(name)) }&.last
134
+ next add(ERROR, 'package.file_missing', manifest, "file listed but not in package: #{name}") if member.nil?
135
+ # sub-workflows are checked as the main workflow; remote nodes and nested packages are not
136
+ next unless name.end_with?('.yml') && (index.zero? || File.basename(name).start_with?('Workflow_'))
137
+ @member = File.basename(name)
138
+ check_yaml(member)
139
+ end
140
+ @member = nil
141
+ rescue Psych::Exception => e
142
+ add(ERROR, 'package.manifest_invalid', manifest.to_s, "invalid manifest: #{e.message}")
143
+ end
144
+
145
+ # @param content [String] Bytes of a workflow file
146
+ def check_yaml(content)
147
+ text = content.dup.force_encoding(Encoding::UTF_8)
148
+ return add(ERROR, 'yaml.encoding', 'file', 'file is not UTF-8') unless text.valid_encoding?
149
+ begin
150
+ root = Psych.parse_stream(text).children.first&.root
151
+ rescue Psych::SyntaxError => e
152
+ return add(ERROR, 'yaml.invalid', 'file', "invalid YAML: #{e.message}")
153
+ end
154
+ return add(ERROR, 'yaml.empty', 'file', 'empty file') if root.nil?
155
+ return add(ERROR, 'yaml.not_list', 'file', 'a workflow file is a YAML list of elements') unless root.is_a?(Psych::Nodes::Sequence)
156
+ @plain = text.lines.none? { |line| line.match?(/^ *concise_attributes:/) }
157
+ @template_attributes = {}
158
+ check_layout(text) if @plain
159
+ elements = root.children
160
+ add(ERROR, 'yaml.element_count', 'file', "expected #{ELEMENT_COUNTS.join(' or ')} elements (workflow, whiteboard, templates, dependencies, packed dependencies, plugin versions), found #{elements.size}") unless ELEMENT_COUNTS.include?(elements.size)
161
+ return if elements.size < 2
162
+ workflow = check_attributes(elements[0], 'Workflow', 'workflow', WORKFLOW_ATTRIBUTES)
163
+ add(ERROR, 'workflow.name_missing', 'workflow', 'attribute name is missing') if workflow['name'].to_s.strip.empty?
164
+ whiteboard = check_attributes(elements[1], 'Whiteboard', 'whiteboard', WHITEBOARD_ATTRIBUTES)
165
+ add(ERROR, 'whiteboard.error_free', 'whiteboard', 'error_free is not true: the workflow cannot be published') unless TRUE_VALUES.include?(whiteboard['error_free'].to_s.downcase)
166
+ templates = elements.size > 2 ? check_templates(elements[2]) : []
167
+ elements[3..4]&.each_with_index do |element, index|
168
+ add(ERROR, 'dependencies.not_map', "element #{index + 4}", 'must be a map') unless element.is_a?(Psych::Nodes::Mapping)
169
+ end
170
+ if whiteboard['xml_export'].to_s.strip.empty?
171
+ add(ERROR, 'whiteboard.xml_missing', 'whiteboard', 'attribute xml_export is missing')
172
+ else
173
+ check_graph(whiteboard['xml_export'], templates)
174
+ end
175
+ check_dependencies(elements[3], elements[4]) if elements.size > 4
176
+ end
177
+
178
+ # Plain format is converted by Orchestrator line by line: each element starts with a dash at column 0
179
+ def check_layout(text)
180
+ count = text.lines.count { |line| line.match?(/^- /) }
181
+ return if ELEMENT_COUNTS.include?(count)
182
+ add(ERROR, 'yaml.layout', 'file', "plain format: each element must start with '- ' at column 0 (#{ELEMENT_COUNTS.join(' or ')} expected), found #{count} such lines")
183
+ end
184
+
185
+ # @return [Hash{String=>Psych::Nodes::Node}] Keys and values of a mapping
186
+ def pairs(node)
187
+ return {} unless node.is_a?(Psych::Nodes::Mapping)
188
+ node.children.each_slice(2).to_h { |key, value| [key.respond_to?(:value) ? key.value : nil, value] }
189
+ end
190
+
191
+ def scalar(node)
192
+ node.is_a?(Psych::Nodes::Scalar) ? node.value : nil
193
+ end
194
+
195
+ # @return [Hash{String=>String}] Attributes of a workflow, a whiteboard or a template, in plain or Rails format
196
+ def attributes(node)
197
+ content = pairs(node)
198
+ if content.key?('concise_attributes')
199
+ content['concise_attributes'].children.to_h do |item|
200
+ item_pairs = pairs(item)
201
+ [scalar(item_pairs['name']), scalar(item_pairs['value_before_type_cast'])]
202
+ end
203
+ else
204
+ inner = content['attributes']
205
+ (inner.is_a?(Psych::Nodes::Mapping) ? pairs(inner) : content.except('attributes', 'new_record', 'active_record_yaml_version')).transform_values { |value| scalar(value) }
206
+ end
207
+ end
208
+
209
+ def check_attributes(node, klass, path, known)
210
+ unless node.is_a?(Psych::Nodes::Mapping) && node.tag.eql?("!ruby/object:#{klass}")
211
+ add(ERROR, "#{path}.type", path, "element must be a map tagged !ruby/object:#{klass}")
212
+ return {}
213
+ end
214
+ attributes(node).tap do |attrs|
215
+ attrs.each_key { |name| add(WARNING, "#{path}.attribute_unknown", path, "unknown attribute: #{name} (the import may fail)") unless known.include?(name) }
216
+ end
217
+ end
218
+
219
+ # @return [Array<String>] Keys of the action templates
220
+ def check_templates(node)
221
+ return [] unless node.is_a?(Psych::Nodes::Mapping)
222
+ pairs(node).filter_map do |key, template|
223
+ path = "templates/#{key}"
224
+ match = key.to_s.match(/\A(.+)__(\d+)\z/)
225
+ if match.nil?
226
+ add(ERROR, 'template.key', path, "key must be <StepType>__<N>: #{key}")
227
+ elsif !template.respond_to?(:tag) || !template.tag.eql?("!ruby/object:#{match[1]}")
228
+ add(ERROR, 'template.tag', path, "value must be tagged !ruby/object:#{match[1]}")
229
+ else
230
+ check_boolean_values(path, template) if @plain
231
+ @template_attributes[key] = attributes(template)
232
+ check_template_attributes(path, match[1], @template_attributes[key]) unless @specs.nil?
233
+ end
234
+ key
235
+ end
236
+ end
237
+
238
+ # In plain format, a blank attribute is dropped, and false is blank
239
+ def check_boolean_values(path, template)
240
+ attributes(template).each do |name, value|
241
+ add(WARNING, 'template.boolean', path, "#{name} is #{value}: write 1 or 0, false is dropped in plain format") if BOOLEAN_TEXT.include?(value)
242
+ end
243
+ end
244
+
245
+ def check_dependencies(listed, packed)
246
+ return unless listed.is_a?(Psych::Nodes::Mapping) && packed.is_a?(Psych::Nodes::Mapping)
247
+ external = pairs(listed).values.flat_map { |list| list.is_a?(Psych::Nodes::Sequence) ? list.children : [] }
248
+ return if external.empty? || !packed.children.empty?
249
+ names = external.map do |dep|
250
+ fields = pairs(dep)
251
+ "#{scalar(fields[':entity'] || fields['entity'])} #{scalar(fields[':id_value'] || fields['id_value'])}".strip
252
+ end
253
+ add(WARNING, 'dependencies.external', 'dependencies', "uses entities not included in the file, they must exist on the server: #{names.uniq.join(', ')}")
254
+ end
255
+
256
+ def text_of(element, name)
257
+ element.elements[name]&.text&.strip
258
+ end
259
+
260
+ def check_graph(xml, templates)
261
+ begin
262
+ root = REXML::Document.new(xml).root
263
+ rescue REXML::ParseException => e
264
+ return add(ERROR, 'graph.xml_invalid', 'xml_export', "invalid XML: #{e.message.lines.first&.strip}")
265
+ end
266
+ return add(ERROR, 'graph.xml_invalid', 'xml_export', 'no XML root element') if root.nil?
267
+ terminals = %w[Start End Fail Error].to_h { |name| [name, root.get_elements(name)] }
268
+ terminals.each do |name, nodes|
269
+ add(ERROR, 'graph.terminal_count', "graph/#{name}", "#{name} must be present once, found #{nodes.size}") unless nodes.size.eql?(1)
270
+ end
271
+ steps = root.get_elements('Steps/Step')
272
+ add(ERROR, 'graph.no_step', 'graph', 'at least one Step is required') if steps.empty?
273
+ ids = (terminals.values.flatten + steps).map { |node| node.attributes['id'] }
274
+ (ids.tally.select { |_, count| count > 1 }.keys + ids.compact.select(&:empty?)).each do |id|
275
+ add(ERROR, 'graph.id_not_unique', 'graph', "node id must be unique and not empty: '#{id}'")
276
+ end
277
+ check_steps(steps, templates)
278
+ closing = terminals.values_at('End', 'Fail', 'Error').flatten
279
+ (closing + steps).each { |node| check_node(node, ids) }
280
+ check_branches(closing, steps)
281
+ check_parameters(root, steps)
282
+ check_step_interfaces(steps) unless @specs.nil?
283
+ end
284
+
285
+ def node_path(node)
286
+ node.name.eql?('Step') ? "graph/Step[#{text_of(node, 'Step_name')}]" : "graph/#{node.name}"
287
+ end
288
+
289
+ def check_node(node, ids)
290
+ path = node_path(node)
291
+ factor = text_of(node, 'Synch_factor')
292
+ if factor.nil? || !factor.match?(/\A-?\d+\z/)
293
+ add(ERROR, 'graph.synch_factor', path, 'Synch_factor is missing or not an integer')
294
+ elsif factor.to_i.negative?
295
+ add(ERROR, 'graph.synch_factor_negative', path, 'negative Synch_factor is never met')
296
+ end
297
+ node.get_elements('Prerequisites/Prerequisite').each do |pre|
298
+ add(ERROR, 'graph.prerequisite_unknown', path, "prerequisite refers to unknown node '#{pre.attributes['id']}'") unless ids.include?(pre.attributes['id'])
299
+ end
300
+ end
301
+
302
+ def check_steps(steps, templates)
303
+ names = []
304
+ steps.each do |step|
305
+ name = text_of(step, 'Step_name')
306
+ type = text_of(step, 'Step_type')
307
+ action_id = text_of(step, 'Action_id')
308
+ path = node_path(step)
309
+ if UNNAMED.include?(name.to_s)
310
+ add(ERROR, 'graph.step_unnamed', "graph/Step[#{step.attributes['id']}]", 'step is not named')
311
+ elsif names.include?(name)
312
+ add(ERROR, 'graph.step_name_not_unique', path, 'step name is not unique')
313
+ end
314
+ names.push(name)
315
+ if UNNAMED.include?(type.to_s)
316
+ add(ERROR, 'graph.step_type_missing', path, 'Step_type is not defined')
317
+ elsif action_id.to_s.empty?
318
+ add(ERROR, 'graph.action_id_missing', path, 'Action_id is missing')
319
+ elsif !templates.include?("#{type}__#{action_id}")
320
+ add(ERROR, 'graph.template_missing', path, "no action template #{type}__#{action_id} in element 3")
321
+ end
322
+ STEP_CHILDREN.each do |child|
323
+ add(ERROR, 'graph.step_element_missing', path, "#{child} is missing (may be empty)") if step.elements[child].nil?
324
+ end
325
+ add(ERROR, 'graph.step_orphan', path, 'no prerequisite, the step never runs (orphaned)') if step.get_elements('Prerequisites/Prerequisite').empty?
326
+ check_output_references(step, steps, path)
327
+ end
328
+ end
329
+
330
+ def check_output_references(step, steps, path)
331
+ step.get_elements('Inputs/Input/Map_to').each do |map|
332
+ next unless map.attributes['type'].eql?('output')
333
+ source = steps.find { |other| text_of(other, 'Step_name').eql?(map.attributes['step_name']) }
334
+ next unless source.nil? || !text_of(source, 'Step_type').eql?(map.attributes['step_type']) || !text_of(source, 'Action_id').eql?(map.attributes['action_id'])
335
+ add(ERROR, 'graph.output_unknown', path, "input #{map.parent.attributes['name']} refers to unknown output of step '#{map.attributes['step_name']}' (step_name, step_type, action_id must match)")
336
+ end
337
+ end
338
+
339
+ def check_branches(closing, steps)
340
+ success = ->(pre) { SUCCESS_STATUSES.include?(pre.attributes['status'].to_s.downcase) }
341
+ consumers = (closing + steps).flat_map { |node| node.get_elements('Prerequisites/Prerequisite').select(&success).map { |pre| pre.attributes['id'] } }
342
+ steps.each do |step|
343
+ next if consumers.include?(step.attributes['id'])
344
+ add(ERROR, 'graph.step_no_output', node_path(step), 'no node follows it with status Complete (step does not have output)')
345
+ end
346
+ return unless closing.all? { |node| node.get_elements('Prerequisites/Prerequisite').empty? }
347
+ add(ERROR, 'graph.no_termination', 'graph', 'workflow does not terminate: End, Fail and Error have no prerequisite')
348
+ end
349
+
350
+ def check_parameters(root, steps)
351
+ linked = (root.get_elements('Start/Parameters/Parameter') + steps.flat_map { |step| step.get_elements('Parameters/Parameter') }).map { |link| link.attributes['id'] }
352
+ defined = {}
353
+ root.get_elements('Parameters/Parameter').each do |parameter|
354
+ name = text_of(parameter, 'Name')
355
+ type = text_of(parameter, 'Value_type')
356
+ path = "graph/Parameter[#{name || parameter.attributes['id']}]"
357
+ add(ERROR, 'graph.parameter_name', path, 'Name is missing') if name.to_s.empty?
358
+ add(ERROR, 'graph.parameter_type', path, "Value_type must be one of #{VALUE_TYPES.join(', ')}") unless VALUE_TYPES.include?(type)
359
+ mandatory_at_runtime = text_of(parameter, 'Runtime_override').eql?('true') && !text_of(parameter, 'Runtime_optional').eql?('true')
360
+ add(WARNING, 'graph.parameter_value', path, 'Value is missing') if !mandatory_at_runtime && parameter.elements['Value'].nil?
361
+ defined[name] = parameter.attributes['id']
362
+ end
363
+ steps.each do |step|
364
+ step.get_elements('Inputs/Input/Map_to').each do |map|
365
+ next unless PARAMETER_MAPPINGS.include?(map.attributes['type'])
366
+ id = defined[map.attributes['name']]
367
+ if id.nil?
368
+ add(ERROR, 'graph.parameter_undefined', node_path(step), "input refers to undefined parameter '#{map.attributes['name']}'")
369
+ elsif !linked.include?(id)
370
+ add(ERROR, 'graph.parameter_not_linked', node_path(step), "parameter '#{map.attributes['name']}' is not linked to Start or to a step: inputs mapped to it are not created")
371
+ end
372
+ end
373
+ end
374
+ end
375
+
376
+ # --- Checks with the specifications of plugins ---
377
+
378
+ def blank?(value)
379
+ value.nil? || value.to_s.strip.empty?
380
+ end
381
+
382
+ # @param values [Hash{String=>String}] Attributes of the template
383
+ def check_template_attributes(path, type, values)
384
+ spec = @specs[type]
385
+ if spec.nil?
386
+ similar = @specs.keys.find { |name| name.casecmp?(type) }
387
+ return add(ERROR, 'template.plugin_case', path, "plugin #{type} does not exist, class names are case sensitive: #{similar}") if similar
388
+ return add(WARNING, 'template.plugin_unknown', path, "plugin #{type} is not in the specifications: its template and the inputs of its steps are not checked")
389
+ end
390
+ columns = spec['attributes'] || {}
391
+ values.each do |name, value|
392
+ attribute = columns[name]
393
+ if attribute.nil?
394
+ next if TEMPLATE_COLUMNS.include?(name)
395
+ next add(ERROR, 'template.attribute_unknown', path, "#{type} has no attribute #{name}: the import fails") if @plain
396
+ next add(WARNING, 'template.attribute_unknown', path, "#{type} has no attribute #{name}: the import may fail")
397
+ end
398
+ # a placeholder is replaced at run time
399
+ next if blank?(value) || value.include?('<%')
400
+ check_value(path, name, attribute, value)
401
+ end
402
+ end
403
+
404
+ # Values are cast by the database layer (ActiveRecord): a text that is not false is true, a text that is not a number is 0
405
+ def check_value(path, name, attribute, value)
406
+ case attribute['type']
407
+ when 'boolean'
408
+ return if (TRUE_VALUES + FALSE_VALUES).include?(value.downcase)
409
+ return add(WARNING, 'template.attribute_value', path, "#{name}: #{value} is read as true: write 1 or 0")
410
+ when *INTEGER_TYPES
411
+ return add(WARNING, 'template.attribute_value', path, "#{name}: #{value} is not an integer, it is read as #{value.to_i}") unless value.match?(/\A-?\d+\z/)
412
+ end
413
+ allowed = attribute['values']
414
+ return if allowed.nil? || allowed.map(&:to_s).include?(value)
415
+ add(ERROR, 'template.attribute_value', path, "#{name}: #{value} is not an allowed value: #{allowed.join(', ')}")
416
+ end
417
+
418
+ # Value of an attribute as the plugin sees it: typed for booleans and integers
419
+ def typed(value, type)
420
+ return value unless value.is_a?(String)
421
+ case type
422
+ when 'boolean' then !FALSE_VALUES.include?(value.downcase)
423
+ when *INTEGER_TYPES then value.to_i
424
+ else value
425
+ end
426
+ end
427
+
428
+ # Values of the attributes of a template as the plugin sees them: nil when blank
429
+ # An attribute not in the template takes its database default; in plain format, so does a blank one (and false is blank)
430
+ # @return [Hash{String=>Object}]
431
+ def effective_values(spec, values)
432
+ (spec['attributes'] || {}).to_h do |name, attribute|
433
+ value = values[name]
434
+ value = nil if blank?(value) || (@plain && value.eql?('false'))
435
+ value = attribute['default'] if value.nil? && (@plain || !values.key?(name))
436
+ [name, typed(value, attribute['type'])]
437
+ end
438
+ end
439
+
440
+ # @param conditions [Hash] `if_blank`, `if_set`, `when` and `conditional` of an input or output of the specifications
441
+ # @return [Boolean, nil] true if the conditions are met, nil if unknown
442
+ def met(conditions, values)
443
+ return if conditions['conditional']
444
+ Array(conditions['if_blank']).all? { |name| values[name].nil? } &&
445
+ Array(conditions['if_set']).all? { |name| !values[name].nil? } &&
446
+ (conditions['when'] || {}).all? do |name, condition|
447
+ value = values[name]
448
+ if condition.key?('in')
449
+ condition['in'].any? { |item| item.nil? ? value.nil? : item.to_s.eql?(value.to_s) }
450
+ else
451
+ !value.nil? && condition['not_in'].none? { |item| item.to_s.eql?(value.to_s) }
452
+ end
453
+ end
454
+ end
455
+
456
+ # @return [Array<String>] Names declared by a type map `{'name'=>'type'}` (Ruby or JSON syntax) or a list `['name']`
457
+ def declared_names(value)
458
+ text = value.strip
459
+ return text.scan(/['"]([^'"]+)['"]\s*(?:=>|:)/).flatten if text.start_with?('{')
460
+ return text.scan(/['"]([^'"]+)['"]/).flatten if text.start_with?('[')
461
+ []
462
+ end
463
+
464
+ # Names given by the value of a template attribute, for a name in angle brackets (`<attribute>`) in the specification
465
+ # @return [Array<String>, nil] The names, or `nil` if they cannot be found in the value
466
+ def names_in_value(value)
467
+ text = value.to_s.strip
468
+ return [] if text.empty?
469
+ return [text] if text.match?(SINGLE_NAME)
470
+ header = text.lines.first.strip
471
+ return header.split(TABLE_SEPARATOR).map(&:strip) if header.include?(TABLE_SEPARATOR)
472
+ nil
473
+ end
474
+
475
+ # Inputs and outputs of a step, for the attributes of its template
476
+ # @return [Hash] `:inputs` and `:outputs`: name => {`exists`, `required`} (true, false or nil if unknown),
477
+ # and `:complete`: kind => true if all inputs (or outputs) are known, so that an other one does not exist
478
+ def step_interface(spec, attributes)
479
+ values = effective_values(spec, attributes)
480
+ interface = {inputs: {}, outputs: {}, complete: {}}
481
+ %i[inputs outputs].each do |kind|
482
+ # false when names come from an attribute value whose format is not known
483
+ names_known = true
484
+ (spec[kind.to_s] || {}).each do |name, info|
485
+ exists = met(info, values)
486
+ next if exists.eql?(false)
487
+ names = [name]
488
+ # a name in angle brackets comes from the value of that attribute
489
+ if name =~ /\A<(\w+)>\z/
490
+ names = names_in_value(values[Regexp.last_match(1)])
491
+ names_known = false if names.nil?
492
+ end
493
+ required = info['required'].is_a?(Hash) ? met(info['required'], values) : info['required']
494
+ Array(names).each do |each_name|
495
+ interface[kind][each_name] = {'exists' => exists, 'required' => required.eql?('depends') ? nil : required}
496
+ end
497
+ end
498
+ declared = (spec['attributes'] || {}).values.any? { |attribute| attribute['declares'].to_s.end_with?(kind.to_s) }
499
+ partly = Array(spec['notes']).any? { |note| note.match?(PARTLY_DEFINED[kind]) }
500
+ interface[:complete][kind] = names_known && (declared || !partly) && blank?(attributes[SPEC_CODE[kind]])
501
+ end
502
+ (spec['attributes'] || {}).each do |name, attribute|
503
+ value = attributes[name]
504
+ next if blank?(value)
505
+ case attribute['declares']
506
+ when 'required_inputs', 'optional_inputs'
507
+ required = attribute['declares'].eql?('required_inputs')
508
+ declared_names(value).each { |input| interface[:inputs][input] ||= {'exists' => true, 'required' => required} }
509
+ when 'outputs'
510
+ declared_names(value).each { |output| interface[:outputs][output] ||= {'exists' => true} }
511
+ end
512
+ next unless attribute['placeholders']
513
+ value.scan(PLACEHOLDER).flatten.each { |input| interface[:inputs][input] ||= {'exists' => true, 'required' => true} }
514
+ # inputs of other placeholders (functions of variables) are not known
515
+ interface[:complete][:inputs] = false if value.scan('<%').size > value.scan(PLACEHOLDER).size
516
+ end
517
+ interface
518
+ end
519
+
520
+ def check_step_interfaces(steps)
521
+ interfaces = {}
522
+ interface_of = lambda do |step|
523
+ type = text_of(step, 'Step_type')
524
+ key = "#{type}__#{text_of(step, 'Action_id')}"
525
+ next if @specs[type].nil? || !@template_attributes.key?(key)
526
+ interfaces[key] ||= step_interface(@specs[type], @template_attributes[key])
527
+ end
528
+ steps.each do |step|
529
+ path = node_path(step)
530
+ type = text_of(step, 'Step_type')
531
+ interface = interface_of.call(step)
532
+ unless interface.nil?
533
+ inputs = step.get_elements('Inputs/Input').map { |input| input.attributes['name'] }
534
+ # a carried variable is also given to the step under its name
535
+ provided = inputs + step.get_elements('Outputs/Carry_thru').map { |carry| carry.attributes['name'] }
536
+ unknown = interface[:complete][:inputs] ? inputs - interface[:inputs].keys : []
537
+ unknown.each { |name| add(WARNING, 'step.input_unknown', path, "#{type} has no input #{name} with this template: Orchestrator ignores it") }
538
+ interface[:inputs].each do |name, input|
539
+ next unless input['exists'].eql?(true) && input['required'].eql?(true) && !provided.include?(name)
540
+ add(ERROR, 'step.input_missing', path, "required input #{name} is not mapped")
541
+ end
542
+ if interface[:complete][:outputs]
543
+ (step.get_elements('Outputs/Output').map { |output| output.attributes['name'] } - interface[:outputs].keys).each do |name|
544
+ add(WARNING, 'step.output_unknown', path, "#{type} has no output #{name} with this template: its settings are ignored")
545
+ end
546
+ end
547
+ end
548
+ step.get_elements('Inputs/Input/Map_to').each do |map|
549
+ next unless map.attributes['type'].eql?('output')
550
+ source = steps.find { |other| STEP_IDENTITY.all? { |name| text_of(other, name).eql?(map.attributes[name.downcase]) } }
551
+ source_interface = source && interface_of.call(source)
552
+ next if source_interface.nil? || !source_interface[:complete][:outputs] || source_interface[:outputs].key?(map.attributes['name'])
553
+ add(
554
+ WARNING, 'step.output_not_produced', path,
555
+ "input #{map.parent.attributes['name']} maps output #{map.attributes['name']} of step '#{map.attributes['step_name']}', which #{map.attributes['step_type']} does not produce with its template"
556
+ )
557
+ end
558
+ end
559
+ end
560
+ end
561
+ end
@@ -125,11 +125,12 @@ module Aspera
125
125
  # @param blob [String] The image as a binary string
126
126
  # @param text [Boolean] `true` to display the image as text, `false` to use iTerm2 if supported
127
127
  # @param reserve [Integer] Number of lines to reserve for other text than the image
128
- # @param double [Boolean] `true` to use colors on half lines, `false` to use colors on full lines
128
+ # @param double [Boolean, nil] `true` to use colors on half lines (Unicode half block), `false` to use colors on full lines, `nil` for `true` if Unicode is supported
129
129
  # @param font_ratio [Float] ratio = font height / font width
130
130
  # @return [String] The image as text, or the iTerm2 escape sequence
131
- def build(blob, text: false, reserve: 3, double: true, font_ratio: DEFAULT_FONT_RATIO)
131
+ def build(blob, text: false, reserve: 3, double: nil, font_ratio: DEFAULT_FONT_RATIO)
132
132
  return '[Image display requires a terminal]' unless Environment.terminal?
133
+ double = Environment.terminal_supports_unicode? if double.nil?
133
134
  return iterm_display_image(blob) if iterm_supported? && !text
134
135
  pixel_colors =
135
136
  begin
@@ -1,7 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Load Rainbow with terminal detection (tty, TERM=dumb, CLICOLOR_FORCE)
3
+ # Load Rainbow with terminal detection (tty, TERM=dumb, NO_COLOR, CLICOLOR, CLICOLOR_FORCE)
4
4
  # and its String refinement.
5
5
  # Each file using colors must still activate it with: using Rainbow
6
6
  require 'rainbow'
7
7
  require 'rainbow/refinement'
8
+
9
+ module Aspera
10
+ # Detection of ANSI colors support, replaces the one of Rainbow
11
+ # https://no-color.org
12
+ # https://bixense.com/clicolors/
13
+ module Colors
14
+ class << self
15
+ # @return [Boolean] true if ANSI colors shall be used, from environment and terminal
16
+ def detect
17
+ return false if env_set?('NO_COLOR')
18
+ return true if env_set?('CLICOLOR_FORCE') && !ENV['CLICOLOR_FORCE'].eql?('0')
19
+ return false if ENV['CLICOLOR'].eql?('0')
20
+ $stdout.tty? && $stderr.tty? && !ENV['TERM'].eql?('dumb')
21
+ end
22
+
23
+ private
24
+
25
+ # @return [Boolean] true if the env var is set and not empty
26
+ def env_set?(name)
27
+ !ENV.fetch(name, '').empty?
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ Rainbow.enabled = Aspera::Colors.detect