omq-cli 0.1.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.
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class BaseRunner
6
+ attr_reader :config, :sock
7
+
8
+
9
+ def initialize(config, socket_class)
10
+ @config = config
11
+ @klass = socket_class
12
+ @fmt = Formatter.new(config.format, compress: config.compress)
13
+ end
14
+
15
+
16
+ def call(task)
17
+ @sock = create_socket
18
+ attach_endpoints
19
+ setup_curve
20
+ setup_subscriptions
21
+ compile_expr
22
+
23
+ if config.transient
24
+ start_disconnect_monitor(task)
25
+ Async::Task.current.yield # let monitor start waiting
26
+ end
27
+
28
+ sleep(config.delay) if config.delay && config.recv_only?
29
+ wait_for_peer if needs_peer_wait?
30
+
31
+ @sock.instance_exec(&@send_begin_proc) if @send_begin_proc
32
+ @sock.instance_exec(&@recv_begin_proc) if @recv_begin_proc
33
+ run_loop(task)
34
+ @sock.instance_exec(&@send_end_proc) if @send_end_proc
35
+ @sock.instance_exec(&@recv_end_proc) if @recv_end_proc
36
+ ensure
37
+ @sock&.close
38
+ end
39
+
40
+
41
+ private
42
+
43
+
44
+ # Subclasses override this.
45
+ def run_loop(task)
46
+ raise NotImplementedError
47
+ end
48
+
49
+ # ── Socket creation ─────────────────────────────────────────────
50
+
51
+
52
+ def create_socket
53
+ sock_opts = { linger: config.linger }
54
+ sock_opts[:conflate] = true if config.conflate && %w[pub radio].include?(config.type_name)
55
+ sock = @klass.new(**sock_opts)
56
+ sock.recv_timeout = config.timeout if config.timeout
57
+ sock.send_timeout = config.timeout if config.timeout
58
+ sock.reconnect_interval = config.reconnect_ivl if config.reconnect_ivl
59
+ sock.heartbeat_interval = config.heartbeat_ivl if config.heartbeat_ivl
60
+ sock.identity = config.identity if config.identity
61
+ sock.router_mandatory = true if config.type_name == "router"
62
+ sock
63
+ end
64
+
65
+
66
+ def attach_endpoints
67
+ config.binds.each do |url|
68
+ @sock.bind(url)
69
+ log "Bound to #{@sock.last_endpoint}"
70
+ end
71
+ config.connects.each do |url|
72
+ @sock.connect(url)
73
+ log "Connecting to #{url}"
74
+ end
75
+ end
76
+
77
+ # ── Peer wait with grace period ─────────────────────────────────
78
+
79
+
80
+ def needs_peer_wait?
81
+ !config.recv_only? && (config.connects.any? || config.type_name == "router")
82
+ end
83
+
84
+
85
+ def wait_for_peer
86
+ with_timeout(config.timeout) do
87
+ @sock.peer_connected.wait
88
+ log "Peer connected"
89
+ if %w[pub xpub].include?(config.type_name)
90
+ @sock.subscriber_joined.wait
91
+ log "Subscriber joined"
92
+ end
93
+
94
+ # Grace period: when multiple peers may be connecting (bind or
95
+ # multiple connect URLs), wait one reconnect interval so
96
+ # latecomers finish their handshake before we start sending.
97
+ if config.binds.any? || config.connects.size > 1
98
+ ri = @sock.options.reconnect_interval
99
+ sleep(ri.is_a?(Range) ? ri.begin : ri)
100
+ end
101
+ end
102
+ end
103
+
104
+ # ── Transient disconnect monitor ────────────────────────────────
105
+
106
+
107
+ def start_disconnect_monitor(task)
108
+ @transient_barrier = Async::Promise.new
109
+ task.async do
110
+ @transient_barrier.wait
111
+ @sock.all_peers_gone.wait unless @sock.connection_count == 0
112
+ log "All peers disconnected, exiting"
113
+ @sock.reconnect_enabled = false
114
+ if config.send_only?
115
+ task.stop
116
+ else
117
+ @sock.close_read
118
+ end
119
+ end
120
+ end
121
+
122
+
123
+ def transient_ready!
124
+ if config.transient && !@transient_barrier.resolved?
125
+ @transient_barrier.resolve(true)
126
+ end
127
+ end
128
+
129
+ # ── Timeout helper ──────────────────────────────────────────────
130
+
131
+
132
+ def with_timeout(seconds)
133
+ if seconds
134
+ Async::Task.current.with_timeout(seconds) { yield }
135
+ else
136
+ yield
137
+ end
138
+ end
139
+
140
+ # ── Socket setup ────────────────────────────────────────────────
141
+
142
+
143
+ def setup_subscriptions
144
+ case config.type_name
145
+ when "sub"
146
+ prefixes = config.subscribes.empty? ? [""] : config.subscribes
147
+ prefixes.each { |p| @sock.subscribe(p) }
148
+ when "dish"
149
+ config.joins.each { |g| @sock.join(g) }
150
+ end
151
+ end
152
+
153
+
154
+ def setup_curve
155
+ server_key_z85 = config.curve_server_key || ENV["OMQ_SERVER_KEY"]
156
+ server_mode = config.curve_server || (ENV["OMQ_SERVER_PUBLIC"] && ENV["OMQ_SERVER_SECRET"])
157
+
158
+ return unless server_key_z85 || server_mode
159
+
160
+ crypto = load_curve_crypto(config.curve_crypto || ENV["OMQ_CURVE_CRYPTO"])
161
+ require "protocol/zmtp/mechanism/curve"
162
+
163
+ if server_key_z85
164
+ server_key = Protocol::ZMTP::Z85.decode(server_key_z85)
165
+ client_key = crypto::PrivateKey.generate
166
+ @sock.mechanism = Protocol::ZMTP::Mechanism::Curve.client(
167
+ client_key.public_key.to_s, client_key.to_s,
168
+ server_key: server_key, crypto: crypto
169
+ )
170
+ elsif server_mode
171
+ if ENV["OMQ_SERVER_PUBLIC"] && ENV["OMQ_SERVER_SECRET"]
172
+ server_pub = Protocol::ZMTP::Z85.decode(ENV["OMQ_SERVER_PUBLIC"])
173
+ server_sec = Protocol::ZMTP::Z85.decode(ENV["OMQ_SERVER_SECRET"])
174
+ else
175
+ key = crypto::PrivateKey.generate
176
+ server_pub = key.public_key.to_s
177
+ server_sec = key.to_s
178
+ end
179
+ @sock.mechanism = Protocol::ZMTP::Mechanism::Curve.server(
180
+ server_pub, server_sec, crypto: crypto
181
+ )
182
+ $stderr.puts "OMQ_SERVER_KEY='#{Protocol::ZMTP::Z85.encode(server_pub)}'"
183
+ end
184
+ end
185
+
186
+
187
+ def load_curve_crypto(name)
188
+ case name&.downcase
189
+ when "rbnacl" then require "rbnacl"; RbNaCl
190
+ when "nuckle" then require "nuckle"; Nuckle
191
+ when nil
192
+ begin
193
+ require "rbnacl"; RbNaCl
194
+ rescue LoadError
195
+ abort "CURVE requires a crypto backend. Install rbnacl (recommended):\n" \
196
+ " gem install rbnacl # requires system libsodium\n" \
197
+ "Or use pure Ruby (not audited):\n" \
198
+ " --curve-crypto nuckle\n" \
199
+ " # or: OMQ_CURVE_CRYPTO=nuckle"
200
+ end
201
+ else
202
+ abort "Unknown CURVE crypto backend: #{name}. Use 'rbnacl' or 'nuckle'."
203
+ end
204
+ rescue LoadError
205
+ abort "Could not load #{name} gem: gem install #{name}"
206
+ end
207
+
208
+ # ── Shared loop bodies ──────────────────────────────────────────
209
+
210
+
211
+ def run_send_logic
212
+ n = config.count
213
+ i = 0
214
+ sleep(config.delay) if config.delay
215
+ if config.interval
216
+ i += send_tick
217
+ unless @send_tick_eof || (n && n > 0 && i >= n)
218
+ Async::Loop.quantized(interval: config.interval) do
219
+ i += send_tick
220
+ break if @send_tick_eof || (n && n > 0 && i >= n)
221
+ end
222
+ end
223
+ elsif config.data || config.file
224
+ parts = eval_send_expr(read_next)
225
+ send_msg(parts) if parts
226
+ elsif stdin_ready?
227
+ loop do
228
+ parts = read_next
229
+ break unless parts
230
+ parts = eval_send_expr(parts)
231
+ send_msg(parts) if parts
232
+ i += 1
233
+ break if n && n > 0 && i >= n
234
+ end
235
+ elsif @send_eval_proc
236
+ parts = eval_send_expr(nil)
237
+ send_msg(parts) if parts
238
+ end
239
+ end
240
+
241
+
242
+ def send_tick
243
+ raw = read_next_or_nil
244
+ if raw.nil? && !@send_eval_proc
245
+ @send_tick_eof = true
246
+ return 0
247
+ end
248
+ parts = eval_send_expr(raw)
249
+ send_msg(parts) if parts
250
+ 1
251
+ end
252
+
253
+
254
+ def run_recv_logic
255
+ n = config.count
256
+ i = 0
257
+ loop do
258
+ parts = recv_msg
259
+ break if parts.nil?
260
+ parts = eval_recv_expr(parts)
261
+ output(parts)
262
+ i += 1
263
+ break if n && n > 0 && i >= n
264
+ end
265
+ end
266
+
267
+
268
+ def wait_for_loops(receiver, sender)
269
+ if config.data || config.file || config.send_expr || config.recv_expr || config.target
270
+ sender.wait
271
+ receiver.stop
272
+ elsif config.count && config.count > 0
273
+ receiver.wait
274
+ sender.stop
275
+ else
276
+ sender.wait
277
+ receiver.stop
278
+ end
279
+ end
280
+
281
+ # ── Message I/O ─────────────────────────────────────────────────
282
+
283
+
284
+ def send_msg(parts)
285
+ return if parts.empty?
286
+ parts = [Marshal.dump(parts)] if config.format == :marshal
287
+ parts = @fmt.compress(parts)
288
+ @sock.send(parts)
289
+ transient_ready!
290
+ end
291
+
292
+
293
+ def recv_msg
294
+ raw = @sock.receive
295
+ return nil if raw.nil?
296
+ parts = @fmt.decompress(raw)
297
+ parts = Marshal.load(parts.first) if config.format == :marshal
298
+ transient_ready!
299
+ parts
300
+ end
301
+
302
+
303
+ def recv_msg_raw
304
+ msg = @sock.receive
305
+ msg&.dup
306
+ end
307
+
308
+
309
+ def read_next
310
+ if config.data
311
+ @fmt.decode(config.data + "\n")
312
+ elsif config.file
313
+ @file_data ||= (config.file == "-" ? $stdin.read : File.read(config.file)).chomp
314
+ @fmt.decode(@file_data + "\n")
315
+ elsif config.format == :msgpack
316
+ @fmt.decode_msgpack($stdin)
317
+ elsif config.format == :marshal
318
+ @fmt.decode_marshal($stdin)
319
+ elsif config.format == :raw
320
+ data = $stdin.read
321
+ return nil if data.nil? || data.empty?
322
+ [data]
323
+ else
324
+ line = $stdin.gets
325
+ return nil if line.nil?
326
+ @fmt.decode(line)
327
+ end
328
+ end
329
+
330
+
331
+ def stdin_ready?
332
+ return @stdin_ready unless @stdin_ready.nil?
333
+
334
+ @stdin_ready = !$stdin.closed? &&
335
+ !config.stdin_is_tty &&
336
+ IO.select([$stdin], nil, nil, 0.01) &&
337
+ !$stdin.eof?
338
+ end
339
+
340
+
341
+ def read_next_or_nil
342
+ if config.data || config.file
343
+ read_next
344
+ elsif @send_eval_proc
345
+ nil
346
+ else
347
+ read_next
348
+ end
349
+ end
350
+
351
+
352
+ def output(parts)
353
+ return if config.quiet || parts.nil?
354
+ $stdout.write(@fmt.encode(parts))
355
+ $stdout.flush
356
+ end
357
+
358
+ # ── Routing helpers ─────────────────────────────────────────────
359
+
360
+
361
+ def display_routing_id(id)
362
+ if id.bytes.all? { |b| b >= 0x20 && b <= 0x7E }
363
+ id
364
+ else
365
+ "0x#{id.unpack1("H*")}"
366
+ end
367
+ end
368
+
369
+
370
+ def resolve_target(target)
371
+ if target.start_with?("0x")
372
+ [target[2..].delete(" ")].pack("H*")
373
+ else
374
+ target
375
+ end
376
+ end
377
+
378
+ # ── Eval ────────────────────────────────────────────────────────
379
+
380
+
381
+ def compile_expr
382
+ compile_one_expr(:send, config.send_expr)
383
+ compile_one_expr(:recv, config.recv_expr)
384
+ @send_eval_proc ||= wrap_registered_proc(OMQ.outgoing_proc)
385
+ @recv_eval_proc ||= wrap_registered_proc(OMQ.incoming_proc)
386
+ end
387
+
388
+
389
+ def wrap_registered_proc(block)
390
+ return unless block
391
+ proc do |msg|
392
+ $_ = msg&.first
393
+ block.call(msg)
394
+ end
395
+ end
396
+
397
+
398
+ def compile_one_expr(direction, src)
399
+ return unless src
400
+ expr, begin_body, end_body = extract_blocks(src)
401
+ instance_variable_set(:"@#{direction}_begin_proc", eval("proc { #{begin_body} }")) if begin_body
402
+ instance_variable_set(:"@#{direction}_end_proc", eval("proc { #{end_body} }")) if end_body
403
+ if expr && !expr.strip.empty?
404
+ instance_variable_set(:"@#{direction}_eval_proc", eval("proc { $_ = $F&.first; #{expr} }"))
405
+ end
406
+ end
407
+
408
+
409
+ def extract_blocks(expr)
410
+ begin_body = end_body = nil
411
+ expr, begin_body = extract_block(expr, "BEGIN")
412
+ expr, end_body = extract_block(expr, "END")
413
+ [expr, begin_body, end_body]
414
+ end
415
+
416
+
417
+ def extract_block(expr, keyword)
418
+ start = expr.index(/#{keyword}\s*\{/)
419
+ return [expr, nil] unless start
420
+
421
+ # Find the opening brace
422
+ i = expr.index("{", start)
423
+ depth = 1
424
+ j = i + 1
425
+ while j < expr.length && depth > 0
426
+ case expr[j]
427
+ when "{" then depth += 1
428
+ when "}" then depth -= 1
429
+ end
430
+ j += 1
431
+ end
432
+
433
+ body = expr[(i + 1)..(j - 2)]
434
+ trimmed = expr[0...start] + expr[j..]
435
+ [trimmed, body]
436
+ end
437
+
438
+
439
+ SENT = Object.new.freeze # sentinel: eval already sent the reply
440
+
441
+ def eval_send_expr(parts)
442
+ return parts unless @send_eval_proc
443
+ run_eval(@send_eval_proc, parts)
444
+ end
445
+
446
+
447
+ def eval_recv_expr(parts)
448
+ return parts unless @recv_eval_proc
449
+ run_eval(@recv_eval_proc, parts)
450
+ end
451
+
452
+
453
+ def run_eval(eval_proc, parts)
454
+ $F = parts
455
+ result = @sock.instance_exec(parts, &eval_proc)
456
+ return nil if result.nil?
457
+ return SENT if result.equal?(@sock)
458
+ return [result] if config.format == :marshal
459
+ case result
460
+ when Array then result
461
+ when String then [result]
462
+ else [result.to_str]
463
+ end
464
+ rescue => e
465
+ $stderr.puts "omq: eval error: #{e.message} (#{e.class})"
466
+ exit 3
467
+ end
468
+
469
+ # ── Logging ─────────────────────────────────────────────────────
470
+
471
+
472
+ def log(msg)
473
+ $stderr.puts(msg) if config.verbose
474
+ end
475
+ end
476
+ end
477
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class ChannelRunner < PairRunner
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ class ClientRunner < ReqRunner
6
+ end
7
+
8
+
9
+ class ServerRunner < BaseRunner
10
+ private
11
+
12
+
13
+ def run_loop(task)
14
+ if config.echo || config.recv_expr || @recv_eval_proc || config.data || config.file || !config.stdin_is_tty
15
+ reply_loop
16
+ else
17
+ monitor_loop(task)
18
+ end
19
+ end
20
+
21
+
22
+ def reply_loop
23
+ n = config.count
24
+ i = 0
25
+ loop do
26
+ parts = recv_msg_raw
27
+ break if parts.nil?
28
+ routing_id = parts.shift
29
+ body = @fmt.decompress(parts)
30
+
31
+ if config.recv_expr || @recv_eval_proc
32
+ reply = eval_recv_expr(body)
33
+ output([display_routing_id(routing_id), *(reply || [""])])
34
+ @sock.send_to(routing_id, @fmt.compress(reply || [""]).first)
35
+ elsif config.echo
36
+ output([display_routing_id(routing_id), *body])
37
+ @sock.send_to(routing_id, @fmt.compress(body).first || "")
38
+ elsif config.data || config.file || !config.stdin_is_tty
39
+ reply = read_next
40
+ break unless reply
41
+ output([display_routing_id(routing_id), *body])
42
+ @sock.send_to(routing_id, @fmt.compress(reply).first || "")
43
+ end
44
+ i += 1
45
+ break if n && n > 0 && i >= n
46
+ end
47
+ end
48
+
49
+
50
+ def monitor_loop(task)
51
+ receiver = task.async do
52
+ n = config.count
53
+ i = 0
54
+ loop do
55
+ parts = recv_msg_raw
56
+ break if parts.nil?
57
+ routing_id = parts.shift
58
+ parts = @fmt.decompress(parts)
59
+ result = eval_recv_expr([display_routing_id(routing_id), *parts])
60
+ output(result)
61
+ i += 1
62
+ break if n && n > 0 && i >= n
63
+ end
64
+ end
65
+
66
+ sender = task.async do
67
+ n = config.count
68
+ i = 0
69
+ sleep(config.delay) if config.delay
70
+ if config.interval
71
+ Async::Loop.quantized(interval: config.interval) do
72
+ parts = read_next
73
+ break unless parts
74
+ send_targeted_or_eval(parts)
75
+ i += 1
76
+ break if n && n > 0 && i >= n
77
+ end
78
+ elsif config.data || config.file
79
+ parts = read_next
80
+ send_targeted_or_eval(parts) if parts
81
+ else
82
+ loop do
83
+ parts = read_next
84
+ break unless parts
85
+ send_targeted_or_eval(parts)
86
+ i += 1
87
+ break if n && n > 0 && i >= n
88
+ end
89
+ end
90
+ end
91
+
92
+ wait_for_loops(receiver, sender)
93
+ end
94
+
95
+
96
+ def send_targeted_or_eval(parts)
97
+ if @send_eval_proc
98
+ parts = eval_send_expr(parts)
99
+ return unless parts
100
+ routing_id = resolve_target(parts.shift)
101
+ @sock.send_to(routing_id, @fmt.compress(parts).first || "")
102
+ elsif config.target
103
+ parts = @fmt.compress(parts)
104
+ @sock.send_to(resolve_target(config.target), parts.first || "")
105
+ else
106
+ send_msg(parts)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ SEND_ONLY = %w[pub push scatter radio].freeze
6
+ RECV_ONLY = %w[sub pull gather dish].freeze
7
+
8
+ Endpoint = Data.define(:url, :bind?) do
9
+ def connect? = !bind?
10
+ end
11
+
12
+
13
+ Config = Data.define(
14
+ :type_name,
15
+ :endpoints,
16
+ :connects,
17
+ :binds,
18
+ :in_endpoints,
19
+ :out_endpoints,
20
+ :data,
21
+ :file,
22
+ :format,
23
+ :subscribes,
24
+ :joins,
25
+ :group,
26
+ :identity,
27
+ :target,
28
+ :interval,
29
+ :count,
30
+ :delay,
31
+ :timeout,
32
+ :linger,
33
+ :reconnect_ivl,
34
+ :heartbeat_ivl,
35
+ :conflate,
36
+ :compress,
37
+ :send_expr,
38
+ :recv_expr,
39
+ :parallel,
40
+ :transient,
41
+ :verbose,
42
+ :quiet,
43
+ :echo,
44
+ :curve_server,
45
+ :curve_server_key,
46
+ :curve_crypto,
47
+ :has_msgpack,
48
+ :has_zstd,
49
+ :stdin_is_tty,
50
+ ) do
51
+ def send_only? = SEND_ONLY.include?(type_name)
52
+ def recv_only? = RECV_ONLY.include?(type_name)
53
+ end
54
+ end
55
+ end