rsmp 0.48.4 → 0.51.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -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/configuration.md +8 -0
- 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 -4
- 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 +10 -9
- data/lib/rsmp/node/node.rb +3 -21
- data/lib/rsmp/node/site/connections.rb +4 -9
- data/lib/rsmp/node/site/site.rb +33 -17
- data/lib/rsmp/node/supervisor/modules/configuration.rb +24 -11
- 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 +69 -19
- data/lib/rsmp/proxy/modules/watchdogs.rb +15 -2
- data/lib/rsmp/proxy/proxy.rb +7 -69
- 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/site/sxl_selection.rb +7 -2
- 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 +36 -31
- data/lib/rsmp/result.rb +95 -0
- data/lib/rsmp/schema/validation.rb +5 -3
- data/lib/rsmp/schema/version.rb +27 -0
- data/lib/rsmp/schema.rb +1 -0
- 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/modules/system.rb +2 -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
- metadata +26 -4
- data/lib/rsmp/collect/collector/status.rb +0 -34
|
@@ -34,45 +34,39 @@ module RSMP
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
loop do
|
|
37
|
-
connect
|
|
37
|
+
connected = connect
|
|
38
|
+
unless connected.success?
|
|
39
|
+
publish_connection_attempt_failure(connected.failure)
|
|
40
|
+
break unless reconnect_delay?
|
|
41
|
+
|
|
42
|
+
next
|
|
43
|
+
end
|
|
38
44
|
start_reader
|
|
39
45
|
start_handshake
|
|
40
|
-
wait_for_reader
|
|
41
|
-
break unless reconnect_delay?
|
|
42
|
-
rescue Restart
|
|
43
|
-
@logger.mute @ip, @port
|
|
44
|
-
raise
|
|
45
|
-
rescue RSMP::ConnectionError => e
|
|
46
|
-
log e, level: :error
|
|
47
|
-
break unless reconnect_delay?
|
|
48
|
-
rescue StandardError => e
|
|
49
|
-
distribute_error e, level: :internal
|
|
46
|
+
close_from_result(wait_for_reader)
|
|
50
47
|
break unless reconnect_delay?
|
|
51
48
|
ensure
|
|
49
|
+
close(reason: :internal_failure) if $ERROR_INFO
|
|
52
50
|
close
|
|
53
|
-
stop_subtasks
|
|
54
51
|
end
|
|
55
52
|
end
|
|
56
53
|
|
|
57
54
|
def run_accepted_connection
|
|
55
|
+
begin_session
|
|
58
56
|
self.state = :connected
|
|
59
57
|
start_reader
|
|
60
58
|
start_handshake
|
|
61
|
-
wait_for_reader
|
|
62
|
-
rescue RSMP::ConnectionError => e
|
|
63
|
-
log e, level: :error
|
|
64
|
-
rescue StandardError => e
|
|
65
|
-
distribute_error e, level: :internal
|
|
59
|
+
close_from_result(wait_for_reader)
|
|
66
60
|
ensure
|
|
61
|
+
close(reason: :internal_failure) if $ERROR_INFO
|
|
67
62
|
close
|
|
68
|
-
stop_subtasks
|
|
69
63
|
end
|
|
70
64
|
|
|
71
65
|
def start_handshake
|
|
72
|
-
send_version_request @site_settings['site_id'],
|
|
66
|
+
send_version_request @site_settings['site_id'], advertised_core_versions
|
|
73
67
|
end
|
|
74
68
|
|
|
75
|
-
def close
|
|
69
|
+
def close(...)
|
|
76
70
|
prune_unbuffered_status_subscriptions
|
|
77
71
|
super
|
|
78
72
|
end
|
|
@@ -80,14 +74,15 @@ module RSMP
|
|
|
80
74
|
# connect to the supervisor and initiate handshake supervisor
|
|
81
75
|
def connect
|
|
82
76
|
log "Connecting to supervisor at #{@ip}:#{@port}", level: :info
|
|
77
|
+
begin_session
|
|
83
78
|
self.state = :connecting
|
|
84
|
-
connect_tcp
|
|
79
|
+
opened = connect_tcp
|
|
80
|
+
return opened if opened.failure?
|
|
81
|
+
|
|
82
|
+
self.state = :connected
|
|
85
83
|
@logger.unmute @ip, @port
|
|
86
84
|
log "Connected to supervisor at #{@ip}:#{@port}", level: :info
|
|
87
|
-
|
|
88
|
-
raise ConnectionError, "Could not connect to supervisor at #{@ip}:#{@port}: Errno #{e.errno} #{e}"
|
|
89
|
-
rescue StandardError => e
|
|
90
|
-
raise ConnectionError, "Error while connecting to supervisor at #{@ip}:#{@port}: #{e}"
|
|
85
|
+
Result.success(self)
|
|
91
86
|
end
|
|
92
87
|
|
|
93
88
|
def stop_task
|
|
@@ -108,10 +103,22 @@ module RSMP
|
|
|
108
103
|
|
|
109
104
|
@stream = IO::Stream::Buffered.new(@socket)
|
|
110
105
|
@protocol = RSMP::Protocol.new(@stream) # rsmp messages are json terminated with a form-feed
|
|
111
|
-
self
|
|
106
|
+
Result.success(self)
|
|
112
107
|
rescue Errno::ECONNREFUSED => e # rescue to avoid log output
|
|
113
108
|
log 'Connection refused', level: :warning
|
|
114
|
-
|
|
109
|
+
failed_connection_result(e)
|
|
110
|
+
rescue SystemCallError, SocketError, IOError, Async::TimeoutError => e
|
|
111
|
+
failed_connection_result(e)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def failed_connection_result(error)
|
|
115
|
+
Result.failure(
|
|
116
|
+
:connection_failed,
|
|
117
|
+
message: "Could not connect to supervisor at #{@ip}:#{@port}: #{error.message}",
|
|
118
|
+
source: :transport,
|
|
119
|
+
context: { ip: @ip, port: @port, session_id: @session_id },
|
|
120
|
+
cause: error
|
|
121
|
+
)
|
|
115
122
|
end
|
|
116
123
|
|
|
117
124
|
def handshake_complete
|
|
@@ -142,9 +149,6 @@ module RSMP
|
|
|
142
149
|
else
|
|
143
150
|
super
|
|
144
151
|
end
|
|
145
|
-
rescue UnknownComponent, UnknownCommand, UnknownStatus,
|
|
146
|
-
MessageRejected, MissingAttribute => e
|
|
147
|
-
dont_acknowledge message, '', e.to_s
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
def handle_interface_request(message)
|
|
@@ -209,6 +213,7 @@ module RSMP
|
|
|
209
213
|
def check_sxl_version(message)
|
|
210
214
|
if core_3_3?
|
|
211
215
|
@rejected_sxls, @accepted_sxls = message.sxls.partition { |item| item['rejected'] }
|
|
216
|
+
validate_sxl_response! @accepted_sxls
|
|
212
217
|
@receive_alarms = message.attributes.fetch('receiveAlarms', true)
|
|
213
218
|
else
|
|
214
219
|
primary = primary_configured_sxl
|
|
@@ -221,7 +226,7 @@ module RSMP
|
|
|
221
226
|
end
|
|
222
227
|
|
|
223
228
|
def send_component_list
|
|
224
|
-
|
|
229
|
+
send_generated_message ComponentList.new('components' => @site.component_list)
|
|
225
230
|
end
|
|
226
231
|
|
|
227
232
|
def component_list_acknowledged
|
data/lib/rsmp/result.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
# Result values represent the expected outcome of finite operations.
|
|
3
|
+
module Result
|
|
4
|
+
# A successfully completed operation.
|
|
5
|
+
Success = Data.define(:value) do
|
|
6
|
+
def success?
|
|
7
|
+
true
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def failure?
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def failure
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def value!
|
|
19
|
+
value
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def map
|
|
23
|
+
Result.success(yield(value))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def and_then
|
|
27
|
+
yield(value)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# An expected operational failure.
|
|
32
|
+
Failure = Data.define(:failure) do
|
|
33
|
+
def initialize(failure:)
|
|
34
|
+
raise ArgumentError, 'failure must be an RSMP::Failure' unless failure.is_a?(RSMP::Failure)
|
|
35
|
+
|
|
36
|
+
super
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def success?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def failure?
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def value
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def value!
|
|
52
|
+
exception = OperationError.new(failure)
|
|
53
|
+
raise exception, cause: failure.cause if failure.cause
|
|
54
|
+
|
|
55
|
+
raise exception
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def map
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def and_then
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
def success(value = nil)
|
|
70
|
+
Success.new(value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def failure(code = nil, failure: nil, **details)
|
|
74
|
+
Failure.new(
|
|
75
|
+
failure: failure || RSMP::Failure.new(
|
|
76
|
+
code: code,
|
|
77
|
+
message: details.fetch(:message, code),
|
|
78
|
+
source: details.fetch(:source, :operation),
|
|
79
|
+
context: details.fetch(:context, {}),
|
|
80
|
+
cause: details[:cause]
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Raised by bang APIs when an expected failure result is explicitly unwrapped.
|
|
87
|
+
class OperationError < Error
|
|
88
|
+
attr_reader :failure
|
|
89
|
+
|
|
90
|
+
def initialize(failure)
|
|
91
|
+
@failure = failure
|
|
92
|
+
super(failure.message)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -41,6 +41,8 @@ module RSMP
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# Core must pass. SXL-defined messages pass if at least one SXL schema passes.
|
|
44
|
+
# A message which does not satisfy its schemas is an ordinary validation
|
|
45
|
+
# outcome. Invalid API arguments and unavailable schemas still raise.
|
|
44
46
|
def self.validate(message, schemas, options = {})
|
|
45
47
|
raise ArgumentError, 'message missing' unless message
|
|
46
48
|
raise ArgumentError, 'schemas missing' unless schemas
|
|
@@ -49,9 +51,9 @@ module RSMP
|
|
|
49
51
|
|
|
50
52
|
errors = validate_core(message, schemas, options)
|
|
51
53
|
errors.concat validate_sxls(message, schemas, options) if errors.empty?
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
Validation.new(violations: errors)
|
|
55
|
+
rescue UnknownMessageCodeError => e
|
|
56
|
+
Validation.new(violations: [['', 'unknown_message_code', e.message]])
|
|
55
57
|
end
|
|
56
58
|
end
|
|
57
59
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
# Provides JSON Schema validation for RSMP messages across core and SXL versions.
|
|
3
|
+
module Schema
|
|
4
|
+
STRICT_VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
|
|
5
|
+
LEGACY_SHORT_VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
|
|
6
|
+
|
|
7
|
+
# Return the schema version for a supported Core version string.
|
|
8
|
+
# Before Core 3.3, some releases used a two-part version on the wire.
|
|
9
|
+
# Only accept such a version when adding .0 identifies a known legacy
|
|
10
|
+
# schema unambiguously. Core 3.3 and later require all three parts.
|
|
11
|
+
def self.normalize_core_version(version)
|
|
12
|
+
return unless version.is_a?(String)
|
|
13
|
+
return version if core_versions.include? version
|
|
14
|
+
return unless LEGACY_SHORT_VERSION_PATTERN.match? version
|
|
15
|
+
|
|
16
|
+
normalized = "#{version}.0"
|
|
17
|
+
return unless core_versions.include? normalized
|
|
18
|
+
return unless Gem::Version.new(normalized) < Gem::Version.new('3.3.0')
|
|
19
|
+
|
|
20
|
+
normalized
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.strict_version?(version)
|
|
24
|
+
version.is_a?(String) && STRICT_VERSION_PATTERN.match?(version)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/rsmp/schema.rb
CHANGED
data/lib/rsmp/sxl/interface.rb
CHANGED
|
@@ -6,7 +6,14 @@ module RSMP
|
|
|
6
6
|
class Interface
|
|
7
7
|
extend Forwardable
|
|
8
8
|
|
|
9
|
-
def_delegators :proxy,
|
|
9
|
+
def_delegators :proxy,
|
|
10
|
+
:send_message,
|
|
11
|
+
:send_message!,
|
|
12
|
+
:send_message_and_collect,
|
|
13
|
+
:send_message_and_collect!,
|
|
14
|
+
:validate_ready,
|
|
15
|
+
:validate_ready!,
|
|
16
|
+
:log
|
|
10
17
|
|
|
11
18
|
attr_reader :proxy, :name, :version
|
|
12
19
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
module SXL
|
|
3
|
+
module Processing
|
|
4
|
+
# Indexes locally available SXL definitions by name and exact version.
|
|
5
|
+
class Catalogue
|
|
6
|
+
def initialize(paths = [])
|
|
7
|
+
@documents = Hash.new { |hash, key| hash[key] = {} }
|
|
8
|
+
Array(paths).each { |path| add_path(path) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def add_path(path)
|
|
12
|
+
expanded_path = File.expand_path(path)
|
|
13
|
+
raise Error, "SXL source #{path} not found" unless File.exist?(expanded_path)
|
|
14
|
+
|
|
15
|
+
if File.directory?(expanded_path)
|
|
16
|
+
pattern = File.join(expanded_path, '**', '{sxl.yaml,sxl.yml}')
|
|
17
|
+
files = Dir.glob(pattern)
|
|
18
|
+
raise Error, "No sxl.yaml or sxl.yml files found under #{path}" if files.empty?
|
|
19
|
+
|
|
20
|
+
files.each { |file| add_file(file) }
|
|
21
|
+
else
|
|
22
|
+
add_file(expanded_path)
|
|
23
|
+
end
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_document(document)
|
|
28
|
+
existing = @documents[document.name][document.version_string]
|
|
29
|
+
if existing && existing.path != document.path
|
|
30
|
+
raise Error,
|
|
31
|
+
"Duplicate SXL #{document.name} #{document.version_string} in #{existing.path} and #{document.path}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@documents[document.name][document.version_string] = document
|
|
35
|
+
document
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def candidates(name, requirements = [])
|
|
39
|
+
versions = @documents.fetch(name, {}).values.sort_by(&:version).reverse
|
|
40
|
+
versions.select do |document|
|
|
41
|
+
requirements.all? { |requirement| requirement.satisfied_by?(document.version) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def versions(name)
|
|
46
|
+
@documents.fetch(name, {}).values.map(&:version_string).sort_by { |version| Gem::Version.new(version) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch(name, version)
|
|
50
|
+
@documents.fetch(name, {})[version.to_s]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def add_file(path)
|
|
56
|
+
add_document(Document.load(path))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
module SXL
|
|
3
|
+
module Processing
|
|
4
|
+
# Rejects component types or message codes owned by more than one SXL.
|
|
5
|
+
module ConflictChecker
|
|
6
|
+
def self.check!(documents)
|
|
7
|
+
check_definitions!(documents, :component_types, 'component type')
|
|
8
|
+
check_definitions!(documents, :message_codes, 'message code')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.check_definitions!(documents, collection, label)
|
|
12
|
+
owners = {}
|
|
13
|
+
documents.each do |document|
|
|
14
|
+
document.public_send(collection).each do |definition|
|
|
15
|
+
previous = owners[definition.id]
|
|
16
|
+
if previous
|
|
17
|
+
raise Error,
|
|
18
|
+
"Conflicting #{label} #{definition.id.inspect} in " \
|
|
19
|
+
"#{previous[:document].name} and #{document.name}"
|
|
20
|
+
end
|
|
21
|
+
owners[definition.id] = { document: document, definition: definition }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_class_method :check_definitions!
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module RSMP
|
|
2
|
+
module SXL
|
|
3
|
+
module Processing
|
|
4
|
+
# Dependency graph validation shared by resolution and manifest verification.
|
|
5
|
+
module DependencyGraph
|
|
6
|
+
def self.cycle(documents)
|
|
7
|
+
by_name = documents.to_h { |document| [document.name, document] }
|
|
8
|
+
visited = {}
|
|
9
|
+
stack = []
|
|
10
|
+
by_name.each_key do |name|
|
|
11
|
+
found = visit(name, by_name, visited, stack)
|
|
12
|
+
return found if found
|
|
13
|
+
end
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.visit(name, documents, visited, stack)
|
|
18
|
+
return stack[stack.index(name)..] + [name] if stack.include?(name)
|
|
19
|
+
return nil if visited[name]
|
|
20
|
+
|
|
21
|
+
visited[name] = true
|
|
22
|
+
stack << name
|
|
23
|
+
documents.fetch(name).dependencies.each_key do |dependency|
|
|
24
|
+
next unless documents.key?(dependency)
|
|
25
|
+
|
|
26
|
+
found = visit(dependency, documents, visited, stack)
|
|
27
|
+
return found if found
|
|
28
|
+
end
|
|
29
|
+
stack.pop
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method :visit
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -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
|