leopard 0.2.8 → 0.2.10
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/.release-please-manifest.json +1 -1
- data/.version.txt +1 -1
- data/CHANGELOG.md +14 -0
- data/Readme.adoc +26 -0
- data/documents/enable-injectable-request-reply-callbacks.adoc +52 -0
- data/examples/groups.rb +32 -0
- data/exe/leopard-service-info +20 -0
- data/exe/leopard-service-map +29 -0
- data/exe/leopard-service-stats +34 -0
- data/exe/leopard-services +27 -0
- data/lib/leopard/nats_api_server.rb +5 -1
- data/lib/leopard/nats_request_reply_callbacks.rb +8 -13
- data/lib/leopard/nats_service_discovery/cli.rb +208 -0
- data/lib/leopard/nats_service_discovery/operation/collect.rb +35 -0
- data/lib/leopard/nats_service_discovery/operation/connect.rb +53 -0
- data/lib/leopard/nats_service_discovery/operation/discovery_options.rb +31 -0
- data/lib/leopard/nats_service_discovery/operation/info.rb +25 -0
- data/lib/leopard/nats_service_discovery/operation/ping.rb +25 -0
- data/lib/leopard/nats_service_discovery/operation/services.rb +52 -0
- data/lib/leopard/nats_service_discovery/operation/stats.rb +25 -0
- data/lib/leopard/nats_service_discovery/operation/subject_map.rb +83 -0
- data/lib/leopard/nats_service_discovery/operation/verb.rb +46 -0
- data/lib/leopard/nats_service_discovery/operation.rb +23 -0
- data/lib/leopard/nats_service_discovery.rb +179 -0
- data/lib/leopard/version.rb +1 -1
- metadata +40 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61ef512c45c7a77049bcb82926d8f77f30986d3e63cad9f500f6bfc729968448
|
|
4
|
+
data.tar.gz: b27aeba2b9a28e286420dfe5a32be1cfc22792cd7d68c317178b7b463adbf9d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b49256564d284e559c04e52bc689eeb5113d9ddc7d3fa4279c5d6bcca19501bc297595ab2f7481eb12a47b3cf7dffbe5a98fa7e7fc57f32357023ebc1296bc8c
|
|
7
|
+
data.tar.gz: dd3a974237972a781b51aa24ba3295e4be1ac49a56d2a9e5f3dc678db716cb7ec3f7813ce5a41e3eb224129ea7b795c7c01efa863ca991a050e69c026dd920c4
|
data/.version.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.
|
|
1
|
+
0.2.10
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.10](https://github.com/rubyists/leopard/compare/v0.2.9...v0.2.10) (2026-09-16)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add NATS service discovery tools ([#58](https://github.com/rubyists/leopard/issues/58)) ([#59](https://github.com/rubyists/leopard/issues/59)) ([382d5e7](https://github.com/rubyists/leopard/commit/382d5e7e19367796145bec07e4bda686268905ae))
|
|
9
|
+
|
|
10
|
+
## [0.2.9](https://github.com/rubyists/leopard/compare/v0.2.8...v0.2.9) (2026-07-23)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **deps:** remove upper bound from semantic_logger dependency ([#54](https://github.com/rubyists/leopard/issues/54)) ([0b9f154](https://github.com/rubyists/leopard/commit/0b9f154f1b2ec1961dbb176954551b6f9caf16f0))
|
|
16
|
+
|
|
3
17
|
## [0.2.8](https://github.com/rubyists/leopard/compare/v0.2.7...v0.2.8) (2026-05-19)
|
|
4
18
|
|
|
5
19
|
|
data/Readme.adoc
CHANGED
|
@@ -4,6 +4,7 @@ bougyman <me@bougyman.com>
|
|
|
4
4
|
:conventional-commits: https://www.conventionalcommits.org/en/v1.0.0/[Conventional Commits]
|
|
5
5
|
:dry-configurable: https://github.com/dry-rb/dry-configurable[Dry::Configurable]
|
|
6
6
|
:dry-monads: https://github.com/dry-rb/dry-monads[Dry::Monads]
|
|
7
|
+
:jetstream-consumer-config: https://docs.nats.io/nats-concepts/jetstream/consumers#configuration[Jetstream Consumer Configuration]
|
|
7
8
|
|
|
8
9
|
Leopard is a small framework for building concurrent {service-api} workers.
|
|
9
10
|
It uses `Concurrent::FixedThreadPool` to manage multiple workers in a single process and provides a
|
|
@@ -91,6 +92,24 @@ end
|
|
|
91
92
|
EchoService.use LoggerMiddleware
|
|
92
93
|
----
|
|
93
94
|
|
|
95
|
+
== Request/Reply Failure Logging
|
|
96
|
+
|
|
97
|
+
Request/reply endpoints log failed handler results at error level by default.
|
|
98
|
+
To change only that decision while keeping Leopard's existing callbacks and
|
|
99
|
+
error responses, configure a policy that accepts the failure payload:
|
|
100
|
+
|
|
101
|
+
[source,ruby]
|
|
102
|
+
----
|
|
103
|
+
EchoService.config.request_reply_failure_log_policy = lambda do |failure|
|
|
104
|
+
next if failure.status.between?(400, 499)
|
|
105
|
+
|
|
106
|
+
EchoService.logger.error 'Error processing message: ', failure
|
|
107
|
+
end
|
|
108
|
+
----
|
|
109
|
+
|
|
110
|
+
The policy is called before Leopard responds with the failure payload. It must
|
|
111
|
+
not change the response: every failure still uses `respond_with_error`.
|
|
112
|
+
|
|
94
113
|
== JetStream Pull Consumers
|
|
95
114
|
|
|
96
115
|
Leopard can also bind JetStream pull consumers through the same middleware and `Dry::Monads::Result`
|
|
@@ -116,6 +135,13 @@ class EventConsumer
|
|
|
116
135
|
end
|
|
117
136
|
----
|
|
118
137
|
|
|
138
|
+
Any configuration options available for JetStream consumers can be passed via snakeified keys
|
|
139
|
+
in the `consumer` hash, with the exception of `durable_name`, `filter_subject` and `ack_policy`.
|
|
140
|
+
`durable_name` and `filter_subject` are both derived from the endpoint, and `ack_policy` is always
|
|
141
|
+
set to `explicit`.
|
|
142
|
+
|
|
143
|
+
{jetstream-consumer-config}
|
|
144
|
+
|
|
119
145
|
JetStream handlers receive the same `Rubyists::Leopard::MessageWrapper` as service endpoints.
|
|
120
146
|
Leopard will:
|
|
121
147
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
= Enable Injectable Request/Reply Failure Logging
|
|
2
|
+
|
|
3
|
+
== Goal
|
|
4
|
+
|
|
5
|
+
Allow a service to change failure logging without replacing Leopard's
|
|
6
|
+
request/reply callbacks. Applications can treat expected client failures, such
|
|
7
|
+
as failures with particular statuses, differently from operational failures
|
|
8
|
+
while preserving the existing response behavior.
|
|
9
|
+
|
|
10
|
+
== Current behavior
|
|
11
|
+
|
|
12
|
+
Every `Dry::Monads::Failure` is currently logged at error level before its
|
|
13
|
+
payload is returned through `MessageWrapper#respond_with_error`. An application
|
|
14
|
+
cannot change that logging policy without overriding Leopard internals.
|
|
15
|
+
|
|
16
|
+
== Plan
|
|
17
|
+
|
|
18
|
+
. Add a `:request_reply_failure_log_policy` service setting. When present, the
|
|
19
|
+
policy receives each failure payload before Leopard returns it to the client.
|
|
20
|
+
. Keep `NatsRequestReplyCallbacks` as the callback owner. Its default policy
|
|
21
|
+
logs at error level, preserving current behavior when no policy is configured.
|
|
22
|
+
. Pass the configured policy to `NatsRequestReplyCallbacks` when the worker
|
|
23
|
+
constructs its memoized callback helper.
|
|
24
|
+
. Document that policies must leave `respond_with_error` behavior unchanged.
|
|
25
|
+
|
|
26
|
+
== Suggested application policy
|
|
27
|
+
|
|
28
|
+
Configure a policy directly. Leopard owns the callback mapping and invokes the
|
|
29
|
+
policy only for failed handler results.
|
|
30
|
+
|
|
31
|
+
[source,ruby]
|
|
32
|
+
----
|
|
33
|
+
MyService.config.request_reply_failure_log_policy = lambda do |failure|
|
|
34
|
+
next if failure.status.between?(400, 499)
|
|
35
|
+
|
|
36
|
+
MyService.logger.error 'Error processing message: ', failure
|
|
37
|
+
end
|
|
38
|
+
----
|
|
39
|
+
|
|
40
|
+
Use the status accessor provided by the application's failure type (or another
|
|
41
|
+
application-specific classifier). The policy must not change the response:
|
|
42
|
+
Leopard still calls `wrapper.respond_with_error(failure)` for every failure.
|
|
43
|
+
|
|
44
|
+
== Acceptance criteria
|
|
45
|
+
|
|
46
|
+
* Existing request/reply services log and respond exactly as before without
|
|
47
|
+
configuration.
|
|
48
|
+
* A service can configure failure logging without monkey-patching, inheriting
|
|
49
|
+
from, or replacing Leopard's callbacks.
|
|
50
|
+
* A configured policy can select a log level, or no log entry, for any failure
|
|
51
|
+
status while returning the same error payload to the requester.
|
|
52
|
+
* Successes and exceptions continue to follow their existing callback paths.
|
data/examples/groups.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../lib/leopard/nats_api_server'
|
|
5
|
+
|
|
6
|
+
# Example to echo the given message
|
|
7
|
+
class ServiceGroups
|
|
8
|
+
include Rubyists::Leopard::NatsApiServer
|
|
9
|
+
|
|
10
|
+
def initialize(a_var: 1)
|
|
11
|
+
logger.info "ServiceGroups initialized with a_var: #{a_var}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
group :mammal
|
|
15
|
+
group :feline, group: :mammal, queue: :meow
|
|
16
|
+
endpoint(:sound, group: :feline) { |msg| Success("Meow! #{msg.data}") }
|
|
17
|
+
endpoint(:fail, group: :feline) { |msg| Failure({ reason: 'cat nap', data: msg.data }) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if __FILE__ == $PROGRAM_NAME
|
|
21
|
+
SemanticLogger.default_level = :info
|
|
22
|
+
SemanticLogger.add_appender(io: $stdout, formatter: :color)
|
|
23
|
+
ServiceGroups.run(
|
|
24
|
+
nats_url: 'nats://localhost:4222',
|
|
25
|
+
service_opts: {
|
|
26
|
+
name: 'example.groups',
|
|
27
|
+
version: '1.0.0',
|
|
28
|
+
instance_args: { a_var: 2 },
|
|
29
|
+
},
|
|
30
|
+
instances: 1,
|
|
31
|
+
)
|
|
32
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
5
|
+
|
|
6
|
+
require 'leopard/nats_service_discovery/cli'
|
|
7
|
+
|
|
8
|
+
cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
|
|
9
|
+
opts = cli.parse(
|
|
10
|
+
ARGV,
|
|
11
|
+
banner: 'Usage: leopard-service-info [options]',
|
|
12
|
+
description: 'Prints raw $SRV.INFO responses as JSON.',
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
cli.with_client(opts) do |client|
|
|
16
|
+
result = cli.operation_result!(
|
|
17
|
+
Rubyists::Leopard::NatsServiceDiscovery::Operation::Info.call(**cli.discovery_options(opts, client)),
|
|
18
|
+
)
|
|
19
|
+
cli.print_json(result[:responses])
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
5
|
+
|
|
6
|
+
require 'leopard/nats_service_discovery/cli'
|
|
7
|
+
|
|
8
|
+
cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
|
|
9
|
+
opts = cli.parse(
|
|
10
|
+
ARGV,
|
|
11
|
+
banner: 'Usage: leopard-service-map [options]',
|
|
12
|
+
description: 'Builds a subject-to-endpoint map from $SRV.INFO responses.',
|
|
13
|
+
json: true,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
cli.with_client(opts) do |client|
|
|
17
|
+
result = cli.operation_result!(
|
|
18
|
+
Rubyists::Leopard::NatsServiceDiscovery::Operation::SubjectMap.call(**cli.discovery_options(opts, client)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if opts[:json]
|
|
22
|
+
cli.print_json(result[:subject_map])
|
|
23
|
+
else
|
|
24
|
+
rows = result[:subject_map].flat_map do |subject, listeners|
|
|
25
|
+
listeners.map { |listener| [subject, *listener.values_at('service', 'service_id', 'version', 'endpoint', 'queue_group')] }
|
|
26
|
+
end
|
|
27
|
+
cli.print_table(%w[SUBJECT SERVICE SERVICE_ID VERSION ENDPOINT QUEUE], rows)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
5
|
+
|
|
6
|
+
require 'leopard/nats_service_discovery/cli'
|
|
7
|
+
|
|
8
|
+
cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
|
|
9
|
+
opts = cli.parse(
|
|
10
|
+
ARGV,
|
|
11
|
+
banner: 'Usage: leopard-service-stats [options]',
|
|
12
|
+
description: 'Prints service endpoint stats from $SRV.STATS responses.',
|
|
13
|
+
json: true,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
cli.with_client(opts) do |client|
|
|
17
|
+
result = cli.operation_result!(
|
|
18
|
+
Rubyists::Leopard::NatsServiceDiscovery::Operation::Stats.call(**cli.discovery_options(opts, client)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if opts[:json]
|
|
22
|
+
cli.print_json(result[:responses])
|
|
23
|
+
else
|
|
24
|
+
rows = result[:responses].flat_map do |service|
|
|
25
|
+
Array(service['endpoints']).map do |endpoint|
|
|
26
|
+
[
|
|
27
|
+
service['name'], service['id'], endpoint['subject'], endpoint['name'], endpoint['queue_group'],
|
|
28
|
+
endpoint['num_requests'], endpoint['num_errors'], endpoint['average_processing_time'], endpoint['last_error']
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
cli.print_table(%w[SERVICE SERVICE_ID SUBJECT ENDPOINT QUEUE REQUESTS ERRORS AVG_NS LAST_ERROR], rows)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
5
|
+
|
|
6
|
+
require 'leopard/nats_service_discovery/cli'
|
|
7
|
+
|
|
8
|
+
cli = Rubyists::Leopard::NatsServiceDiscovery::CLI
|
|
9
|
+
opts = cli.parse(
|
|
10
|
+
ARGV,
|
|
11
|
+
banner: 'Usage: leopard-services [options]',
|
|
12
|
+
description: 'Lists NATS Service API services and their endpoint counts.',
|
|
13
|
+
json: true,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
cli.with_client(opts) do |client|
|
|
17
|
+
result = cli.operation_result!(
|
|
18
|
+
Rubyists::Leopard::NatsServiceDiscovery::Operation::Services.call(**cli.discovery_options(opts, client)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
if opts[:json]
|
|
22
|
+
cli.print_json(result[:services])
|
|
23
|
+
else
|
|
24
|
+
rows = result[:services].map { |service| service.values_at('name', 'id', 'version', 'endpoints') }
|
|
25
|
+
cli.print_table(%w[SERVICE ID VERSION ENDPOINTS], rows)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -31,6 +31,7 @@ module Rubyists
|
|
|
31
31
|
base.extend(Dry::Monads[:result])
|
|
32
32
|
base.extend(Dry::Configurable)
|
|
33
33
|
base.setting :logger, default: Rubyists::Leopard.logger, reader: true
|
|
34
|
+
base.setting :request_reply_failure_log_policy, default: nil, reader: true
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
# Configuration for a request/reply endpoint declared with {.endpoint}.
|
|
@@ -472,7 +473,10 @@ module Rubyists
|
|
|
472
473
|
#
|
|
473
474
|
# @return [NatsRequestReplyCallbacks] The request/reply callback helper.
|
|
474
475
|
def request_reply_callbacks
|
|
475
|
-
@request_reply_callbacks ||= NatsRequestReplyCallbacks.new(
|
|
476
|
+
@request_reply_callbacks ||= NatsRequestReplyCallbacks.new(
|
|
477
|
+
logger:,
|
|
478
|
+
failure_log_policy: self.class.request_reply_failure_log_policy,
|
|
479
|
+
)
|
|
476
480
|
end
|
|
477
481
|
|
|
478
482
|
# Returns the memoized message processor for this worker instance.
|
|
@@ -6,11 +6,15 @@ module Rubyists
|
|
|
6
6
|
class NatsRequestReplyCallbacks
|
|
7
7
|
# Builds a callback set for request/reply endpoint outcomes.
|
|
8
8
|
#
|
|
9
|
-
# @param logger [#error] Logger used
|
|
9
|
+
# @param logger [#error] Logger used by the default failure log policy.
|
|
10
|
+
# @param failure_log_policy [#call, nil] Optional policy called with a
|
|
11
|
+
# failure payload before it is returned to the requester.
|
|
10
12
|
#
|
|
11
13
|
# @return [void]
|
|
12
|
-
def initialize(logger:)
|
|
13
|
-
@
|
|
14
|
+
def initialize(logger:, failure_log_policy: nil)
|
|
15
|
+
@failure_log_policy = failure_log_policy || lambda do |failure|
|
|
16
|
+
logger.error 'Error processing message: ', failure
|
|
17
|
+
end
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
# Returns transport callbacks for request/reply endpoints.
|
|
@@ -43,7 +47,7 @@ module Rubyists
|
|
|
43
47
|
#
|
|
44
48
|
# @return [void]
|
|
45
49
|
def respond_with_failure(wrapper, result)
|
|
46
|
-
|
|
50
|
+
@failure_log_policy.call(result.failure)
|
|
47
51
|
wrapper.respond_with_error(result.failure)
|
|
48
52
|
end
|
|
49
53
|
|
|
@@ -56,15 +60,6 @@ module Rubyists
|
|
|
56
60
|
def respond_with_error(wrapper, error)
|
|
57
61
|
wrapper.respond_with_error(error)
|
|
58
62
|
end
|
|
59
|
-
|
|
60
|
-
# Logs the failure payload returned by a handler.
|
|
61
|
-
#
|
|
62
|
-
# @param failure [Object] The failure payload from the handler.
|
|
63
|
-
#
|
|
64
|
-
# @return [void]
|
|
65
|
-
def log_failure(failure)
|
|
66
|
-
@logger.error 'Error processing message: ', failure
|
|
67
|
-
end
|
|
68
63
|
end
|
|
69
64
|
end
|
|
70
65
|
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require_relative 'operation'
|
|
6
|
+
|
|
7
|
+
module Rubyists
|
|
8
|
+
module Leopard
|
|
9
|
+
class NatsServiceDiscovery
|
|
10
|
+
# Shared command-line helpers for NATS service discovery executables.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module CLI
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Parses common NATS discovery CLI options.
|
|
17
|
+
#
|
|
18
|
+
# @param argv [Array<String>] Command-line arguments.
|
|
19
|
+
# @param banner [String] OptionParser banner.
|
|
20
|
+
# @param description [String] Command description.
|
|
21
|
+
# @param json [Boolean] Whether the command supports `--json`.
|
|
22
|
+
#
|
|
23
|
+
# @return [Hash] Parsed options.
|
|
24
|
+
def parse(argv, banner:, description:, json: false)
|
|
25
|
+
opts = default_options(json:)
|
|
26
|
+
OptionParser.new do |parser|
|
|
27
|
+
parser.banner = banner
|
|
28
|
+
parser.separator ''
|
|
29
|
+
parser.separator description
|
|
30
|
+
parser.separator ''
|
|
31
|
+
add_common_options(parser, opts)
|
|
32
|
+
end.parse!(argv)
|
|
33
|
+
validate!(opts)
|
|
34
|
+
opts
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Opens a NATS client for the duration of the provided block.
|
|
38
|
+
#
|
|
39
|
+
# @param opts [Hash] Parsed CLI options.
|
|
40
|
+
# @yieldparam client [NATS::Client] Connected NATS client.
|
|
41
|
+
#
|
|
42
|
+
# @return [Object] The block return value.
|
|
43
|
+
def with_client(opts)
|
|
44
|
+
result = Operation::Connect.call(
|
|
45
|
+
server: opts[:server],
|
|
46
|
+
creds: opts[:creds],
|
|
47
|
+
connect_options: { reconnect: false, connect_timeout: opts[:timeout] },
|
|
48
|
+
)
|
|
49
|
+
abort_with(result[:error]) unless result.success?
|
|
50
|
+
|
|
51
|
+
yield result[:client]
|
|
52
|
+
ensure
|
|
53
|
+
result&.[](:client)&.close
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Prints a value as pretty JSON.
|
|
57
|
+
#
|
|
58
|
+
# @param value [Object] JSON-serializable value.
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
def print_json(value)
|
|
62
|
+
puts JSON.pretty_generate(value)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Prints rows as a simple aligned table.
|
|
66
|
+
#
|
|
67
|
+
# @param headers [Array<String>] Column headers.
|
|
68
|
+
# @param rows [Array<Array>] Table rows.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def print_table(headers, rows)
|
|
72
|
+
widths = column_widths(headers, rows)
|
|
73
|
+
format = widths.map { |width| "%-#{width}s" }.join(' ')
|
|
74
|
+
puts format % headers
|
|
75
|
+
puts widths.map { |width| '-' * width }.join(' ')
|
|
76
|
+
rows.each { |row| puts format % row }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Prints an error and exits with a non-zero status.
|
|
80
|
+
#
|
|
81
|
+
# @param error [Exception] Error to report.
|
|
82
|
+
#
|
|
83
|
+
# @return [void]
|
|
84
|
+
def abort_with(error)
|
|
85
|
+
warn "error: #{error.message}"
|
|
86
|
+
exit 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Returns a successful operation result or aborts the process.
|
|
90
|
+
#
|
|
91
|
+
# @param result [Trailblazer::Operation::Railway::Result] Operation result.
|
|
92
|
+
#
|
|
93
|
+
# @return [Trailblazer::Operation::Railway::Result]
|
|
94
|
+
def operation_result!(result)
|
|
95
|
+
abort_with(result[:error]) unless result.success?
|
|
96
|
+
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Builds discovery operation options from CLI options and a client.
|
|
101
|
+
#
|
|
102
|
+
# @param opts [Hash] Parsed CLI options.
|
|
103
|
+
# @param client [NATS::Client] Connected NATS client.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] Arguments for discovery operations.
|
|
106
|
+
def discovery_options(opts, client)
|
|
107
|
+
{
|
|
108
|
+
client:,
|
|
109
|
+
name: opts[:name],
|
|
110
|
+
id: opts[:id],
|
|
111
|
+
prefix: opts[:prefix],
|
|
112
|
+
timeout: opts[:timeout],
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Builds default CLI options.
|
|
117
|
+
#
|
|
118
|
+
# @param json [Boolean] Whether the command supports `--json`.
|
|
119
|
+
#
|
|
120
|
+
# @return [Hash] Default options.
|
|
121
|
+
def default_options(json:)
|
|
122
|
+
{
|
|
123
|
+
server: ENV.fetch('NATS_URL', 'nats://127.0.0.1:4222'),
|
|
124
|
+
timeout: DEFAULT_TIMEOUT,
|
|
125
|
+
prefix: DEFAULT_PREFIX,
|
|
126
|
+
json: json ? false : nil,
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Adds all common options to an OptionParser.
|
|
131
|
+
#
|
|
132
|
+
# @param parser [OptionParser] Parser to configure.
|
|
133
|
+
# @param opts [Hash] Mutable parsed option accumulator.
|
|
134
|
+
#
|
|
135
|
+
# @return [void]
|
|
136
|
+
def add_common_options(parser, opts)
|
|
137
|
+
add_connection_options(parser, opts)
|
|
138
|
+
add_filter_options(parser, opts)
|
|
139
|
+
add_output_options(parser, opts)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Adds NATS connection options to an OptionParser.
|
|
143
|
+
#
|
|
144
|
+
# @param parser [OptionParser] Parser to configure.
|
|
145
|
+
# @param opts [Hash] Mutable parsed option accumulator.
|
|
146
|
+
#
|
|
147
|
+
# @return [void]
|
|
148
|
+
def add_connection_options(parser, opts)
|
|
149
|
+
parser.on('-s', '--server URL', 'NATS server URL. Defaults to ENV[NATS_URL] or local NATS') do |url|
|
|
150
|
+
opts[:server] = url
|
|
151
|
+
end
|
|
152
|
+
parser.on('--creds FILE', 'NATS user credentials file') { |file| opts[:creds] = file }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Adds service filter options to an OptionParser.
|
|
156
|
+
#
|
|
157
|
+
# @param parser [OptionParser] Parser to configure.
|
|
158
|
+
# @param opts [Hash] Mutable parsed option accumulator.
|
|
159
|
+
#
|
|
160
|
+
# @return [void]
|
|
161
|
+
def add_filter_options(parser, opts)
|
|
162
|
+
parser.on('--name NAME', 'Only query services with this name') { |name| opts[:name] = name }
|
|
163
|
+
parser.on('--id ID', 'Only query this service id. Requires --name') { |id| opts[:id] = id }
|
|
164
|
+
parser.on('--prefix PREFIX', 'Service API prefix. Defaults to $SRV') { |prefix| opts[:prefix] = prefix }
|
|
165
|
+
parser.on('-t', '--timeout SECONDS', Float, 'Reply collection idle timeout') { |timeout| opts[:timeout] = timeout }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Adds output and help options to an OptionParser.
|
|
169
|
+
#
|
|
170
|
+
# @param parser [OptionParser] Parser to configure.
|
|
171
|
+
# @param opts [Hash] Mutable parsed option accumulator.
|
|
172
|
+
#
|
|
173
|
+
# @return [void]
|
|
174
|
+
def add_output_options(parser, opts)
|
|
175
|
+
parser.on('--json', 'Print JSON output') { opts[:json] = true } unless opts[:json].nil?
|
|
176
|
+
parser.on('-h', '--help', 'Show this help') do
|
|
177
|
+
puts parser
|
|
178
|
+
exit
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Validates parsed CLI options.
|
|
183
|
+
#
|
|
184
|
+
# @param opts [Hash] Parsed CLI options.
|
|
185
|
+
#
|
|
186
|
+
# @return [void]
|
|
187
|
+
def validate!(opts)
|
|
188
|
+
return unless opts[:id] && opts[:name].to_s.empty?
|
|
189
|
+
|
|
190
|
+
warn 'error: --id requires --name'
|
|
191
|
+
exit 1
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Computes table column widths.
|
|
195
|
+
#
|
|
196
|
+
# @param headers [Array<String>] Column headers.
|
|
197
|
+
# @param rows [Array<Array>] Table rows.
|
|
198
|
+
#
|
|
199
|
+
# @return [Array<Integer>] Width for each column.
|
|
200
|
+
def column_widths(headers, rows)
|
|
201
|
+
headers.each_index.map do |index|
|
|
202
|
+
([headers[index]] + rows.map { |row| row[index].to_s }).map(&:length).max
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'trailblazer/operation'
|
|
4
|
+
require_relative '../../nats_service_discovery'
|
|
5
|
+
|
|
6
|
+
module Rubyists
|
|
7
|
+
module Leopard
|
|
8
|
+
class NatsServiceDiscovery
|
|
9
|
+
module Operation
|
|
10
|
+
# Collects and parses JSON replies for an arbitrary Service API subject.
|
|
11
|
+
#
|
|
12
|
+
# On success, the result exposes `:responses`.
|
|
13
|
+
# On failure, the result exposes `:error`.
|
|
14
|
+
class Collect < Trailblazer::Operation
|
|
15
|
+
step :collect
|
|
16
|
+
|
|
17
|
+
# Collects JSON replies from `subject`.
|
|
18
|
+
#
|
|
19
|
+
# @param ctx [Hash] Operation context.
|
|
20
|
+
# @param client [NATS::Client] Connected NATS client.
|
|
21
|
+
# @param subject [String] Service API subject to query.
|
|
22
|
+
# @param timeout [Numeric] Idle timeout while waiting for replies.
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] Whether replies were collected successfully.
|
|
25
|
+
def collect(ctx, client:, subject:, timeout: DEFAULT_TIMEOUT, **)
|
|
26
|
+
ctx[:responses] = NatsServiceDiscovery.new(client:).send(:collect_json, subject, timeout:)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
ctx[:error] = e
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nats/client'
|
|
4
|
+
require 'trailblazer/operation'
|
|
5
|
+
require_relative '../../nats_service_discovery'
|
|
6
|
+
|
|
7
|
+
module Rubyists
|
|
8
|
+
module Leopard
|
|
9
|
+
class NatsServiceDiscovery
|
|
10
|
+
module Operation
|
|
11
|
+
# Opens a NATS client connection for discovery operations.
|
|
12
|
+
#
|
|
13
|
+
# On success, the result exposes `:client`.
|
|
14
|
+
# On failure, the result exposes `:error`.
|
|
15
|
+
class Connect < Trailblazer::Operation
|
|
16
|
+
step :connect
|
|
17
|
+
|
|
18
|
+
# Connects to NATS using `:server` or `:nats_url`.
|
|
19
|
+
#
|
|
20
|
+
# @param ctx [Hash] Operation context.
|
|
21
|
+
#
|
|
22
|
+
# @return [Boolean] Whether the connection was opened.
|
|
23
|
+
def connect(ctx, **)
|
|
24
|
+
opts = (ctx[:connect_options] || {}).dup
|
|
25
|
+
opts[:user_credentials] = ctx[:creds] if ctx[:creds]
|
|
26
|
+
ctx[:client] = NATS.connect(connection_url(ctx), opts)
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
ctx[:error] = e
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Default NATS URL used when no URL is provided.
|
|
35
|
+
#
|
|
36
|
+
# @return [String]
|
|
37
|
+
def default_url
|
|
38
|
+
ENV.fetch('NATS_URL', 'nats://127.0.0.1:4222')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolves the NATS URL from the operation context.
|
|
42
|
+
#
|
|
43
|
+
# @param ctx [Hash] Operation context.
|
|
44
|
+
#
|
|
45
|
+
# @return [String]
|
|
46
|
+
def connection_url(ctx)
|
|
47
|
+
ctx[:nats_url] || ctx[:server] || default_url
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../../nats_service_discovery'
|
|
4
|
+
|
|
5
|
+
module Rubyists
|
|
6
|
+
module Leopard
|
|
7
|
+
class NatsServiceDiscovery
|
|
8
|
+
module Operation
|
|
9
|
+
# Shared option extraction for operations that compose other discovery operations.
|
|
10
|
+
module DiscoveryOptions
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Extracts the common discovery options from a Trailblazer context.
|
|
14
|
+
#
|
|
15
|
+
# @param ctx [Hash] Operation context.
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash] Arguments suitable for another discovery operation.
|
|
18
|
+
def operation_options(ctx)
|
|
19
|
+
{
|
|
20
|
+
client: ctx[:client],
|
|
21
|
+
prefix: ctx[:prefix],
|
|
22
|
+
timeout: ctx[:timeout],
|
|
23
|
+
name: ctx[:name],
|
|
24
|
+
id: ctx[:id],
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'verb'
|
|
4
|
+
|
|
5
|
+
module Rubyists
|
|
6
|
+
module Leopard
|
|
7
|
+
class NatsServiceDiscovery
|
|
8
|
+
module Operation
|
|
9
|
+
# Collects `$SRV.INFO` responses.
|
|
10
|
+
#
|
|
11
|
+
# On success, the result exposes `:responses`.
|
|
12
|
+
class Info < Verb
|
|
13
|
+
# Invokes {NatsServiceDiscovery#info}.
|
|
14
|
+
#
|
|
15
|
+
# @param discovery [NatsServiceDiscovery] Discovery helper.
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<Hash>] Parsed info responses.
|
|
18
|
+
def public_send_verb(discovery, **)
|
|
19
|
+
discovery.info(**)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'verb'
|
|
4
|
+
|
|
5
|
+
module Rubyists
|
|
6
|
+
module Leopard
|
|
7
|
+
class NatsServiceDiscovery
|
|
8
|
+
module Operation
|
|
9
|
+
# Collects `$SRV.PING` responses.
|
|
10
|
+
#
|
|
11
|
+
# On success, the result exposes `:responses`.
|
|
12
|
+
class Ping < Verb
|
|
13
|
+
# Invokes {NatsServiceDiscovery#ping}.
|
|
14
|
+
#
|
|
15
|
+
# @param discovery [NatsServiceDiscovery] Discovery helper.
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<Hash>] Parsed ping responses.
|
|
18
|
+
def public_send_verb(discovery, **)
|
|
19
|
+
discovery.ping(**)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'trailblazer/operation'
|
|
4
|
+
require_relative 'discovery_options'
|
|
5
|
+
require_relative 'info'
|
|
6
|
+
|
|
7
|
+
module Rubyists
|
|
8
|
+
module Leopard
|
|
9
|
+
class NatsServiceDiscovery
|
|
10
|
+
module Operation
|
|
11
|
+
# Builds service-list summaries from `$SRV.INFO` responses.
|
|
12
|
+
#
|
|
13
|
+
# On success, the result exposes `:responses` and `:services`.
|
|
14
|
+
class Services < Trailblazer::Operation
|
|
15
|
+
include DiscoveryOptions
|
|
16
|
+
|
|
17
|
+
step :load_info?
|
|
18
|
+
step :summarize
|
|
19
|
+
|
|
20
|
+
# Loads raw service info responses for summarization.
|
|
21
|
+
#
|
|
22
|
+
# @param ctx [Hash] Operation context.
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] Whether service info was loaded.
|
|
25
|
+
def load_info?(ctx, **)
|
|
26
|
+
result = Info.call(**operation_options(ctx))
|
|
27
|
+
ctx[:error] = result[:error] unless result.success?
|
|
28
|
+
ctx[:responses] = result[:responses]
|
|
29
|
+
result.success?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builds compact service summaries from raw info responses.
|
|
33
|
+
#
|
|
34
|
+
# @param ctx [Hash] Operation context.
|
|
35
|
+
# @param responses [Array<Hash>] Parsed `$SRV.INFO` responses.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<Hash>] Service summary rows.
|
|
38
|
+
def summarize(ctx, responses:, **)
|
|
39
|
+
ctx[:services] = responses.map do |service|
|
|
40
|
+
{
|
|
41
|
+
'name' => service['name'],
|
|
42
|
+
'id' => service['id'],
|
|
43
|
+
'version' => service['version'],
|
|
44
|
+
'endpoints' => Array(service['endpoints']).size,
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'verb'
|
|
4
|
+
|
|
5
|
+
module Rubyists
|
|
6
|
+
module Leopard
|
|
7
|
+
class NatsServiceDiscovery
|
|
8
|
+
module Operation
|
|
9
|
+
# Collects `$SRV.STATS` responses.
|
|
10
|
+
#
|
|
11
|
+
# On success, the result exposes `:responses`.
|
|
12
|
+
class Stats < Verb
|
|
13
|
+
# Invokes {NatsServiceDiscovery#stats}.
|
|
14
|
+
#
|
|
15
|
+
# @param discovery [NatsServiceDiscovery] Discovery helper.
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<Hash>] Parsed stats responses.
|
|
18
|
+
def public_send_verb(discovery, **)
|
|
19
|
+
discovery.stats(**)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'trailblazer/operation'
|
|
4
|
+
require_relative 'discovery_options'
|
|
5
|
+
require_relative 'info'
|
|
6
|
+
|
|
7
|
+
module Rubyists
|
|
8
|
+
module Leopard
|
|
9
|
+
class NatsServiceDiscovery
|
|
10
|
+
module Operation
|
|
11
|
+
# Builds a subject-to-endpoint listener map from `$SRV.INFO` responses.
|
|
12
|
+
#
|
|
13
|
+
# On success, the result exposes `:responses` and `:subject_map`.
|
|
14
|
+
class SubjectMap < Trailblazer::Operation
|
|
15
|
+
include DiscoveryOptions
|
|
16
|
+
|
|
17
|
+
step :load_info?
|
|
18
|
+
step :map_subjects
|
|
19
|
+
|
|
20
|
+
# Loads raw service info responses for mapping.
|
|
21
|
+
#
|
|
22
|
+
# @param ctx [Hash] Operation context.
|
|
23
|
+
#
|
|
24
|
+
# @return [Boolean] Whether service info was loaded.
|
|
25
|
+
def load_info?(ctx, **)
|
|
26
|
+
result = Info.call(**operation_options(ctx))
|
|
27
|
+
ctx[:error] = result[:error] unless result.success?
|
|
28
|
+
ctx[:responses] = result[:responses]
|
|
29
|
+
result.success?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builds a subject-to-listeners map from info responses.
|
|
33
|
+
#
|
|
34
|
+
# @param ctx [Hash] Operation context.
|
|
35
|
+
# @param responses [Array<Hash>] Parsed `$SRV.INFO` responses.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash] Listener entries keyed by NATS subject.
|
|
38
|
+
def map_subjects(ctx, responses:, **)
|
|
39
|
+
ctx[:subject_map] = responses.each_with_object({}) do |service, subjects|
|
|
40
|
+
Array(service['endpoints']).each do |endpoint|
|
|
41
|
+
add_listener(subjects, service, endpoint)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Adds an endpoint listener to the subject map.
|
|
49
|
+
#
|
|
50
|
+
# @param subjects [Hash] Subject map accumulator.
|
|
51
|
+
# @param service [Hash] Parsed `$SRV.INFO` service response.
|
|
52
|
+
# @param endpoint [Hash] Endpoint payload from the service response.
|
|
53
|
+
#
|
|
54
|
+
# @return [void]
|
|
55
|
+
def add_listener(subjects, service, endpoint)
|
|
56
|
+
subject = endpoint['subject']
|
|
57
|
+
return if subject.to_s.empty?
|
|
58
|
+
|
|
59
|
+
subjects[subject] ||= []
|
|
60
|
+
subjects[subject] << listener_entry(service, endpoint)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds a subject-map listener entry.
|
|
64
|
+
#
|
|
65
|
+
# @param service [Hash] Parsed `$SRV.INFO` service response.
|
|
66
|
+
# @param endpoint [Hash] Endpoint payload from the service response.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash] Listener entry suitable for subject maps.
|
|
69
|
+
def listener_entry(service, endpoint)
|
|
70
|
+
{
|
|
71
|
+
'service' => service['name'],
|
|
72
|
+
'service_id' => service['id'],
|
|
73
|
+
'version' => service['version'],
|
|
74
|
+
'endpoint' => endpoint['name'],
|
|
75
|
+
'queue_group' => endpoint['queue_group'],
|
|
76
|
+
'metadata' => endpoint['metadata'],
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'trailblazer/operation'
|
|
4
|
+
require_relative '../../nats_service_discovery'
|
|
5
|
+
|
|
6
|
+
module Rubyists
|
|
7
|
+
module Leopard
|
|
8
|
+
class NatsServiceDiscovery
|
|
9
|
+
module Operation
|
|
10
|
+
# Base class for `$SRV.<VERB>` operations.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses choose which Service API verb to invoke.
|
|
13
|
+
class Verb < Trailblazer::Operation
|
|
14
|
+
step :collect
|
|
15
|
+
|
|
16
|
+
# Collects responses for the subclass Service API verb.
|
|
17
|
+
#
|
|
18
|
+
# @param ctx [Hash] Operation context.
|
|
19
|
+
#
|
|
20
|
+
# @return [Boolean] Whether responses were collected successfully.
|
|
21
|
+
def collect(ctx, **)
|
|
22
|
+
discovery = NatsServiceDiscovery.new(client: ctx[:client], prefix: ctx[:prefix] || DEFAULT_PREFIX)
|
|
23
|
+
ctx[:responses] = public_send_verb(
|
|
24
|
+
discovery,
|
|
25
|
+
name: ctx[:name],
|
|
26
|
+
id: ctx[:id],
|
|
27
|
+
timeout: ctx[:timeout] || DEFAULT_TIMEOUT,
|
|
28
|
+
)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
ctx[:error] = e
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# Invokes the subclass-specific discovery method.
|
|
37
|
+
#
|
|
38
|
+
# @raise [NotImplementedError] when a subclass does not implement this.
|
|
39
|
+
def public_send_verb(_discovery, **)
|
|
40
|
+
raise NotImplementedError, "#{self.class} must define #public_send_verb"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../nats_service_discovery'
|
|
4
|
+
|
|
5
|
+
module Rubyists
|
|
6
|
+
module Leopard
|
|
7
|
+
class NatsServiceDiscovery
|
|
8
|
+
# Trailblazer operations for NATS Service API discovery workflows.
|
|
9
|
+
module Operation
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require_relative 'operation/discovery_options'
|
|
16
|
+
require_relative 'operation/connect'
|
|
17
|
+
require_relative 'operation/collect'
|
|
18
|
+
require_relative 'operation/verb'
|
|
19
|
+
require_relative 'operation/ping'
|
|
20
|
+
require_relative 'operation/info'
|
|
21
|
+
require_relative 'operation/stats'
|
|
22
|
+
require_relative 'operation/services'
|
|
23
|
+
require_relative 'operation/subject_map'
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'nats/client'
|
|
5
|
+
|
|
6
|
+
module Rubyists
|
|
7
|
+
module Leopard
|
|
8
|
+
# Collects NATS Service API monitoring responses from a cluster.
|
|
9
|
+
#
|
|
10
|
+
# The NATS Service API uses request/reply subjects such as `$SRV.INFO`.
|
|
11
|
+
# Cluster-wide requests can receive multiple replies, so this helper creates
|
|
12
|
+
# an inbox subscription, publishes the request, and collects replies until a
|
|
13
|
+
# short timeout elapses.
|
|
14
|
+
class NatsServiceDiscovery
|
|
15
|
+
# Default NATS Service API monitoring prefix.
|
|
16
|
+
DEFAULT_PREFIX = '$SRV'
|
|
17
|
+
# Default idle timeout used to decide that all service replies arrived.
|
|
18
|
+
DEFAULT_TIMEOUT = 0.25
|
|
19
|
+
|
|
20
|
+
attr_reader :client, :prefix
|
|
21
|
+
|
|
22
|
+
# @param client [NATS::Client] Connected NATS client.
|
|
23
|
+
# @param prefix [String] Service API monitoring prefix.
|
|
24
|
+
def initialize(client:, prefix: DEFAULT_PREFIX)
|
|
25
|
+
@client = client
|
|
26
|
+
@prefix = prefix
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Collects `$SRV.PING` responses.
|
|
30
|
+
#
|
|
31
|
+
# @param name [String, nil] Optional service name filter.
|
|
32
|
+
# @param id [String, nil] Optional service id filter; requires `name`.
|
|
33
|
+
# @param timeout [Numeric] Idle timeout while collecting replies.
|
|
34
|
+
#
|
|
35
|
+
# @return [Array<Hash>] Parsed ping responses.
|
|
36
|
+
def ping(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
|
|
37
|
+
collect_json(service_subject('PING', name:, id:), timeout:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Collects `$SRV.INFO` responses.
|
|
41
|
+
#
|
|
42
|
+
# @param name [String, nil] Optional service name filter.
|
|
43
|
+
# @param id [String, nil] Optional service id filter; requires `name`.
|
|
44
|
+
# @param timeout [Numeric] Idle timeout while collecting replies.
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<Hash>] Parsed info responses.
|
|
47
|
+
def info(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
|
|
48
|
+
collect_json(service_subject('INFO', name:, id:), timeout:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Collects `$SRV.STATS` responses.
|
|
52
|
+
#
|
|
53
|
+
# @param name [String, nil] Optional service name filter.
|
|
54
|
+
# @param id [String, nil] Optional service id filter; requires `name`.
|
|
55
|
+
# @param timeout [Numeric] Idle timeout while collecting replies.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Hash>] Parsed stats responses.
|
|
58
|
+
def stats(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
|
|
59
|
+
collect_json(service_subject('STATS', name:, id:), timeout:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Builds a subject-to-listener map from service info responses.
|
|
63
|
+
#
|
|
64
|
+
# @param name [String, nil] Optional service name filter.
|
|
65
|
+
# @param id [String, nil] Optional service id filter; requires `name`.
|
|
66
|
+
# @param timeout [Numeric] Idle timeout while collecting replies.
|
|
67
|
+
#
|
|
68
|
+
# @return [Hash{String => Array<Hash>}] Endpoint listeners keyed by subject.
|
|
69
|
+
def endpoint_subject_map(name: nil, id: nil, timeout: DEFAULT_TIMEOUT)
|
|
70
|
+
info(name:, id:, timeout:).each_with_object({}) do |service, subjects|
|
|
71
|
+
Array(service['endpoints']).each do |endpoint|
|
|
72
|
+
subject = endpoint['subject']
|
|
73
|
+
next if subject.to_s.empty?
|
|
74
|
+
|
|
75
|
+
subjects[subject] ||= []
|
|
76
|
+
subjects[subject] << listener_entry(service, endpoint)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds a Service API monitoring subject.
|
|
82
|
+
#
|
|
83
|
+
# @param verb [String, Symbol] Monitoring verb such as `INFO`.
|
|
84
|
+
# @param name [String, nil] Optional service name filter.
|
|
85
|
+
# @param id [String, nil] Optional service id filter; requires `name`.
|
|
86
|
+
#
|
|
87
|
+
# @return [String] Monitoring subject.
|
|
88
|
+
def service_subject(verb, name: nil, id: nil)
|
|
89
|
+
raise ArgumentError, 'service id requires a service name' if present?(id) && !present?(name)
|
|
90
|
+
|
|
91
|
+
parts = [prefix, verb.to_s.upcase]
|
|
92
|
+
parts << name.to_s if present?(name)
|
|
93
|
+
parts << id.to_s if present?(id)
|
|
94
|
+
parts.join('.')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Collects JSON replies for a Service API subject until `timeout` elapses.
|
|
100
|
+
#
|
|
101
|
+
# @param subject [String] Service API subject to publish.
|
|
102
|
+
# @param timeout [Numeric] Idle timeout while waiting for replies.
|
|
103
|
+
#
|
|
104
|
+
# @return [Array<Hash>] Parsed response payloads.
|
|
105
|
+
def collect_json(subject, timeout:)
|
|
106
|
+
replies = []
|
|
107
|
+
sub = subscribe_to_inbox
|
|
108
|
+
client.publish(subject, '', sub.subject)
|
|
109
|
+
begin
|
|
110
|
+
collect_replies(sub, replies, timeout)
|
|
111
|
+
rescue NATS::Timeout
|
|
112
|
+
replies
|
|
113
|
+
end
|
|
114
|
+
ensure
|
|
115
|
+
sub&.unsubscribe
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Subscribes to an ephemeral reply inbox and flushes the subscription.
|
|
119
|
+
#
|
|
120
|
+
# @return [NATS::Subscription] Subscription bound to the reply inbox.
|
|
121
|
+
def subscribe_to_inbox
|
|
122
|
+
sub = client.subscribe(client.new_inbox)
|
|
123
|
+
client.flush
|
|
124
|
+
sub
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Appends parsed replies to the provided accumulator until timeout.
|
|
128
|
+
#
|
|
129
|
+
# @param sub [NATS::Subscription] Reply subscription.
|
|
130
|
+
# @param replies [Array<Hash>] Response accumulator.
|
|
131
|
+
# @param timeout [Numeric] Idle timeout while waiting for replies.
|
|
132
|
+
#
|
|
133
|
+
# @return [void]
|
|
134
|
+
def collect_replies(sub, replies, timeout)
|
|
135
|
+
loop do
|
|
136
|
+
msg = sub.next_msg(timeout:)
|
|
137
|
+
next if no_responders?(msg)
|
|
138
|
+
|
|
139
|
+
replies << JSON.parse(msg.data)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Reports whether a reply is the server no-responders status message.
|
|
144
|
+
#
|
|
145
|
+
# @param msg [NATS::Msg] Reply message.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def no_responders?(msg)
|
|
149
|
+
msg.header && msg.header['Status'] == '503'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Builds a subject-map listener entry from a service and endpoint payload.
|
|
153
|
+
#
|
|
154
|
+
# @param service [Hash] Parsed `$SRV.INFO` service response.
|
|
155
|
+
# @param endpoint [Hash] Endpoint payload from the service response.
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash] Listener entry suitable for subject maps.
|
|
158
|
+
def listener_entry(service, endpoint)
|
|
159
|
+
{
|
|
160
|
+
'service' => service['name'],
|
|
161
|
+
'service_id' => service['id'],
|
|
162
|
+
'version' => service['version'],
|
|
163
|
+
'endpoint' => endpoint['name'],
|
|
164
|
+
'queue_group' => endpoint['queue_group'],
|
|
165
|
+
'metadata' => endpoint['metadata'],
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Reports whether a value is present for subject construction.
|
|
170
|
+
#
|
|
171
|
+
# @param value [Object] Value to check.
|
|
172
|
+
#
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def present?(value)
|
|
175
|
+
!value.nil? && !value.to_s.empty?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
data/lib/leopard/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: leopard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- bougyman
|
|
@@ -69,21 +69,39 @@ dependencies:
|
|
|
69
69
|
name: semantic_logger
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
71
71
|
requirements:
|
|
72
|
-
- - "
|
|
72
|
+
- - ">="
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
74
|
version: '4'
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
|
-
- - "
|
|
79
|
+
- - ">="
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
81
|
version: '4'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: trailblazer-operation
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.11'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.11'
|
|
82
96
|
description: Leopard is a puma-like server for managing concurrent NATS ServiceApi
|
|
83
97
|
endpoint workers
|
|
84
98
|
email:
|
|
85
99
|
- bougyman@users.noreply.github.com
|
|
86
|
-
executables:
|
|
100
|
+
executables:
|
|
101
|
+
- leopard-service-info
|
|
102
|
+
- leopard-service-map
|
|
103
|
+
- leopard-service-stats
|
|
104
|
+
- leopard-services
|
|
87
105
|
extensions: []
|
|
88
106
|
extra_rdoc_files: []
|
|
89
107
|
files:
|
|
@@ -100,8 +118,14 @@ files:
|
|
|
100
118
|
- ci/nats/start.sh
|
|
101
119
|
- ci/publish-gem.sh
|
|
102
120
|
- doc/service-api-vs-rest.adoc
|
|
121
|
+
- documents/enable-injectable-request-reply-callbacks.adoc
|
|
103
122
|
- examples/echo_endpoint.rb
|
|
123
|
+
- examples/groups.rb
|
|
104
124
|
- examples/jetstream_endpoint.rb
|
|
125
|
+
- exe/leopard-service-info
|
|
126
|
+
- exe/leopard-service-map
|
|
127
|
+
- exe/leopard-service-stats
|
|
128
|
+
- exe/leopard-services
|
|
105
129
|
- lib/leopard.rb
|
|
106
130
|
- lib/leopard/errors.rb
|
|
107
131
|
- lib/leopard/message_processor.rb
|
|
@@ -112,6 +136,18 @@ files:
|
|
|
112
136
|
- lib/leopard/nats_jetstream_consumer.rb
|
|
113
137
|
- lib/leopard/nats_jetstream_endpoint.rb
|
|
114
138
|
- lib/leopard/nats_request_reply_callbacks.rb
|
|
139
|
+
- lib/leopard/nats_service_discovery.rb
|
|
140
|
+
- lib/leopard/nats_service_discovery/cli.rb
|
|
141
|
+
- lib/leopard/nats_service_discovery/operation.rb
|
|
142
|
+
- lib/leopard/nats_service_discovery/operation/collect.rb
|
|
143
|
+
- lib/leopard/nats_service_discovery/operation/connect.rb
|
|
144
|
+
- lib/leopard/nats_service_discovery/operation/discovery_options.rb
|
|
145
|
+
- lib/leopard/nats_service_discovery/operation/info.rb
|
|
146
|
+
- lib/leopard/nats_service_discovery/operation/ping.rb
|
|
147
|
+
- lib/leopard/nats_service_discovery/operation/services.rb
|
|
148
|
+
- lib/leopard/nats_service_discovery/operation/stats.rb
|
|
149
|
+
- lib/leopard/nats_service_discovery/operation/subject_map.rb
|
|
150
|
+
- lib/leopard/nats_service_discovery/operation/verb.rb
|
|
115
151
|
- lib/leopard/settings.rb
|
|
116
152
|
- lib/leopard/templates/prometheus_metrics.erb
|
|
117
153
|
- lib/leopard/version.rb
|