asterism 0.7.1 → 0.9.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 +100 -0
- data/README.md +288 -5
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/complete.rb +282 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/nouns.rb +396 -0
- data/lib/asterism/cli/survey.rb +206 -0
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli.rb +701 -0
- data/lib/asterism/cruby/credentials.rb +61 -116
- data/lib/asterism/cruby/objects.rb +60 -7
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +1 -1
- data/mrblib/asterism.rb +206 -23
- data/mrblib/credentials.rb +197 -0
- data/mrblib/status.rb +198 -0
- metadata +17 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# The credentials of one node or router as one file
|
|
1
|
+
# The credentials of one node or router as one file, CRuby's part (the
|
|
2
|
+
# class itself is mrblib/credentials.rb, shared with the boards): the .tar
|
|
2
3
|
# an Asterism console hands out when it issues a certificate ("Save all
|
|
3
4
|
# (.tar)" on the certificate page; the archive is made in the browser that
|
|
4
5
|
# made the key). It holds, at its top or in one directory:
|
|
@@ -23,130 +24,74 @@ require "json"
|
|
|
23
24
|
require "stringio"
|
|
24
25
|
|
|
25
26
|
module Asterism
|
|
27
|
+
# The portable class is mrblib/credentials.rb (the boards read the .tar
|
|
28
|
+
# with it); CRuby reads the file with the standard library and checks
|
|
29
|
+
# more: who may read the file, and the key pair (prepended, so the
|
|
30
|
+
# portable methods are not redefined).
|
|
26
31
|
class Credentials
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
module CRuby
|
|
33
|
+
private
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
MAX_SIZE = 1024 * 1024
|
|
33
|
-
|
|
34
|
-
attr_reader :path, :name, :kind, :router, :not_after, :key_pem, :cert_pem, :ca_pem, :info
|
|
35
|
-
|
|
36
|
-
# Reads path (a String or Pathname of a .tar). warn: false skips the
|
|
37
|
-
# check of who may read the file.
|
|
38
|
-
def self.load(path, warn: true)
|
|
39
|
-
new(path, warn: warn)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# A credentials file (a path or a Credentials) and the arguments of a
|
|
43
|
-
# connection -> [locator, config Hash, credentials]. The locator given
|
|
44
|
-
# wins over asterism.json's router; config given (a Hash) is laid over
|
|
45
|
-
# the TLS keys. @api private
|
|
46
|
-
def self.merge(credentials, locator, config)
|
|
47
|
-
creds = credentials.is_a?(Credentials) ? credentials : load(credentials)
|
|
48
|
-
if config.is_a?(String)
|
|
49
|
-
raise ArgumentError, "credentials: and a config String cannot be combined; pass config: as a Hash"
|
|
35
|
+
def b64(pem)
|
|
36
|
+
[pem].pack("m0")
|
|
50
37
|
end
|
|
51
|
-
merged = creds.zenoh_config.merge(config || {})
|
|
52
|
-
[locator || creds.router, merged, creds]
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def initialize(path, warn: true)
|
|
56
|
-
@path = path.to_s
|
|
57
|
-
check_mode if warn
|
|
58
|
-
files = read_tar
|
|
59
|
-
@info = parse_info(files["asterism.json"])
|
|
60
|
-
keys = files.keys.select { _1.end_with?(".key") }
|
|
61
|
-
raise Error, "#{@path}: no private key (<name>.key) in it" if keys.empty?
|
|
62
|
-
raise Error, "#{@path}: more than one private key in it (#{keys.sort.join(', ')})" if keys.size > 1
|
|
63
|
-
@name = @info["name"].to_s
|
|
64
|
-
@name = File.basename(keys.first, ".key") if @name.empty?
|
|
65
|
-
@key_pem = files["#{@name}.key"] or raise Error, "#{@path}: the key is not named after #{@name} (#{keys.first})"
|
|
66
|
-
@cert_pem = files["#{@name}.pem"] or raise Error, "#{@path}: no certificate #{@name}.pem in it"
|
|
67
|
-
@ca_pem = files["ca.pem"] or raise Error, "#{@path}: no ca.pem in it"
|
|
68
|
-
@kind = @info["kind"]
|
|
69
|
-
@router = @info["router"].to_s.empty? ? nil : @info["router"].to_s
|
|
70
|
-
@not_after = @info["not_after"]
|
|
71
|
-
check_pair
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# The zenoh configuration keys (a Hash for Session.open's config:):
|
|
75
|
-
# the CA, the certificate and the key as base64, and mutual TLS.
|
|
76
|
-
def zenoh_config
|
|
77
|
-
{
|
|
78
|
-
"#{TLS}/root_ca_certificate_base64" => b64(@ca_pem),
|
|
79
|
-
"#{TLS}/connect_certificate_base64" => b64(@cert_pem),
|
|
80
|
-
"#{TLS}/connect_private_key_base64" => b64(@key_pem),
|
|
81
|
-
"#{TLS}/enable_mtls" => true
|
|
82
|
-
}
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def inspect
|
|
86
|
-
"#<Asterism::Credentials #{@name} #{@router || '(no router)'}#{@not_after ? " until #{@not_after}" : ''}>"
|
|
87
|
-
end
|
|
88
|
-
alias to_s inspect
|
|
89
|
-
|
|
90
|
-
private
|
|
91
38
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
"(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
|
|
103
|
-
rescue SystemCallError => e
|
|
104
|
-
raise Error, "#{@path}: #{e.message}"
|
|
105
|
-
end
|
|
39
|
+
# Like ssh with a private key: a file that the group or others may read
|
|
40
|
+
# is told about (it still works).
|
|
41
|
+
def check_mode
|
|
42
|
+
st = File.stat(@path)
|
|
43
|
+
return if Gem.win_platform? || (st.mode & 0o077).zero?
|
|
44
|
+
Kernel.warn("asterism: #{@path} holds a private key and can be read by others " \
|
|
45
|
+
"(mode #{format('%04o', st.mode & 0o7777)}); chmod 600 #{@path}")
|
|
46
|
+
rescue SystemCallError => e
|
|
47
|
+
raise Error, "#{@path}: #{e.message}"
|
|
48
|
+
end
|
|
106
49
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
50
|
+
# {basename => content} of the regular files, at the top or in one
|
|
51
|
+
# directory.
|
|
52
|
+
def read_tar
|
|
53
|
+
data = File.binread(@path)
|
|
54
|
+
raise Error, "#{@path}: too large for a credentials file" if data.bytesize > MAX_SIZE
|
|
55
|
+
files = {}
|
|
56
|
+
Gem::Package::TarReader.new(StringIO.new(data)) do |tar|
|
|
57
|
+
tar.each do |entry|
|
|
58
|
+
next unless entry.file?
|
|
59
|
+
base = File.basename(entry.full_name)
|
|
60
|
+
raise Error, "#{@path}: #{base} is in it twice" if files.key?(base)
|
|
61
|
+
files[base] = entry.read.to_s
|
|
62
|
+
end
|
|
119
63
|
end
|
|
64
|
+
raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
|
|
65
|
+
files
|
|
66
|
+
rescue SystemCallError => e
|
|
67
|
+
raise Error, "#{@path}: #{e.message}"
|
|
68
|
+
rescue Gem::Package::TarInvalidError, ArgumentError => e
|
|
69
|
+
raise Error, "#{@path}: not a tar file (#{e.message})"
|
|
120
70
|
end
|
|
121
|
-
raise Error, "#{@path}: not a tar file with credentials in it" if files.empty?
|
|
122
|
-
files
|
|
123
|
-
rescue SystemCallError => e
|
|
124
|
-
raise Error, "#{@path}: #{e.message}"
|
|
125
|
-
rescue Gem::Package::TarInvalidError, ArgumentError => e
|
|
126
|
-
raise Error, "#{@path}: not a tar file (#{e.message})"
|
|
127
|
-
end
|
|
128
71
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
72
|
+
def parse_info(text)
|
|
73
|
+
return {} if text.nil?
|
|
74
|
+
h = JSON.parse(text)
|
|
75
|
+
raise Error, "#{@path}: asterism.json is not an object" unless h.is_a?(Hash)
|
|
76
|
+
h
|
|
77
|
+
rescue JSON::ParserError => e
|
|
78
|
+
raise Error, "#{@path}: asterism.json: #{e.message}"
|
|
79
|
+
end
|
|
137
80
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
81
|
+
# The key belongs to the certificate, and the certificate has not
|
|
82
|
+
# expired (a warning: zenoh refuses it anyway). Skipped without openssl.
|
|
83
|
+
def check_pair
|
|
84
|
+
require "openssl"
|
|
85
|
+
cert = OpenSSL::X509::Certificate.new(@cert_pem)
|
|
86
|
+
key = OpenSSL::PKey.read(@key_pem)
|
|
87
|
+
raise Error, "#{@path}: #{@name}.key is not the key of #{@name}.pem" unless cert.check_private_key(key)
|
|
88
|
+
Kernel.warn("asterism: #{@path}: the certificate expired at #{cert.not_after.utc}") if cert.not_after < Time.now
|
|
89
|
+
rescue LoadError
|
|
90
|
+
nil
|
|
91
|
+
rescue OpenSSL::OpenSSLError => e
|
|
92
|
+
raise Error, "#{@path}: #{e.message}"
|
|
93
|
+
end
|
|
150
94
|
end
|
|
95
|
+
prepend CRuby
|
|
151
96
|
end
|
|
152
97
|
end
|
|
@@ -27,7 +27,7 @@ module Asterism
|
|
|
27
27
|
# singleton class, so the shared methods themselves are not changed.
|
|
28
28
|
module Locked
|
|
29
29
|
%i[expose unexpose poll nodes [] call_async meta connected? reconnecting? exposed on_join on_leave off_join off_leave
|
|
30
|
-
profile profile_async session].each do |m|
|
|
30
|
+
on_app_join on_app_leave off_app_join off_app_leave profile profile_async status status_async apps session].each do |m|
|
|
31
31
|
define_method(m) do |*args, **kw, &blk|
|
|
32
32
|
::Asterism::LOCK.synchronize { super(*args, **kw, &blk) }
|
|
33
33
|
end
|
|
@@ -73,9 +73,10 @@ module Asterism
|
|
|
73
73
|
::Asterism::LOCK.synchronize { super }
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# The on_join / on_leave blocks (called from
|
|
77
|
-
# what they raise goes to its on_error and
|
|
78
|
-
|
|
76
|
+
# The on_join / on_leave (and on_app_*) blocks (called from
|
|
77
|
+
# Asterism.poll): with a Net, what they raise goes to its on_error and
|
|
78
|
+
# the other blocks still run.
|
|
79
|
+
def tell(where, blk, node, app = nil)
|
|
79
80
|
net = @net
|
|
80
81
|
return super unless net
|
|
81
82
|
net.runner.guard(where) { super }
|
|
@@ -162,11 +163,12 @@ module Asterism
|
|
|
162
163
|
def exposed = ::Asterism.exposed
|
|
163
164
|
|
|
164
165
|
# Asterism.expose: methods: an Array of names or a Hash name => number
|
|
165
|
-
# of arguments
|
|
166
|
+
# of arguments (or => {args: [names], doc: "text"}); doc: the object's
|
|
167
|
+
# own text (both go to the meta); caller: :from passes the calling node's ID as that
|
|
166
168
|
# keyword. The object's methods run on the receiving thread (or in
|
|
167
169
|
# run, or in whatever polls).
|
|
168
|
-
def expose(name, obj, methods:, caller: nil)
|
|
169
|
-
::Asterism.expose(name, obj, methods: methods, caller: caller)
|
|
170
|
+
def expose(name, obj, methods:, caller: nil, doc: nil)
|
|
171
|
+
::Asterism.expose(name, obj, methods: methods, caller: caller, doc: doc)
|
|
170
172
|
end
|
|
171
173
|
|
|
172
174
|
# Asterism.current_call: the call being answered on this thread, or nil.
|
|
@@ -187,6 +189,34 @@ module Asterism
|
|
|
187
189
|
::Asterism.profile_of(node, icon: icon, timeout: timeout, timeout_ms: timeout_ms)
|
|
188
190
|
end
|
|
189
191
|
|
|
192
|
+
# Asterism.status: this application's status (a Hash: the whole
|
|
193
|
+
# status; keywords: those fields). Without arguments, returns it.
|
|
194
|
+
def status(h = nil, **fields)
|
|
195
|
+
::Asterism.status(h, **fields)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Asterism.status_of: another application's status, or nil. Waits like
|
|
199
|
+
# a call (for the receiving thread's ticks while it runs).
|
|
200
|
+
def status_of(node, app, timeout: nil, timeout_ms: nil)
|
|
201
|
+
::Asterism.status_of(node, app, timeout: timeout, timeout_ms: timeout_ms)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# on_status(pattern = "*/*") { |node, app, status| }: the statuses put
|
|
205
|
+
# by the applications whose "<node>/<app>" matches the pattern (Zenoh
|
|
206
|
+
# wildcards: * one chunk, ** several), as they change, on the receiving
|
|
207
|
+
# thread. Only changes come: ask status_of for the one there now.
|
|
208
|
+
# Returns the subscription (close it to stop).
|
|
209
|
+
def on_status(pattern = "*/*", &blk)
|
|
210
|
+
raise ArgumentError, "on_status needs a block" unless blk
|
|
211
|
+
session.subscribe("#{::Asterism::ROOT}/#{pattern}/#{::Asterism::Status::KEY}") do |sample|
|
|
212
|
+
got = ::Asterism::Status.parse(sample.key, sample.payload)
|
|
213
|
+
blk.call(*got) if got
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Asterism.apps: the applications alive now, "<node>/<app>".
|
|
218
|
+
def apps(pattern = "*/*") = ::Asterism.apps(pattern)
|
|
219
|
+
|
|
190
220
|
# A proxy for <node>/<app>/<object>; timeout: in seconds (or
|
|
191
221
|
# timeout_ms:).
|
|
192
222
|
def [](path, timeout: nil, timeout_ms: nil)
|
|
@@ -217,6 +247,29 @@ module Asterism
|
|
|
217
247
|
self
|
|
218
248
|
end
|
|
219
249
|
|
|
250
|
+
# on_app_join { |node, app| } / on_app_leave { |node, app| }: an
|
|
251
|
+
# application appeared on the network / is gone (Asterism.on_app_join),
|
|
252
|
+
# on the receiving thread. What they raise goes to on_error.
|
|
253
|
+
def on_app_join(&blk)
|
|
254
|
+
::Asterism.on_app_join(&blk)
|
|
255
|
+
self
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def on_app_leave(&blk)
|
|
259
|
+
::Asterism.on_app_leave(&blk)
|
|
260
|
+
self
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def off_app_join(blk)
|
|
264
|
+
::Asterism.off_app_join(blk)
|
|
265
|
+
self
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def off_app_leave(blk)
|
|
269
|
+
::Asterism.off_app_leave(blk)
|
|
270
|
+
self
|
|
271
|
+
end
|
|
272
|
+
|
|
220
273
|
# Removes a block given to on_join / on_leave (keep the Proc).
|
|
221
274
|
def off_join(blk)
|
|
222
275
|
::Asterism.off_join(blk)
|
data/lib/asterism/version.rb
CHANGED
data/lib/asterism.rb
CHANGED
|
@@ -12,7 +12,7 @@ require "asterism/zenoh"
|
|
|
12
12
|
require_relative "asterism/version"
|
|
13
13
|
|
|
14
14
|
# The same order as an mrbgem's mrblib (alphabetical).
|
|
15
|
-
%w[asterism cdr future profile proxy ros].each do |f|
|
|
15
|
+
%w[asterism cdr credentials future profile proxy ros status].each do |f|
|
|
16
16
|
require_relative "../mrblib/#{f}"
|
|
17
17
|
end
|
|
18
18
|
require_relative "asterism/cruby"
|
data/mrblib/asterism.rb
CHANGED
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
# reply {"value" => v} or
|
|
14
14
|
# {"error" => {"kind" => k, "class" => c, "message" => m}}
|
|
15
15
|
# asterism/<node>/<app>/<object>/meta
|
|
16
|
-
# get: reply {"methods" => [{"name" => n, "arity" => a
|
|
16
|
+
# get: reply {"methods" => [{"name" => n, "arity" => a,
|
|
17
|
+
# "args" => [...], "doc" => "..."}, ...], "doc" => "..."}
|
|
17
18
|
# asterism/<node>/<app> liveliness: the application is up
|
|
18
19
|
# asterism/<node>/<app>/<object> liveliness: the object is exposed
|
|
19
20
|
# asterism/<node>/@profile get: reply the node's profile (profile.rb)
|
|
21
|
+
# asterism/<node>/<app>/@status get: reply the application's status (status.rb)
|
|
20
22
|
#
|
|
21
23
|
# Everything is polled: nothing runs behind the application's back. A call
|
|
22
24
|
# that waits for its answer keeps polling meanwhile and answers the calls
|
|
@@ -240,16 +242,23 @@ module Asterism
|
|
|
240
242
|
# Opens the connection. locator, mode: and listen: go to
|
|
241
243
|
# Asterism::Zenoh::Session.open (client of a router by default; mode: :peer
|
|
242
244
|
# with or without listen: for no router), and so do config: (a Hash of
|
|
243
|
-
# "key/path" => value or a JSON5 String, e.g. TLS certificates
|
|
244
|
-
#
|
|
245
|
-
# only
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
245
|
+
# "key/path" => value or a JSON5 String, e.g. TLS certificates; the boards
|
|
246
|
+
# take the TLS keys only) and connect_timeout: (seconds; CRuby's
|
|
247
|
+
# asterism-zenoh only, the boards raise ArgumentError) when they are given.
|
|
248
|
+
# credentials: a .tar from an Asterism console (Asterism::Credentials, a
|
|
249
|
+
# path or a loaded one): its certificates go to zenoh (mutual TLS), and
|
|
250
|
+
# locator and node: default to its router and name. node: is this
|
|
251
|
+
# machine's ID and app: this application's name. check_timeout: how long
|
|
252
|
+
# to look for another copy of the same <node>/<app> (seconds,
|
|
253
|
+
# CHECK_TIMEOUT). Raises Disconnected when it cannot connect, and Error
|
|
254
|
+
# when the same <node>/<app> is already on the network.
|
|
255
|
+
def self.connect(locator = nil, node: nil, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
|
|
256
|
+
check_timeout: nil, credentials: nil)
|
|
252
257
|
raise Error, "already connected (Asterism.close first)" if connected?
|
|
258
|
+
if credentials
|
|
259
|
+
locator, config, creds = Credentials.merge(credentials, locator, config)
|
|
260
|
+
node ||= creds.name
|
|
261
|
+
end
|
|
253
262
|
release
|
|
254
263
|
node = check_name("node", node)
|
|
255
264
|
app = check_name("app", app)
|
|
@@ -279,7 +288,8 @@ module Asterism
|
|
|
279
288
|
@session = s
|
|
280
289
|
@node = node
|
|
281
290
|
@app = app
|
|
282
|
-
@objects = {} # name => [object, {method name => arity}, token, caller keyword or nil
|
|
291
|
+
@objects = {} # name => [object, {method name => arity}, token, caller keyword or nil,
|
|
292
|
+
# {method name => meta fields}, the object's doc or nil]
|
|
283
293
|
@alive = {} # "node/app/object" => node (from liveliness)
|
|
284
294
|
@apps = {} # "node/app" => node (from liveliness)
|
|
285
295
|
@proxies = {}
|
|
@@ -287,10 +297,13 @@ module Asterism
|
|
|
287
297
|
@lost = nil
|
|
288
298
|
@known = [] # the other nodes told to on_join so far
|
|
289
299
|
@known_set = {} # the same, as node => true
|
|
300
|
+
@known_apps = {} # "node/app" => true: the other applications told to on_app_join
|
|
290
301
|
@node_list = [node] # what nodes returns (this node first), rebuilt on a change
|
|
291
302
|
@nodes_changed = false # liveliness changed since tell_nodes last compared
|
|
292
303
|
@was_reconnecting = false
|
|
304
|
+
@status_put = nil
|
|
293
305
|
tell_profile # for those who follow the profiles (the reply is the queryable's)
|
|
306
|
+
tell_status(true) # a status set before connecting (status.rb)
|
|
294
307
|
self
|
|
295
308
|
end
|
|
296
309
|
|
|
@@ -353,8 +366,11 @@ module Asterism
|
|
|
353
366
|
def self.close
|
|
354
367
|
release
|
|
355
368
|
@known = nil
|
|
369
|
+
@known_apps = nil
|
|
356
370
|
@on_join = nil
|
|
357
371
|
@on_leave = nil
|
|
372
|
+
@on_app_join = nil
|
|
373
|
+
@on_app_leave = nil
|
|
358
374
|
nil
|
|
359
375
|
end
|
|
360
376
|
|
|
@@ -380,6 +396,8 @@ module Asterism
|
|
|
380
396
|
@queryable = nil
|
|
381
397
|
@watch = nil
|
|
382
398
|
@profile_queryable = nil
|
|
399
|
+
@status_queryable = nil
|
|
400
|
+
@status_put = nil
|
|
383
401
|
end
|
|
384
402
|
|
|
385
403
|
# The Zenoh session of the connection (an Asterism::Zenoh::Session), for
|
|
@@ -438,21 +456,39 @@ module Asterism
|
|
|
438
456
|
|
|
439
457
|
# Makes obj callable from other machines as <node>/<app>/<name>. Only the
|
|
440
458
|
# listed methods can be called. methods: is an Array of names, or a Hash
|
|
441
|
-
# name => number of arguments (checked before calling; -1 for any)
|
|
459
|
+
# name => number of arguments (checked before calling; -1 for any), or
|
|
460
|
+
# name => {args: [names], doc: "text"} (docs/protocol.md 5): the
|
|
461
|
+
# arguments' names (their number is the number checked, unless arity:
|
|
462
|
+
# says otherwise) and what the method does, both told in the meta for
|
|
463
|
+
# the readers that show them (asterism mcp, the command line). doc: a
|
|
464
|
+
# text about the object itself, also in the meta.
|
|
442
465
|
# caller: a keyword name (caller: :from): each call passes the calling
|
|
443
466
|
# node's ID (Asterism.current_call.from) to the method as that keyword,
|
|
444
467
|
# and a call that brings the keyword itself is refused (an "arguments"
|
|
445
468
|
# error), so the value is always the one Asterism took from the call.
|
|
446
|
-
def self.expose(name, obj, methods:, caller: nil)
|
|
469
|
+
def self.expose(name, obj, methods:, caller: nil, doc: nil)
|
|
447
470
|
s = session!
|
|
448
471
|
name = check_name("object", name)
|
|
449
472
|
table = {}
|
|
473
|
+
info = {}
|
|
450
474
|
if methods.is_a?(Hash)
|
|
451
|
-
methods.each
|
|
475
|
+
methods.each do |m, n|
|
|
476
|
+
if n.is_a?(Hash)
|
|
477
|
+
d = method_doc(m, n)
|
|
478
|
+
table[m.to_s] = d[0]
|
|
479
|
+
info[m.to_s] = d[1]
|
|
480
|
+
else
|
|
481
|
+
table[m.to_s] = n.to_i
|
|
482
|
+
end
|
|
483
|
+
end
|
|
452
484
|
else
|
|
453
485
|
methods.each { |m| table[m.to_s] = -1 }
|
|
454
486
|
end
|
|
455
487
|
raise ArgumentError, "no method to expose" if table.empty?
|
|
488
|
+
unless doc.nil?
|
|
489
|
+
raise ArgumentError, "doc: expects a String, got #{doc.class}" unless doc.is_a?(String)
|
|
490
|
+
raise ArgumentError, "doc: at most #{DOC_MAX} bytes" if doc.bytesize > DOC_MAX
|
|
491
|
+
end
|
|
456
492
|
table.each_key do |m|
|
|
457
493
|
raise ArgumentError, "#{obj.class} has no public method #{m}" unless obj.respond_to?(m)
|
|
458
494
|
end
|
|
@@ -466,12 +502,60 @@ module Asterism
|
|
|
466
502
|
end
|
|
467
503
|
unexpose(name)
|
|
468
504
|
token = s.liveliness("#{ROOT}/#{@node}/#{@app}/#{name}")
|
|
469
|
-
@objects[name] = [obj, table, token, kw]
|
|
505
|
+
@objects[name] = [obj, table, token, kw, info, doc]
|
|
470
506
|
"#{@node}/#{@app}/#{name}"
|
|
471
507
|
rescue Asterism::Zenoh::Error => e
|
|
472
508
|
raise Disconnected, e.message
|
|
473
509
|
end
|
|
474
510
|
|
|
511
|
+
# The longest doc text (bytes) and the most argument names a method may
|
|
512
|
+
# tell in its meta (docs/protocol.md 5).
|
|
513
|
+
DOC_MAX = 2048
|
|
514
|
+
ARGS_MAX = 16
|
|
515
|
+
|
|
516
|
+
# A method's {args:, doc:, arity:} at expose: [the number of arguments
|
|
517
|
+
# checked, its meta fields {"args", "doc"}].
|
|
518
|
+
# @api private
|
|
519
|
+
def self.method_doc(m, h)
|
|
520
|
+
args = nil
|
|
521
|
+
doc = nil
|
|
522
|
+
arity = nil
|
|
523
|
+
h.each do |k, v|
|
|
524
|
+
ks = k.to_s
|
|
525
|
+
if ks == "args" then args = v
|
|
526
|
+
elsif ks == "doc" then doc = v
|
|
527
|
+
elsif ks == "arity" then arity = v
|
|
528
|
+
else raise ArgumentError, "#{m}: unknown key #{ks} (args:, doc:, arity:)"
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
out = {}
|
|
532
|
+
unless args.nil?
|
|
533
|
+
raise ArgumentError, "#{m}: args: expects an Array of names" unless args.is_a?(Array)
|
|
534
|
+
raise ArgumentError, "#{m}: args: at most #{ARGS_MAX} names" if args.size > ARGS_MAX
|
|
535
|
+
names = []
|
|
536
|
+
args.each do |a|
|
|
537
|
+
raise ArgumentError, "#{m}: args: expects names, got #{a.inspect}" unless a.is_a?(String) || a.is_a?(Symbol)
|
|
538
|
+
a = a.to_s
|
|
539
|
+
raise ArgumentError, "#{m}: args: an empty name" if a.empty? || a.bytesize > 64
|
|
540
|
+
names << a
|
|
541
|
+
end
|
|
542
|
+
out["args"] = names
|
|
543
|
+
end
|
|
544
|
+
unless doc.nil?
|
|
545
|
+
raise ArgumentError, "#{m}: doc: expects a String" unless doc.is_a?(String)
|
|
546
|
+
raise ArgumentError, "#{m}: doc: at most #{DOC_MAX} bytes" if doc.bytesize > DOC_MAX
|
|
547
|
+
out["doc"] = doc
|
|
548
|
+
end
|
|
549
|
+
n = if !arity.nil? then arity.to_i
|
|
550
|
+
elsif !args.nil? then args.size
|
|
551
|
+
else -1
|
|
552
|
+
end
|
|
553
|
+
if n >= 0 && !args.nil? && args.size != n
|
|
554
|
+
raise ArgumentError, "#{m}: #{args.size} names in args: for #{n} arguments"
|
|
555
|
+
end
|
|
556
|
+
[n, out]
|
|
557
|
+
end
|
|
558
|
+
|
|
475
559
|
def self.unexpose(name)
|
|
476
560
|
entry = @objects ? @objects.delete(name.to_s) : nil
|
|
477
561
|
entry[2].close if entry
|
|
@@ -532,6 +616,88 @@ module Asterism
|
|
|
532
616
|
self
|
|
533
617
|
end
|
|
534
618
|
|
|
619
|
+
# on_app_join { |node, app| }: an application of another node appeared
|
|
620
|
+
# (its token asterism/<node>/<app>); on_app_leave { |node, app| }: it is
|
|
621
|
+
# gone. A node may run several applications: each comes and goes on its
|
|
622
|
+
# own, and the node stays (on_join / on_leave) while one of them or an
|
|
623
|
+
# object is there. The applications there already join on the first
|
|
624
|
+
# polls after connecting; when the connection is lost, every application
|
|
625
|
+
# known until then leaves. Called from Asterism.poll, as on_join;
|
|
626
|
+
# Asterism.close forgets them.
|
|
627
|
+
def self.on_app_join(&blk)
|
|
628
|
+
raise ArgumentError, "on_app_join needs a block" unless blk
|
|
629
|
+
(@on_app_join ||= []) << blk
|
|
630
|
+
self
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def self.on_app_leave(&blk)
|
|
634
|
+
raise ArgumentError, "on_app_leave needs a block" unless blk
|
|
635
|
+
(@on_app_leave ||= []) << blk
|
|
636
|
+
self
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def self.off_app_join(blk)
|
|
640
|
+
@on_app_join.delete(blk) if @on_app_join
|
|
641
|
+
self
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def self.off_app_leave(blk)
|
|
645
|
+
@on_app_leave.delete(blk) if @on_app_leave
|
|
646
|
+
self
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Compares the applications alive now with those told before and calls
|
|
650
|
+
# the on_app_* blocks for the difference (this application left out).
|
|
651
|
+
# @api private
|
|
652
|
+
def self.tell_apps
|
|
653
|
+
known = @known_apps
|
|
654
|
+
return if known.nil?
|
|
655
|
+
now = {}
|
|
656
|
+
if @session
|
|
657
|
+
own = "#{@node}/#{@app}"
|
|
658
|
+
keys = @apps.keys
|
|
659
|
+
i = 0
|
|
660
|
+
while i < keys.size
|
|
661
|
+
now[keys[i]] = true unless keys[i] == own
|
|
662
|
+
i += 1
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
joined = []
|
|
666
|
+
left = []
|
|
667
|
+
keys = now.keys
|
|
668
|
+
i = 0
|
|
669
|
+
while i < keys.size
|
|
670
|
+
joined << keys[i] unless known[keys[i]]
|
|
671
|
+
i += 1
|
|
672
|
+
end
|
|
673
|
+
keys = known.keys
|
|
674
|
+
i = 0
|
|
675
|
+
while i < keys.size
|
|
676
|
+
left << keys[i] unless now[keys[i]]
|
|
677
|
+
i += 1
|
|
678
|
+
end
|
|
679
|
+
@known_apps = @session ? now : nil
|
|
680
|
+
tell_apps_each("on_app_join", @on_app_join, joined)
|
|
681
|
+
tell_apps_each("on_app_leave", @on_app_leave, left)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# @api private
|
|
685
|
+
def self.tell_apps_each(where, blocks, names)
|
|
686
|
+
return if blocks.nil? || names.empty?
|
|
687
|
+
i = 0
|
|
688
|
+
while i < names.size
|
|
689
|
+
cut = names[i].index("/")
|
|
690
|
+
node = names[i][0, cut]
|
|
691
|
+
app = names[i][cut + 1, names[i].size]
|
|
692
|
+
j = 0
|
|
693
|
+
while j < blocks.size
|
|
694
|
+
tell(where, blocks[j], node, app)
|
|
695
|
+
j += 1
|
|
696
|
+
end
|
|
697
|
+
i += 1
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
535
701
|
# Compares the nodes alive now with those told before and calls the
|
|
536
702
|
# blocks for the difference.
|
|
537
703
|
# @api private
|
|
@@ -542,6 +708,7 @@ module Asterism
|
|
|
542
708
|
# the connection is neither new nor lost): nothing can have changed.
|
|
543
709
|
return unless @nodes_changed
|
|
544
710
|
@nodes_changed = false
|
|
711
|
+
tell_apps
|
|
545
712
|
now = []
|
|
546
713
|
now_set = {}
|
|
547
714
|
if @session
|
|
@@ -595,10 +762,15 @@ module Asterism
|
|
|
595
762
|
end
|
|
596
763
|
end
|
|
597
764
|
|
|
598
|
-
# One block for one node (CRuby routes what it
|
|
765
|
+
# One block for one node, or one application (CRuby routes what it
|
|
766
|
+
# raises to on_error).
|
|
599
767
|
# @api private
|
|
600
|
-
def self.tell(_where, blk, node)
|
|
601
|
-
|
|
768
|
+
def self.tell(_where, blk, node, app = nil)
|
|
769
|
+
if app.nil?
|
|
770
|
+
blk.call(node)
|
|
771
|
+
else
|
|
772
|
+
blk.call(node, app)
|
|
773
|
+
end
|
|
602
774
|
end
|
|
603
775
|
|
|
604
776
|
# @api private
|
|
@@ -612,6 +784,7 @@ module Asterism
|
|
|
612
784
|
back_again(s)
|
|
613
785
|
answer_calls
|
|
614
786
|
answer_profile
|
|
787
|
+
answer_status
|
|
615
788
|
follow_liveliness
|
|
616
789
|
!@session.nil?
|
|
617
790
|
rescue Asterism::Zenoh::Error => e
|
|
@@ -619,14 +792,16 @@ module Asterism
|
|
|
619
792
|
!@session.nil?
|
|
620
793
|
end
|
|
621
794
|
|
|
622
|
-
# After the binding opened a lost connection again: tell the profile
|
|
623
|
-
# again (a put made while the router was away reached nobody).
|
|
795
|
+
# After the binding opened a lost connection again: tell the profile and
|
|
796
|
+
# the status again (a put made while the router was away reached nobody).
|
|
624
797
|
# @api private
|
|
625
798
|
def self.back_again(s)
|
|
626
799
|
now = s.respond_to?(:reconnecting?) && s.reconnecting?
|
|
627
800
|
was = @was_reconnecting
|
|
628
801
|
@was_reconnecting = now
|
|
629
|
-
|
|
802
|
+
return unless was && !now
|
|
803
|
+
tell_profile
|
|
804
|
+
tell_status(true)
|
|
630
805
|
end
|
|
631
806
|
|
|
632
807
|
# A Zenoh error during a call or a poll: the connection is gone only when
|
|
@@ -924,8 +1099,16 @@ module Asterism
|
|
|
924
1099
|
entry = @objects[name]
|
|
925
1100
|
return nil unless entry
|
|
926
1101
|
list = []
|
|
927
|
-
entry[
|
|
928
|
-
|
|
1102
|
+
info = entry[4] || {}
|
|
1103
|
+
entry[1].each do |m, n|
|
|
1104
|
+
e = { "name" => m, "arity" => n }
|
|
1105
|
+
extra = info[m]
|
|
1106
|
+
extra.each { |k, v| e[k] = v } if extra
|
|
1107
|
+
list << e
|
|
1108
|
+
end
|
|
1109
|
+
out = { "methods" => list }
|
|
1110
|
+
out["doc"] = entry[5] if entry[5]
|
|
1111
|
+
out
|
|
929
1112
|
end
|
|
930
1113
|
|
|
931
1114
|
MALFORMED = "malformed call (expected a map {\"method\" => name, \"args\" => [...], \"kwargs\" => {...}})"
|