asterism 0.7.0 → 0.8.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 +78 -0
- data/README.md +208 -8
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/survey.rb +204 -0
- data/lib/asterism/cli.rb +424 -0
- data/lib/asterism/cruby/credentials.rb +97 -0
- data/lib/asterism/cruby/objects.rb +72 -10
- data/lib/asterism/cruby/ros.rb +1 -1
- data/lib/asterism/cruby/zenoh.rb +13 -2
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +2 -2
- data/mrblib/asterism.rb +242 -24
- data/mrblib/credentials.rb +197 -0
- data/mrblib/profile.rb +1 -1
- data/mrblib/status.rb +198 -0
- metadata +22 -6
data/lib/asterism/cli.rb
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# The command line of the gem (exe/asterism): look at an Asterism network
|
|
2
|
+
# from CRuby, and call an object when asked to.
|
|
3
|
+
#
|
|
4
|
+
# asterism nodes --router tcp/192.0.2.2:7447
|
|
5
|
+
# asterism tree --credentials admin.tar
|
|
6
|
+
# asterism objects 'games/games/*'
|
|
7
|
+
# asterism watch # joins, leaves, statuses as they come
|
|
8
|
+
# asterism ping games/games/othello1 # the time of a round trip (meta)
|
|
9
|
+
# asterism call games/games/othello1 view '"<token>"'
|
|
10
|
+
# asterism mcp --allow 'games/othello1.*' # an MCP server on stdio
|
|
11
|
+
#
|
|
12
|
+
# It is one more Asterism node: it connects with Asterism.connect (as the
|
|
13
|
+
# certificate's name with --credentials, as cli-<6 hex digits> otherwise)
|
|
14
|
+
# and reads what docs/protocol.md lets anyone read. It calls a method only
|
|
15
|
+
# with `call` (and the MCP bridge only the methods --allow names). What it
|
|
16
|
+
# can see is what the router lets it see: with a console's .tar, its role.
|
|
17
|
+
require "asterism"
|
|
18
|
+
require "optparse"
|
|
19
|
+
require "json"
|
|
20
|
+
require "securerandom"
|
|
21
|
+
require_relative "cli/literal"
|
|
22
|
+
require_relative "cli/survey"
|
|
23
|
+
require_relative "cli/allow"
|
|
24
|
+
|
|
25
|
+
module Asterism
|
|
26
|
+
class CLI
|
|
27
|
+
USAGE = <<~TEXT
|
|
28
|
+
Usage: asterism COMMAND [options]
|
|
29
|
+
|
|
30
|
+
Commands (they only read, except call):
|
|
31
|
+
nodes the nodes, their applications and profiles
|
|
32
|
+
objects [PATTERN] the objects and their methods (PATTERN: node/app/object, * and **)
|
|
33
|
+
tree nodes > applications (with their status) > objects (with their methods)
|
|
34
|
+
watch nodes, applications and objects coming and going, and status changes
|
|
35
|
+
ping PATH round trips: PATH node/app/object (its meta), or node (its profile);
|
|
36
|
+
--call METHOD [ARG...] times a call instead (echo is the same command)
|
|
37
|
+
call PATH METHOD [ARG...]
|
|
38
|
+
calls a method; each ARG is JSON, else a Ruby literal, else text
|
|
39
|
+
mcp an MCP server on stdio (needs the mcp gem): the listings as tools,
|
|
40
|
+
and the methods --allow names
|
|
41
|
+
version the gem's version
|
|
42
|
+
|
|
43
|
+
Where the network is (one of them):
|
|
44
|
+
--router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
|
|
45
|
+
--credentials TAR a console's .tar (mutual TLS; its router and name)
|
|
46
|
+
TEXT
|
|
47
|
+
|
|
48
|
+
def self.start(argv, out: $stdout, err: $stderr)
|
|
49
|
+
new(out: out, err: err).run(argv.dup)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(out: $stdout, err: $stderr)
|
|
53
|
+
@out = out
|
|
54
|
+
@err = err
|
|
55
|
+
@opts = { timeout: 2.0, settle: 1.5, json: false, count: 5, interval: 0.2, allow: [] }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(argv)
|
|
59
|
+
cmd = argv.shift
|
|
60
|
+
case cmd
|
|
61
|
+
when nil, "help", "-h", "--help"
|
|
62
|
+
@out.puts USAGE
|
|
63
|
+
return cmd.nil? ? 2 : 0
|
|
64
|
+
when "version", "--version", "-v"
|
|
65
|
+
@out.puts "asterism #{::Asterism::VERSION}"
|
|
66
|
+
return 0
|
|
67
|
+
end
|
|
68
|
+
unless %w[nodes objects tree watch ping echo call mcp].include?(cmd)
|
|
69
|
+
@err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
|
|
70
|
+
return 2
|
|
71
|
+
end
|
|
72
|
+
rest = parser(cmd).parse(argv)
|
|
73
|
+
send("cmd_#{cmd == 'echo' ? 'ping' : cmd}", rest)
|
|
74
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
75
|
+
@err.puts "asterism: #{e.message}"
|
|
76
|
+
2
|
|
77
|
+
rescue ::Asterism::Disconnected => e
|
|
78
|
+
@err.puts "asterism: cannot reach the network: #{e.message}"
|
|
79
|
+
1
|
|
80
|
+
rescue ::Asterism::Error => e
|
|
81
|
+
@err.puts "asterism: #{e.class}: #{e.message}"
|
|
82
|
+
1
|
|
83
|
+
rescue Interrupt
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parser(cmd)
|
|
88
|
+
OptionParser.new do |o|
|
|
89
|
+
o.banner = "Usage: asterism #{cmd} [options]"
|
|
90
|
+
o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
|
|
91
|
+
o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
|
|
92
|
+
o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
|
|
93
|
+
o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
|
|
94
|
+
o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
|
|
95
|
+
o.on("--json", "JSON instead of text (watch: one JSON object a line)") { @opts[:json] = true }
|
|
96
|
+
o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
|
|
97
|
+
o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
|
|
98
|
+
case cmd
|
|
99
|
+
when "watch"
|
|
100
|
+
o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
|
|
101
|
+
when "ping", "echo"
|
|
102
|
+
o.on("--count N", Integer, "round trips (5)") { @opts[:count] = _1 }
|
|
103
|
+
o.on("--interval SECONDS", Float, "pause between them (0.2)") { @opts[:interval] = _1 }
|
|
104
|
+
o.on("--call METHOD", "time a call of METHOD (with the ARGs after PATH) instead of the meta") { @opts[:call] = _1 }
|
|
105
|
+
when "call"
|
|
106
|
+
o.on("--kwargs VALUE", "keyword arguments: a JSON object or a Ruby Hash literal") { @opts[:kwargs] = _1 }
|
|
107
|
+
when "mcp"
|
|
108
|
+
o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
|
|
109
|
+
"(repeatable)") { @opts[:allow] << _1 }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ------------------------------------------------------------ connecting
|
|
115
|
+
|
|
116
|
+
# Connects, starts the receiving thread, waits for the tokens and yields
|
|
117
|
+
# the Net and a Survey.
|
|
118
|
+
def connect(app_prefix: "cli")
|
|
119
|
+
router = @opts[:router]
|
|
120
|
+
creds = @opts[:credentials]
|
|
121
|
+
peer = @opts[:peer]
|
|
122
|
+
if router.nil? && creds.nil? && peer.nil?
|
|
123
|
+
raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar"
|
|
124
|
+
end
|
|
125
|
+
node = @opts[:node]
|
|
126
|
+
node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
|
|
127
|
+
app = @opts[:app] || "#{app_prefix}-#{SecureRandom.hex(2)}"
|
|
128
|
+
kw = { node: node, app: app }
|
|
129
|
+
kw[:credentials] = creds if creds
|
|
130
|
+
locator = router
|
|
131
|
+
if peer
|
|
132
|
+
locator = peer
|
|
133
|
+
kw[:mode] = :peer
|
|
134
|
+
end
|
|
135
|
+
::Asterism.profile(name: app_prefix == "mcp" ? "asterism MCP" : "asterism CLI")
|
|
136
|
+
::Asterism.connect(locator, **kw) do |net|
|
|
137
|
+
net.on_error { |e, where| @err.puts "asterism: #{where}: #{e.class}: #{e.message}" }
|
|
138
|
+
net.start
|
|
139
|
+
survey = Survey.new(timeout: @opts[:timeout])
|
|
140
|
+
survey.settle(@opts[:settle])
|
|
141
|
+
yield net, survey
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def emit_json(v)
|
|
146
|
+
@out.puts JSON.pretty_generate(v)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------ nodes
|
|
150
|
+
|
|
151
|
+
def cmd_nodes(_rest)
|
|
152
|
+
connect do |_net, sv|
|
|
153
|
+
tree = sv.tree(meta: false)
|
|
154
|
+
if @opts[:json]
|
|
155
|
+
emit_json(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
|
|
156
|
+
elsif tree.empty?
|
|
157
|
+
@out.puts "(no other node is visible)"
|
|
158
|
+
else
|
|
159
|
+
tree.each do |n|
|
|
160
|
+
@out.puts "#{n['node']}#{profile_text(n['profile'])}"
|
|
161
|
+
n["apps"].each do |a|
|
|
162
|
+
@out.puts " #{a['app']}#{" #{a['objects'].size} objects" unless a['objects'].empty?}" \
|
|
163
|
+
"#{" status #{compact(a['status'])}" if a.key?('status')}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
0
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------ objects
|
|
172
|
+
|
|
173
|
+
def cmd_objects(rest)
|
|
174
|
+
pattern = rest.shift || "**"
|
|
175
|
+
connect do |_net, sv|
|
|
176
|
+
paths = sv.objects(pattern)
|
|
177
|
+
metas = sv.metas(paths)
|
|
178
|
+
if @opts[:json]
|
|
179
|
+
emit_json(paths.map do |p|
|
|
180
|
+
h = { "path" => p, "methods" => metas[p] }
|
|
181
|
+
h["doc"] = sv.object_doc(p) if sv.object_doc(p)
|
|
182
|
+
h
|
|
183
|
+
end)
|
|
184
|
+
elsif paths.empty?
|
|
185
|
+
@out.puts "(no object matches #{pattern})"
|
|
186
|
+
else
|
|
187
|
+
paths.each do |p|
|
|
188
|
+
@out.puts "#{p} #{metas.key?(p) ? methods_text(metas[p]) : '(no meta: not answered in time, or not allowed)'}"
|
|
189
|
+
@out.puts " #{sv.object_doc(p)}" if sv.object_doc(p)
|
|
190
|
+
# The methods that tell what they do, one a line (doc's first line).
|
|
191
|
+
(metas[p] || []).each do |m|
|
|
192
|
+
@out.puts " #{signature(m)} #{m['doc'].lines.first.strip}" if m["doc"]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
0
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------ tree
|
|
201
|
+
|
|
202
|
+
def cmd_tree(_rest)
|
|
203
|
+
connect do |_net, sv|
|
|
204
|
+
tree = sv.tree
|
|
205
|
+
if @opts[:json]
|
|
206
|
+
emit_json(tree)
|
|
207
|
+
elsif tree.empty?
|
|
208
|
+
@out.puts "(no other node is visible)"
|
|
209
|
+
else
|
|
210
|
+
tree.each do |n|
|
|
211
|
+
@out.puts "#{n['node']}#{profile_text(n['profile'])}"
|
|
212
|
+
n["apps"].each do |a|
|
|
213
|
+
@out.puts " #{a['app']}#{" status #{compact(a['status'])}" if a.key?('status')}"
|
|
214
|
+
a["objects"].each do |o|
|
|
215
|
+
@out.puts " #{o['name']} #{o['methods'] ? methods_text(o['methods']) : '(no meta)'}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
0
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------ watch
|
|
225
|
+
|
|
226
|
+
# Prints the changes until Ctrl-C (or --duration): nodes (Asterism's
|
|
227
|
+
# on_join / on_leave), applications and objects (their liveliness
|
|
228
|
+
# tokens), statuses (their puts) and profiles (their puts). The
|
|
229
|
+
# statuses there at the start come first.
|
|
230
|
+
def cmd_watch(_rest)
|
|
231
|
+
connect do |net, sv|
|
|
232
|
+
own = "#{net.node_id}/#{net.app}"
|
|
233
|
+
sv.statuses.sort.each { |path, st| event("status", path, status: st, now: true) }
|
|
234
|
+
net.on_join { |n| event("join", n, what: "node") }
|
|
235
|
+
net.on_leave { |n| event("leave", n, what: "node") }
|
|
236
|
+
net.session.liveliness_watch("asterism/**") do |key, alive|
|
|
237
|
+
# The tokens alive at the start come first, in one burst: not news.
|
|
238
|
+
next unless @watch_started
|
|
239
|
+
path = key.delete_prefix("asterism/")
|
|
240
|
+
next if path == own || path.start_with?("#{own}/")
|
|
241
|
+
what = { 2 => "app", 3 => "object" }[path.count("/") + 1]
|
|
242
|
+
event(alive ? "join" : "leave", path, what: what) if what
|
|
243
|
+
end
|
|
244
|
+
net.session.subscribe(Survey::STATUS_ALL) do |sample|
|
|
245
|
+
got = ::Asterism::Status.parse(sample.key, sample.payload)
|
|
246
|
+
event("status", "#{got[0]}/#{got[1]}", status: got[2]) if got && "#{got[0]}/#{got[1]}" != own
|
|
247
|
+
end
|
|
248
|
+
net.session.subscribe(Survey::PROFILES_ALL) do |sample|
|
|
249
|
+
node = sample.key.split("/")[1]
|
|
250
|
+
pr = ::Asterism::Profile.check(::Asterism::Codec.unpack(sample.payload))
|
|
251
|
+
event("profile", node, profile: pr) if pr && node != net.node_id
|
|
252
|
+
end
|
|
253
|
+
sleep 0.3
|
|
254
|
+
@watch_started = true
|
|
255
|
+
@err.puts "asterism: watching (Ctrl-C to stop)" unless @opts[:json]
|
|
256
|
+
deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
|
|
257
|
+
loop do
|
|
258
|
+
break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
259
|
+
break unless net.connected?
|
|
260
|
+
sleep 0.05
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
0
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def event(kind, path, what: nil, status: nil, profile: nil, now: false)
|
|
267
|
+
t = Time.now
|
|
268
|
+
if @opts[:json]
|
|
269
|
+
h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
|
|
270
|
+
h["what"] = what if what
|
|
271
|
+
h["status"] = status if status
|
|
272
|
+
h["profile"] = profile if profile
|
|
273
|
+
h["now"] = true if now
|
|
274
|
+
@out.puts JSON.generate(h)
|
|
275
|
+
else
|
|
276
|
+
mark = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }[kind]
|
|
277
|
+
text = case kind
|
|
278
|
+
when "status" then "status #{path} #{compact(status)}#{' (now)' if now}"
|
|
279
|
+
when "profile" then "profile #{path}#{profile_text(profile)}"
|
|
280
|
+
else "#{what} #{path}"
|
|
281
|
+
end
|
|
282
|
+
@out.puts "#{t.strftime('%H:%M:%S.%L')} #{mark} #{text}"
|
|
283
|
+
end
|
|
284
|
+
@out.flush
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# ------------------------------------------------------------ ping
|
|
288
|
+
|
|
289
|
+
# Round trips to PATH: the meta of an object (no method runs), the
|
|
290
|
+
# profile of a node, or with --call a method (with the ARGs given).
|
|
291
|
+
def cmd_ping(rest)
|
|
292
|
+
path = rest.shift or raise ArgumentError, "ping: which node or object? (asterism ping node/app/object)"
|
|
293
|
+
args = rest.map { Literal.parse(_1) }
|
|
294
|
+
chunks = path.split("/")
|
|
295
|
+
raise ArgumentError, "ping: PATH is node or node/app/object" unless [1, 3].include?(chunks.size)
|
|
296
|
+
raise ArgumentError, "ping: --call needs node/app/object" if @opts[:call] && chunks.size != 3
|
|
297
|
+
what = @opts[:call] ? "call #{@opts[:call]}" : (chunks.size == 3 ? "meta" : "profile")
|
|
298
|
+
times = []
|
|
299
|
+
connect do |_net, _sv|
|
|
300
|
+
ms = (@opts[:timeout] * 1000).round
|
|
301
|
+
@opts[:count].times do |i|
|
|
302
|
+
sleep @opts[:interval] if i.positive?
|
|
303
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
304
|
+
ok = begin
|
|
305
|
+
ping_once(path, chunks, args, ms)
|
|
306
|
+
true
|
|
307
|
+
rescue ::Asterism::RemoteError => e
|
|
308
|
+
@err.puts "asterism: #{path}: #{e.remote_kind || 'error'}: #{e.message}" if i.zero?
|
|
309
|
+
true # it answered
|
|
310
|
+
rescue ::Asterism::TimeoutError => e
|
|
311
|
+
@err.puts "asterism: #{e.message}"
|
|
312
|
+
false
|
|
313
|
+
end
|
|
314
|
+
dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
|
|
315
|
+
times << dt if ok
|
|
316
|
+
@out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless @opts[:json]
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
if @opts[:json]
|
|
320
|
+
emit_json({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
|
|
321
|
+
"ms" => times.map { _1.round(2) } })
|
|
322
|
+
elsif times.any?
|
|
323
|
+
@out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
|
|
324
|
+
times.min, times.sum / times.size, times.max)
|
|
325
|
+
end
|
|
326
|
+
times.empty? ? 1 : 0
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def ping_once(path, chunks, args, ms)
|
|
330
|
+
if @opts[:call]
|
|
331
|
+
::Asterism[path, timeout_ms: ms].asterism_call(@opts[:call], *args)
|
|
332
|
+
elsif chunks.size == 3
|
|
333
|
+
raw = ::Asterism.meta(path, ms)
|
|
334
|
+
raise ::Asterism::Error, "malformed meta from #{path}" unless raw.is_a?(Hash)
|
|
335
|
+
else
|
|
336
|
+
::Asterism.profile_async(path, false, nil, ms).raw_value
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------ call
|
|
341
|
+
|
|
342
|
+
def cmd_call(rest)
|
|
343
|
+
path = rest.shift
|
|
344
|
+
method = rest.shift
|
|
345
|
+
raise ArgumentError, "call: asterism call node/app/object METHOD [ARG...]" unless path && method
|
|
346
|
+
::Asterism.split_path(path)
|
|
347
|
+
args = rest.map { Literal.parse(_1) }
|
|
348
|
+
kwargs = @opts[:kwargs] ? Literal.parse(@opts[:kwargs]) : {}
|
|
349
|
+
raise ArgumentError, "--kwargs: expected a JSON object or a Ruby Hash" unless kwargs.is_a?(Hash)
|
|
350
|
+
kwargs = kwargs.transform_keys(&:to_sym)
|
|
351
|
+
code = 0
|
|
352
|
+
connect do |_net, _sv|
|
|
353
|
+
px = ::Asterism[path, timeout: @opts[:timeout]]
|
|
354
|
+
begin
|
|
355
|
+
v = px.asterism_call(method, *args, **kwargs)
|
|
356
|
+
@opts[:json] ? @out.puts(JSON.generate({ "value" => json_safe(v) })) : @out.puts(v.inspect)
|
|
357
|
+
rescue ::Asterism::RemoteError => e
|
|
358
|
+
if @opts[:json]
|
|
359
|
+
@out.puts JSON.generate({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
|
|
360
|
+
"message" => e.remote_message } })
|
|
361
|
+
else
|
|
362
|
+
@err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
|
|
363
|
+
end
|
|
364
|
+
code = 1
|
|
365
|
+
rescue ::Asterism::TimeoutError => e
|
|
366
|
+
@err.puts "asterism: #{e.message}" \
|
|
367
|
+
"#{"\n (with a console's .tar, the router refuses a call its role does not allow; " \
|
|
368
|
+
'it looks the same as no answer)' if @opts[:credentials]}"
|
|
369
|
+
code = 1
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
code
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------ mcp
|
|
376
|
+
|
|
377
|
+
def cmd_mcp(_rest)
|
|
378
|
+
begin
|
|
379
|
+
require_relative "cli/mcp"
|
|
380
|
+
rescue LoadError => e
|
|
381
|
+
raise unless e.message.include?("mcp")
|
|
382
|
+
@err.puts "asterism mcp needs the mcp gem: gem install mcp (#{e.message})"
|
|
383
|
+
return 1
|
|
384
|
+
end
|
|
385
|
+
connect(app_prefix: "mcp") do |net, sv|
|
|
386
|
+
MCPBridge.new(net, sv, allow: @opts[:allow], log: @err).run
|
|
387
|
+
end
|
|
388
|
+
0
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# ------------------------------------------------------------ text
|
|
392
|
+
|
|
393
|
+
def profile_text(pr)
|
|
394
|
+
return "" unless pr
|
|
395
|
+
parts = [pr["kind"], pr["host"]].compact.join("/")
|
|
396
|
+
name = pr["name"] ? " \"#{pr['name']}\"" : ""
|
|
397
|
+
" (#{parts})#{name}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def methods_text(list)
|
|
401
|
+
list.map { |m| signature(m) }.join(" ")
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# name(arg, arg) with the names the meta tells, else name(2) / name(*).
|
|
405
|
+
def signature(m)
|
|
406
|
+
return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
|
|
407
|
+
m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def compact(v)
|
|
411
|
+
JSON.generate(json_safe(v))
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# A value as JSON can hold it (bytes as a note).
|
|
415
|
+
def json_safe(v)
|
|
416
|
+
case v
|
|
417
|
+
when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
|
|
418
|
+
when Array then v.map { json_safe(_1) }
|
|
419
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
420
|
+
else v
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
|
3
|
+
# an Asterism console hands out when it issues a certificate ("Save all
|
|
4
|
+
# (.tar)" on the certificate page; the archive is made in the browser that
|
|
5
|
+
# made the key). It holds, at its top or in one directory:
|
|
6
|
+
#
|
|
7
|
+
# <name>.key the private key (PKCS#8 PEM)
|
|
8
|
+
# <name>.pem the certificate (its common name is <name>)
|
|
9
|
+
# ca.pem the CA that signed it (and the router's certificate)
|
|
10
|
+
# zenoh.json5 an example configuration (for zenohd or another client)
|
|
11
|
+
# asterism.json {"name": ..., "router": "tls/host:port", "not_after": ..., ...}
|
|
12
|
+
#
|
|
13
|
+
# Asterism.connect(credentials: "alice.tar", app: "game") { |net| ... }
|
|
14
|
+
# Asterism::Zenoh.open(credentials: "alice.tar") { |s| ... }
|
|
15
|
+
# Asterism::ROS.connect(credentials: "alice.tar") { |ros| ... }
|
|
16
|
+
#
|
|
17
|
+
# The file is read into memory (Gem::Package::TarReader, the standard
|
|
18
|
+
# library): nothing is written anywhere. The certificates go to zenoh as
|
|
19
|
+
# base64 (transport/link/tls/*_base64) with enable_mtls, the router's
|
|
20
|
+
# locator and the node ID come from asterism.json unless given, and a file
|
|
21
|
+
# that others may read is warned about, as ssh does with a key.
|
|
22
|
+
require "rubygems/package"
|
|
23
|
+
require "json"
|
|
24
|
+
require "stringio"
|
|
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).
|
|
31
|
+
class Credentials
|
|
32
|
+
module CRuby
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def b64(pem)
|
|
36
|
+
[pem].pack("m0")
|
|
37
|
+
end
|
|
38
|
+
|
|
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
|
|
49
|
+
|
|
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
|
|
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})"
|
|
70
|
+
end
|
|
71
|
+
|
|
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
|
|
80
|
+
|
|
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
|
|
94
|
+
end
|
|
95
|
+
prepend CRuby
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -26,8 +26,8 @@ module Asterism
|
|
|
26
26
|
# The portable API, entered under LOCK. Prepended to the Asterism module's
|
|
27
27
|
# singleton class, so the shared methods themselves are not changed.
|
|
28
28
|
module Locked
|
|
29
|
-
%i[expose unexpose poll nodes [] call_async meta connected? exposed on_join on_leave off_join off_leave
|
|
30
|
-
profile profile_async session].each do |m|
|
|
29
|
+
%i[expose unexpose poll nodes [] call_async meta connected? reconnecting? exposed on_join on_leave off_join off_leave
|
|
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
|
|
@@ -36,8 +36,16 @@ module Asterism
|
|
|
36
36
|
# With a block: yields an Asterism::Net and closes when the block ends
|
|
37
37
|
# (also on an exception); returns the block's value. Without a block,
|
|
38
38
|
# as before (returns Asterism).
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
#
|
|
40
|
+
# credentials: a .tar from an Asterism console (Asterism::Credentials):
|
|
41
|
+
# its certificates go to zenoh (mutual TLS), and the locator and node:
|
|
42
|
+
# default to its router and name (given ones win).
|
|
43
|
+
def connect(locator = nil, node: nil, app:, mode: nil, listen: nil, config: nil, connect_timeout: nil,
|
|
44
|
+
check_timeout: nil, credentials: nil, &blk)
|
|
45
|
+
if credentials
|
|
46
|
+
locator, config, creds = ::Asterism::Credentials.merge(credentials, locator, config)
|
|
47
|
+
node ||= creds.name
|
|
48
|
+
end
|
|
41
49
|
::Asterism::LOCK.synchronize do
|
|
42
50
|
super(locator, node: node, app: app, mode: mode, listen: listen, config: config,
|
|
43
51
|
connect_timeout: connect_timeout, check_timeout: check_timeout)
|
|
@@ -65,9 +73,10 @@ module Asterism
|
|
|
65
73
|
::Asterism::LOCK.synchronize { super }
|
|
66
74
|
end
|
|
67
75
|
|
|
68
|
-
# The on_join / on_leave blocks (called from
|
|
69
|
-
# what they raise goes to its on_error and
|
|
70
|
-
|
|
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)
|
|
71
80
|
net = @net
|
|
72
81
|
return super unless net
|
|
73
82
|
net.runner.guard(where) { super }
|
|
@@ -148,16 +157,18 @@ module Asterism
|
|
|
148
157
|
def node_id = ::Asterism.node_id
|
|
149
158
|
def app = ::Asterism.app
|
|
150
159
|
def connected? = ::Asterism.connected?
|
|
160
|
+
def reconnecting? = ::Asterism.reconnecting?
|
|
151
161
|
def lost_reason = ::Asterism.lost_reason
|
|
152
162
|
def nodes = ::Asterism.nodes
|
|
153
163
|
def exposed = ::Asterism.exposed
|
|
154
164
|
|
|
155
165
|
# Asterism.expose: methods: an Array of names or a Hash name => number
|
|
156
|
-
# 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
|
|
157
168
|
# keyword. The object's methods run on the receiving thread (or in
|
|
158
169
|
# run, or in whatever polls).
|
|
159
|
-
def expose(name, obj, methods:, caller: nil)
|
|
160
|
-
::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)
|
|
161
172
|
end
|
|
162
173
|
|
|
163
174
|
# Asterism.current_call: the call being answered on this thread, or nil.
|
|
@@ -178,6 +189,34 @@ module Asterism
|
|
|
178
189
|
::Asterism.profile_of(node, icon: icon, timeout: timeout, timeout_ms: timeout_ms)
|
|
179
190
|
end
|
|
180
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
|
+
|
|
181
220
|
# A proxy for <node>/<app>/<object>; timeout: in seconds (or
|
|
182
221
|
# timeout_ms:).
|
|
183
222
|
def [](path, timeout: nil, timeout_ms: nil)
|
|
@@ -208,6 +247,29 @@ module Asterism
|
|
|
208
247
|
self
|
|
209
248
|
end
|
|
210
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
|
+
|
|
211
273
|
# Removes a block given to on_join / on_leave (keep the Proc).
|
|
212
274
|
def off_join(blk)
|
|
213
275
|
::Asterism.off_join(blk)
|
data/lib/asterism/cruby/ros.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Asterism
|
|
|
27
27
|
# Opens a Zenoh session and makes a Connection for nodes on it. domain:
|
|
28
28
|
# the ROS domain of the nodes made by ros.node. type_path: directories
|
|
29
29
|
# with generated types to add to Asterism::ROS::TYPE_PATH. The other
|
|
30
|
-
# options go to Asterism::Zenoh
|
|
30
|
+
# options (credentials: among them) go to Asterism::Zenoh.open. With a block: yields the
|
|
31
31
|
# Connection, closes it when the block ends and returns its value.
|
|
32
32
|
def self.connect(locator = nil, domain: 0, type_path: nil, interval: Runner::DEFAULT_INTERVAL, **opts)
|
|
33
33
|
Array(type_path).each { |d| TYPE_PATH << File.expand_path(d) unless TYPE_PATH.include?(File.expand_path(d)) }
|