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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- 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:
|
|
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
|
data/lib/aspera/rainbow.rb
CHANGED
|
@@ -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
|