rsmp 0.48.3 → 0.51.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
- data/CHANGELOG.md +6 -0
- data/Gemfile.lock +19 -20
- data/README.md +27 -3
- data/Rakefile +3 -1
- data/documentation/cli.md +79 -4
- data/documentation/collecting_message.md +90 -170
- data/documentation/message_distribution.md +17 -3
- data/documentation/tasks.md +65 -117
- data/lib/rsmp/cli.rb +93 -5
- data/lib/rsmp/collect/collector.rb +117 -134
- data/lib/rsmp/collect/distributor.rb +16 -3
- data/lib/rsmp/collect/queue.rb +14 -6
- data/lib/rsmp/collect/receiver.rb +1 -1
- data/lib/rsmp/collect/state_collector.rb +11 -2
- data/lib/rsmp/collection.rb +12 -0
- data/lib/rsmp/completion.rb +49 -0
- data/lib/rsmp/component/component_base.rb +3 -3
- data/lib/rsmp/convert/export/json_schema/index.rb +8 -3
- data/lib/rsmp/convert/import/yaml.rb +12 -8
- data/lib/rsmp/delivery.rb +18 -0
- data/lib/rsmp/event.rb +19 -0
- data/lib/rsmp/event_source.rb +36 -0
- data/lib/rsmp/exchange.rb +10 -0
- data/lib/rsmp/failure.rb +23 -0
- data/lib/rsmp/helpers/error.rb +14 -23
- data/lib/rsmp/message.rb +9 -7
- data/lib/rsmp/node/node.rb +3 -21
- data/lib/rsmp/node/site/connections.rb +4 -9
- data/lib/rsmp/node/site/site.rb +31 -16
- data/lib/rsmp/node/supervisor/modules/connection.rb +19 -5
- data/lib/rsmp/node/supervisor/modules/sites.rb +16 -5
- data/lib/rsmp/node/supervisor/supervisor.rb +13 -14
- data/lib/rsmp/node/task.rb +59 -53
- data/lib/rsmp/proxy/modules/acknowledgements.rb +22 -9
- data/lib/rsmp/proxy/modules/lifecycle.rb +134 -0
- data/lib/rsmp/proxy/modules/receive.rb +109 -66
- data/lib/rsmp/proxy/modules/send.rb +109 -51
- data/lib/rsmp/proxy/modules/state.rb +15 -5
- data/lib/rsmp/proxy/modules/tasks.rb +96 -70
- data/lib/rsmp/proxy/modules/versions.rb +8 -8
- data/lib/rsmp/proxy/modules/watchdogs.rb +15 -2
- data/lib/rsmp/proxy/proxy.rb +4 -66
- data/lib/rsmp/proxy/site/connection.rb +27 -22
- data/lib/rsmp/proxy/site/modules/aggregated_status.rb +18 -8
- data/lib/rsmp/proxy/site/modules/alarms.rb +45 -38
- data/lib/rsmp/proxy/site/modules/commands.rb +18 -8
- data/lib/rsmp/proxy/site/modules/status.rb +44 -19
- data/lib/rsmp/proxy/site/site_proxy.rb +12 -10
- data/lib/rsmp/proxy/supervisor/modules/aggregated_status.rb +1 -1
- data/lib/rsmp/proxy/supervisor/modules/alarms.rb +1 -1
- data/lib/rsmp/proxy/supervisor/modules/commands.rb +1 -1
- data/lib/rsmp/proxy/supervisor/modules/message_buffer.rb +8 -9
- data/lib/rsmp/proxy/supervisor/modules/status.rb +1 -1
- data/lib/rsmp/proxy/supervisor/modules/status_updates.rb +1 -1
- data/lib/rsmp/proxy/supervisor/supervisor_proxy.rb +34 -30
- data/lib/rsmp/result.rb +95 -0
- data/lib/rsmp/schema/validation.rb +5 -3
- data/lib/rsmp/sxl/interface.rb +8 -1
- data/lib/rsmp/sxl/processing/catalogue.rb +61 -0
- data/lib/rsmp/sxl/processing/conflict_checker.rb +30 -0
- data/lib/rsmp/sxl/processing/dependency_graph.rb +37 -0
- data/lib/rsmp/sxl/processing/document.rb +158 -0
- data/lib/rsmp/sxl/processing/error.rb +8 -0
- data/lib/rsmp/sxl/processing/manifest.rb +144 -0
- data/lib/rsmp/sxl/processing/natural_sort.rb +22 -0
- data/lib/rsmp/sxl/processing/resolver.rb +94 -0
- data/lib/rsmp/sxl/processing/version_requirement.rb +86 -0
- data/lib/rsmp/sxl/processing.rb +18 -0
- data/lib/rsmp/sxl/supervisor_interface.rb +8 -1
- data/lib/rsmp/termination.rb +10 -0
- data/lib/rsmp/tlc/modules/plans.rb +0 -2
- data/lib/rsmp/tlc/proxy/control.rb +50 -16
- data/lib/rsmp/tlc/proxy/detectors.rb +15 -4
- data/lib/rsmp/tlc/proxy/io.rb +46 -31
- data/lib/rsmp/tlc/proxy/plans.rb +36 -19
- data/lib/rsmp/tlc/proxy/plans_bang.rb +44 -0
- data/lib/rsmp/tlc/proxy/status.rb +47 -23
- data/lib/rsmp/tlc/proxy/system.rb +16 -4
- data/lib/rsmp/tlc/supervisor_interface.rb +7 -1
- data/lib/rsmp/tlc/traffic_controller_site.rb +6 -9
- data/lib/rsmp/validation.rb +22 -0
- data/lib/rsmp/version.rb +1 -1
- data/lib/rsmp.rb +19 -4
- data/rsmp.gemspec +1 -1
- data/schemas/tlc/1.0.10/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.10/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.10/sxl_index.json +3 -3
- data/schemas/tlc/1.0.13/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.13/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.13/sxl_index.json +3 -3
- data/schemas/tlc/1.0.14/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.14/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.14/sxl_index.json +3 -3
- data/schemas/tlc/1.0.15/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.15/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.15/sxl_index.json +3 -3
- data/schemas/tlc/1.0.7/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.7/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.7/sxl_index.json +3 -3
- data/schemas/tlc/1.0.8/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.8/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.8/sxl_index.json +3 -3
- data/schemas/tlc/1.0.9/commands/M0001.json +3 -3
- data/schemas/tlc/1.0.9/source/sxl.yaml +3 -3
- data/schemas/tlc/1.0.9/sxl_index.json +3 -3
- metadata +25 -4
- data/lib/rsmp/collect/collector/status.rb +0 -34
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require 'psych'
|
|
2
|
+
|
|
3
|
+
module RSMP
|
|
4
|
+
module SXL
|
|
5
|
+
module Processing
|
|
6
|
+
# A validated SXL YAML document with a normalized component structure.
|
|
7
|
+
class Document
|
|
8
|
+
NAME = %r{\A[a-z0-9][a-z0-9_/-]*\z}
|
|
9
|
+
PREFIX = %r{\A[a-zA-Z0-9_/-]+/\z}
|
|
10
|
+
MESSAGE_KINDS = %w[alarms statuses commands].freeze
|
|
11
|
+
|
|
12
|
+
SymbolDefinition = Struct.new(:id, :kind, :location, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
attr_reader :path, :data, :name, :version, :version_string, :minimum_core_version,
|
|
15
|
+
:dependencies, :component_types, :message_codes
|
|
16
|
+
|
|
17
|
+
def self.load(path)
|
|
18
|
+
expanded_path = File.expand_path(path)
|
|
19
|
+
raise Error, "SXL source #{path} not found" unless File.exist?(expanded_path)
|
|
20
|
+
raise Error, "SXL source #{path} is not a file" unless File.file?(expanded_path)
|
|
21
|
+
|
|
22
|
+
parse(File.read(expanded_path, encoding: 'UTF-8'), path: expanded_path)
|
|
23
|
+
rescue EncodingError => e
|
|
24
|
+
raise Error, "Cannot read SXL source #{path}: #{e.message}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.parse(source, path: '(SXL source)')
|
|
28
|
+
data = Psych.safe_load(source, permitted_classes: [], permitted_symbols: [], aliases: false)
|
|
29
|
+
new(path: path, data: data)
|
|
30
|
+
rescue Psych::Exception => e
|
|
31
|
+
raise Error, "Cannot read SXL source #{path}: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.validate_name!(value, label: 'SXL name')
|
|
35
|
+
return value if value.is_a?(String) && NAME.match?(value)
|
|
36
|
+
|
|
37
|
+
raise Error, "Invalid #{label} #{value.inspect}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(path:, data:)
|
|
41
|
+
@path = path
|
|
42
|
+
@data = normalize_document(require_hash(data, 'document'))
|
|
43
|
+
read_metadata
|
|
44
|
+
read_dependencies
|
|
45
|
+
read_symbols
|
|
46
|
+
rescue Error => e
|
|
47
|
+
raise Error, "#{path}: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def normalize_document(document)
|
|
53
|
+
components = component_definitions(document)
|
|
54
|
+
document.except('objects').merge('components' => components)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def component_definitions(document)
|
|
58
|
+
if document.key?('components') && document.key?('objects')
|
|
59
|
+
raise Error, 'Invalid document; use either components or objects, not both'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
key = document.key?('components') ? 'components' : 'objects'
|
|
63
|
+
require_hash(document[key], key)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def read_metadata
|
|
67
|
+
meta = require_hash(data['meta'], 'meta')
|
|
68
|
+
@name = self.class.validate_name!(meta['name'])
|
|
69
|
+
@version_string = meta['version'].to_s
|
|
70
|
+
@version = VersionRequirement.exact_version(meta['version'], label: 'SXL version')
|
|
71
|
+
@minimum_core_version = read_minimum_core_version(meta)
|
|
72
|
+
@prefix = data['prefix']
|
|
73
|
+
validate_prefix!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def read_minimum_core_version(meta)
|
|
77
|
+
minimum = meta['minimum_core_version']
|
|
78
|
+
return unless minimum
|
|
79
|
+
|
|
80
|
+
VersionRequirement.exact_version(minimum, label: 'minimum Core version')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def validate_prefix!
|
|
84
|
+
return if @prefix.nil? || (@prefix.is_a?(String) && PREFIX.match?(@prefix))
|
|
85
|
+
|
|
86
|
+
raise Error, "Invalid SXL prefix #{@prefix.inspect}; it must end with /"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_dependencies
|
|
90
|
+
dependency_data = data['dependencies'] || {}
|
|
91
|
+
require_hash(dependency_data, 'dependencies')
|
|
92
|
+
@dependencies = dependency_data.to_h do |dependency_name, requirement|
|
|
93
|
+
self.class.validate_name!(dependency_name, label: 'dependency name')
|
|
94
|
+
unless requirement.is_a?(String)
|
|
95
|
+
raise Error, "Invalid requirement for #{dependency_name.inspect}; expected a string"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
[dependency_name, VersionRequirement.new(requirement)]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read_symbols
|
|
103
|
+
components = data['components']
|
|
104
|
+
@component_types = []
|
|
105
|
+
@message_codes = []
|
|
106
|
+
seen_components = {}
|
|
107
|
+
seen_messages = {}
|
|
108
|
+
|
|
109
|
+
components.each_pair do |component_id, component|
|
|
110
|
+
component_id = require_identifier(component_id, 'component type')
|
|
111
|
+
full_component_id = prefixed(component_id)
|
|
112
|
+
ensure_unique!(seen_components, full_component_id, 'component type')
|
|
113
|
+
@component_types << SymbolDefinition.new(id: full_component_id, kind: :component,
|
|
114
|
+
location: component_id)
|
|
115
|
+
read_component_messages(component_id, component, seen_messages)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def read_component_messages(component_id, component, seen_messages)
|
|
120
|
+
component = require_hash(component, "component #{component_id.inspect}")
|
|
121
|
+
MESSAGE_KINDS.each do |kind|
|
|
122
|
+
messages = component[kind] || {}
|
|
123
|
+
require_hash(messages, "#{kind} for component #{component_id.inspect}")
|
|
124
|
+
messages.each_key do |code|
|
|
125
|
+
code = require_identifier(code, 'message code')
|
|
126
|
+
full_code = prefixed(code)
|
|
127
|
+
ensure_unique!(seen_messages, full_code, 'message code')
|
|
128
|
+
@message_codes << SymbolDefinition.new(id: full_code, kind: kind.delete_suffix('s').to_sym,
|
|
129
|
+
location: "#{component_id}/#{kind}/#{code}")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def require_hash(value, label)
|
|
135
|
+
return value if value.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
raise Error, "Invalid #{label}; expected a mapping"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def require_identifier(value, label)
|
|
141
|
+
return value if value.is_a?(String) && !value.empty?
|
|
142
|
+
|
|
143
|
+
raise Error, "Invalid #{label} #{value.inspect}; expected a non-empty string"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def prefixed(identifier)
|
|
147
|
+
"#{@prefix}#{identifier}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ensure_unique!(seen, identifier, label)
|
|
151
|
+
raise Error, "Duplicate #{label} #{identifier.inspect}" if seen[identifier]
|
|
152
|
+
|
|
153
|
+
seen[identifier] = true
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
require 'date'
|
|
2
|
+
require 'psych'
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module RSMP
|
|
6
|
+
module SXL
|
|
7
|
+
module Processing
|
|
8
|
+
# Creates and verifies deterministic SXL manifests.
|
|
9
|
+
module Manifest
|
|
10
|
+
TOP_LEVEL_KEYS = %w[meta sxls].freeze
|
|
11
|
+
META_KEYS = %w[created_at created_by format].freeze
|
|
12
|
+
|
|
13
|
+
def self.create(documents, format:, now: Time.now)
|
|
14
|
+
validate_documents!(documents, format)
|
|
15
|
+
{
|
|
16
|
+
'meta' => {
|
|
17
|
+
'created_at' => now.utc.iso8601,
|
|
18
|
+
'created_by' => "rsmp v#{RSMP::VERSION}",
|
|
19
|
+
'format' => format.to_s
|
|
20
|
+
},
|
|
21
|
+
'sxls' => NaturalSort.sort(documents.map(&:name)).to_h do |name|
|
|
22
|
+
document = documents.find { |candidate| candidate.name == name }
|
|
23
|
+
[name, document.version_string]
|
|
24
|
+
end
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.dump(manifest)
|
|
29
|
+
Psych.safe_dump(manifest, permitted_classes: [], aliases: false, line_width: -1)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.load(path)
|
|
33
|
+
raise Error, "Manifest #{path} not found" unless File.exist?(path)
|
|
34
|
+
raise Error, "Manifest #{path} is not a file" unless File.file?(path)
|
|
35
|
+
|
|
36
|
+
parse(File.read(path, encoding: 'UTF-8'), path: path)
|
|
37
|
+
rescue EncodingError => e
|
|
38
|
+
raise Error, "Cannot read manifest #{path}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parse(source, path: '(manifest)')
|
|
42
|
+
Psych.safe_load(source, permitted_classes: [Date, Time], permitted_symbols: [], aliases: false)
|
|
43
|
+
rescue Psych::Exception => e
|
|
44
|
+
raise Error, "Cannot read manifest #{path}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.verify!(manifest, catalogue)
|
|
48
|
+
meta, sxls = validate_shape!(manifest)
|
|
49
|
+
documents = load_documents!(sxls, catalogue)
|
|
50
|
+
validate_dependency_closure!(documents, sxls)
|
|
51
|
+
validate_documents!(documents, meta['format'])
|
|
52
|
+
documents
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.validate_documents!(documents, format)
|
|
56
|
+
format_version = VersionRequirement.exact_version(format, label: 'manifest format')
|
|
57
|
+
documents.each do |document|
|
|
58
|
+
next unless document.minimum_core_version && document.minimum_core_version > format_version
|
|
59
|
+
|
|
60
|
+
raise Error,
|
|
61
|
+
"SXL #{document.name} #{document.version_string} requires Core " \
|
|
62
|
+
"#{document.minimum_core_version}, newer than manifest format #{format}"
|
|
63
|
+
end
|
|
64
|
+
cycle = DependencyGraph.cycle(documents)
|
|
65
|
+
raise Error, "Cyclic dependency #{cycle.join(' -> ')}" if cycle
|
|
66
|
+
|
|
67
|
+
ConflictChecker.check!(documents)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.validate_shape!(manifest)
|
|
71
|
+
raise Error, 'Invalid manifest; expected a mapping' unless manifest.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
reject_unknown_keys!(manifest, TOP_LEVEL_KEYS, 'manifest')
|
|
74
|
+
|
|
75
|
+
meta = manifest['meta']
|
|
76
|
+
sxls = manifest['sxls']
|
|
77
|
+
raise Error, 'Invalid manifest meta; expected a mapping' unless meta.is_a?(Hash)
|
|
78
|
+
raise Error, 'Invalid manifest sxls; expected a non-empty mapping' unless sxls.is_a?(Hash) && !sxls.empty?
|
|
79
|
+
|
|
80
|
+
reject_unknown_keys!(meta, META_KEYS, 'manifest meta')
|
|
81
|
+
validate_metadata!(meta)
|
|
82
|
+
validate_sxls!(sxls)
|
|
83
|
+
[meta, sxls]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.validate_metadata!(meta)
|
|
87
|
+
missing = META_KEYS.reject { |key| meta.key?(key) }
|
|
88
|
+
raise Error, "Manifest meta is missing #{missing.join(', ')}" unless missing.empty?
|
|
89
|
+
|
|
90
|
+
Time.iso8601(meta['created_at'].to_s)
|
|
91
|
+
unless meta['created_by'].is_a?(String) && !meta['created_by'].empty?
|
|
92
|
+
raise Error, 'Manifest created_by must be a non-empty string'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
VersionRequirement.exact_version(meta['format'], label: 'manifest format')
|
|
96
|
+
rescue ArgumentError
|
|
97
|
+
raise Error, 'Manifest created_at must be an ISO 8601 timestamp'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.validate_sxls!(sxls)
|
|
101
|
+
sxls.each_pair do |name, version|
|
|
102
|
+
Document.validate_name!(name)
|
|
103
|
+
VersionRequirement.exact_version(version, label: "version for #{name}")
|
|
104
|
+
end
|
|
105
|
+
expected_order = NaturalSort.sort(sxls.keys)
|
|
106
|
+
return if sxls.keys == expected_order
|
|
107
|
+
|
|
108
|
+
raise Error, 'Manifest SXLs are not in natural name order'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.load_documents!(sxls, catalogue)
|
|
112
|
+
sxls.map do |name, version|
|
|
113
|
+
document = catalogue.fetch(name, version)
|
|
114
|
+
raise Error, "SXL #{name} #{version} is not available from the supplied sources" unless document
|
|
115
|
+
|
|
116
|
+
document
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.validate_dependency_closure!(documents, sxls)
|
|
121
|
+
documents.each do |document|
|
|
122
|
+
document.dependencies.each_pair do |name, requirement|
|
|
123
|
+
version = sxls[name]
|
|
124
|
+
raise Error, "Manifest is missing dependency #{name} required by #{document.name}" unless version
|
|
125
|
+
next if requirement.satisfied_by?(version)
|
|
126
|
+
|
|
127
|
+
raise Error,
|
|
128
|
+
"Manifest dependency #{name} #{version} does not satisfy #{requirement.source} " \
|
|
129
|
+
"required by #{document.name}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.reject_unknown_keys!(hash, expected, label)
|
|
135
|
+
unknown = hash.keys - expected
|
|
136
|
+
raise Error, "Unknown #{label} key(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private_class_method :validate_shape!, :validate_metadata!, :validate_sxls!, :load_documents!,
|
|
140
|
+
:validate_dependency_closure!, :reject_unknown_keys!
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
module SXL
|
|
3
|
+
module Processing
|
|
4
|
+
# Natural sorting for identifiers containing decimal digit sequences.
|
|
5
|
+
module NaturalSort
|
|
6
|
+
def self.key(value)
|
|
7
|
+
value.scan(/\d+|\D+/).map do |part|
|
|
8
|
+
if part.match?(/\A\d+\z/)
|
|
9
|
+
[0, part.to_i, part.length]
|
|
10
|
+
else
|
|
11
|
+
[1, part]
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.sort(values)
|
|
17
|
+
values.sort_by { |value| key(value) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
module SXL
|
|
3
|
+
module Processing
|
|
4
|
+
# Resolves root requirements to a deterministic set of exact SXL versions.
|
|
5
|
+
class Resolver
|
|
6
|
+
def initialize(catalogue)
|
|
7
|
+
@catalogue = catalogue
|
|
8
|
+
@diagnostics = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def resolve(root_requirements)
|
|
12
|
+
constraints = Hash.new { |hash, key| hash[key] = [] }
|
|
13
|
+
root_requirements.each do |name, requirement|
|
|
14
|
+
Document.validate_name!(name)
|
|
15
|
+
constraints[name] << requirement
|
|
16
|
+
end
|
|
17
|
+
raise Error, 'At least one root SXL is required' if constraints.empty?
|
|
18
|
+
|
|
19
|
+
solution = search({}, constraints)
|
|
20
|
+
return sorted(solution.values) if solution
|
|
21
|
+
|
|
22
|
+
detail = @diagnostics.uniq.first(3).join('; ')
|
|
23
|
+
raise Error, "Cannot resolve SXL dependencies#{": #{detail}" unless detail.empty?}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def search(selected, constraints)
|
|
29
|
+
return nil unless selected_versions_valid?(selected, constraints)
|
|
30
|
+
|
|
31
|
+
unresolved = NaturalSort.sort(constraints.keys.reject { |name| selected.key?(name) })
|
|
32
|
+
return selected if unresolved.empty?
|
|
33
|
+
|
|
34
|
+
name = unresolved.first
|
|
35
|
+
candidates = @catalogue.candidates(name, constraints[name])
|
|
36
|
+
record_missing_candidate(name, constraints[name]) if candidates.empty?
|
|
37
|
+
|
|
38
|
+
candidates.each do |document|
|
|
39
|
+
solution = search_candidate(name, document, selected, constraints)
|
|
40
|
+
return solution if solution
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def search_candidate(name, document, selected, constraints)
|
|
46
|
+
next_selected = selected.merge(name => document)
|
|
47
|
+
next_constraints = copy_constraints(constraints)
|
|
48
|
+
document.dependencies.each_pair do |dependency_name, requirement|
|
|
49
|
+
next_constraints[dependency_name] << requirement
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
cycle = DependencyGraph.cycle(next_selected.values)
|
|
53
|
+
if cycle
|
|
54
|
+
@diagnostics << "cyclic dependency #{cycle.join(' -> ')}"
|
|
55
|
+
return nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
search(next_selected, next_constraints)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def selected_versions_valid?(selected, constraints)
|
|
62
|
+
selected.each_pair do |name, document|
|
|
63
|
+
next if constraints[name].all? { |requirement| requirement.satisfied_by?(document.version) }
|
|
64
|
+
|
|
65
|
+
requirements = constraints[name].map(&:source).join(', ')
|
|
66
|
+
@diagnostics << "#{name} #{document.version_string} does not satisfy #{requirements}"
|
|
67
|
+
return false
|
|
68
|
+
end
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def record_missing_candidate(name, requirements)
|
|
73
|
+
available = @catalogue.versions(name)
|
|
74
|
+
if available.empty?
|
|
75
|
+
@diagnostics << "no versions available for #{name}"
|
|
76
|
+
else
|
|
77
|
+
expected = requirements.map(&:source).join(', ')
|
|
78
|
+
@diagnostics << "no version of #{name} satisfies #{expected} (available: #{available.join(', ')})"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def copy_constraints(constraints)
|
|
83
|
+
constraints.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |(name, requirements), copy|
|
|
84
|
+
copy[name] = requirements.dup
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sorted(documents)
|
|
89
|
+
documents.sort_by { |document| NaturalSort.key(document.name) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require 'rubygems'
|
|
2
|
+
|
|
3
|
+
module RSMP
|
|
4
|
+
module SXL
|
|
5
|
+
module Processing
|
|
6
|
+
# Parses the version requirement syntax defined by RSMP Core.
|
|
7
|
+
class VersionRequirement
|
|
8
|
+
EXACT_VERSION = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/
|
|
9
|
+
PARTIAL_VERSION = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?\z/
|
|
10
|
+
CLAUSE = /\A(~|>=|<=|>|<)?\s*(\d+(?:\.\d+){1,2})\z/
|
|
11
|
+
|
|
12
|
+
attr_reader :source
|
|
13
|
+
|
|
14
|
+
def self.exact_version(value, label: 'version')
|
|
15
|
+
string = value.to_s
|
|
16
|
+
unless EXACT_VERSION.match?(string)
|
|
17
|
+
raise Error, "Invalid #{label} #{value.inspect}; expected MAJOR.MINOR.PATCH"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Gem::Version.new(string)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(source)
|
|
24
|
+
@source = source.to_s
|
|
25
|
+
@predicates = parse(@source)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def satisfied_by?(version)
|
|
29
|
+
version = self.class.exact_version(version) unless version.is_a?(Gem::Version)
|
|
30
|
+
@predicates.all? { |predicate| predicate.call(version) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parse(source)
|
|
36
|
+
raise Error, 'Version requirement cannot be empty' if source.strip.empty?
|
|
37
|
+
|
|
38
|
+
source.split(/\s+and\s+/).map { |clause| parse_clause(clause.strip) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_clause(clause)
|
|
42
|
+
match = CLAUSE.match(clause)
|
|
43
|
+
raise Error, "Invalid version requirement #{source.inspect}" unless match
|
|
44
|
+
|
|
45
|
+
operator = match[1]
|
|
46
|
+
version_string = match[2]
|
|
47
|
+
return compatible_predicate(version_string) if operator == '~'
|
|
48
|
+
|
|
49
|
+
version = self.class.exact_version(version_string, label: 'requirement version')
|
|
50
|
+
comparison_predicate(operator || '=', version)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def compatible_predicate(version_string)
|
|
54
|
+
match = PARTIAL_VERSION.match(version_string)
|
|
55
|
+
raise Error, "Invalid compatibility requirement #{source.inspect}" unless match
|
|
56
|
+
|
|
57
|
+
major, minor, patch = match.captures.map { |part| part&.to_i }
|
|
58
|
+
return major_zero_predicate(major, minor, patch) if major.zero?
|
|
59
|
+
|
|
60
|
+
lower = Gem::Version.new("#{major}.#{minor}.#{patch || 0}")
|
|
61
|
+
upper = Gem::Version.new("#{major + 1}.0.0")
|
|
62
|
+
->(version) { version >= lower && version < upper }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def major_zero_predicate(major, minor, patch)
|
|
66
|
+
unless patch
|
|
67
|
+
raise Error, "Major-zero compatibility requirement #{source.inspect} must include an exact patch version"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
exact = Gem::Version.new("#{major}.#{minor}.#{patch}")
|
|
71
|
+
->(version) { version == exact }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def comparison_predicate(operator, expected)
|
|
75
|
+
case operator
|
|
76
|
+
when '=' then ->(version) { version == expected }
|
|
77
|
+
when '>' then ->(version) { version > expected }
|
|
78
|
+
when '>=' then ->(version) { version >= expected }
|
|
79
|
+
when '<' then ->(version) { version < expected }
|
|
80
|
+
when '<=' then ->(version) { version <= expected }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require_relative '../version'
|
|
2
|
+
require_relative 'processing/error'
|
|
3
|
+
require_relative 'processing/natural_sort'
|
|
4
|
+
require_relative 'processing/version_requirement'
|
|
5
|
+
require_relative 'processing/document'
|
|
6
|
+
require_relative 'processing/catalogue'
|
|
7
|
+
require_relative 'processing/dependency_graph'
|
|
8
|
+
require_relative 'processing/resolver'
|
|
9
|
+
require_relative 'processing/conflict_checker'
|
|
10
|
+
require_relative 'processing/manifest'
|
|
11
|
+
|
|
12
|
+
module RSMP
|
|
13
|
+
module SXL
|
|
14
|
+
# Dependency resolution, conflict checks, and manifests for SXL definitions.
|
|
15
|
+
module Processing
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -8,12 +8,19 @@ module RSMP
|
|
|
8
8
|
|
|
9
9
|
def_delegators :proxy,
|
|
10
10
|
:request_status,
|
|
11
|
+
:request_status!,
|
|
11
12
|
:request_status_and_collect,
|
|
13
|
+
:request_status_and_collect!,
|
|
12
14
|
:subscribe_to_status,
|
|
15
|
+
:subscribe_to_status!,
|
|
13
16
|
:subscribe_to_status_and_collect,
|
|
17
|
+
:subscribe_to_status_and_collect!,
|
|
14
18
|
:unsubscribe_to_status,
|
|
19
|
+
:unsubscribe_to_status!,
|
|
15
20
|
:send_command,
|
|
16
|
-
:
|
|
21
|
+
:send_command!,
|
|
22
|
+
:send_command_and_collect,
|
|
23
|
+
:send_command_and_collect!
|
|
17
24
|
|
|
18
25
|
def process_message(_message); end
|
|
19
26
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
# An explicit reason for a long-running service task to finish normally.
|
|
3
|
+
Termination = Data.define(:reason, :source) do
|
|
4
|
+
def initialize(reason:, source:)
|
|
5
|
+
raise ArgumentError, 'termination reason must be a Symbol' unless reason.is_a?(Symbol)
|
|
6
|
+
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|