nonnative 3.10.0 → 3.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a276b7ac5edae149fc6eb065909773b73c5c7e1684e4502244d88a0cdd6c521
4
- data.tar.gz: 82545f52a383ba1f67af5c1d01c72653adee7339a3e1968fb987eabc9236421e
3
+ metadata.gz: 304a9d9d01fafb9f0822e4510c647f87095f78987feecb5a9d2eddc43317a880
4
+ data.tar.gz: 366157abd34f6ffa48cce159dffd49075e4a98653ea5d3f3b11b2494ad47b4a1
5
5
  SHA512:
6
- metadata.gz: 0eea335efd1554822d7153077244d2f8ff5d19c46af6b19e153bd0298e99e4902f090b933899d4614e46a88252d9b95e9f7ec92b3d6e9e824c96f00694d579f9
7
- data.tar.gz: ce2d8e41f9313b289c3531fe8c605e869dd856a1eb8ae47bb1671ebc12aa1f97a3a8e795cad16681c11857b2a70ce4f2250b0310dd57286162cc9aed7efa764e
6
+ metadata.gz: 6fa7d92393e3a4df9f400a7f35c857998c13dc026ec85be407fa37d6e8ba2189c5992af70e001c1cdfaf9589d1b6d33a2daf11abf8361f84f07ab8bc3a72fed1
7
+ data.tar.gz: bf37673b803ff66338009fbbd458d707c6eed9ef0ba618bb53b97e2f5770788708343f6bd34b67758fd6d1a13e464f63edffced76c26591f8ac4fe4ec2267a3e
data/.circleci/config.yml CHANGED
@@ -14,10 +14,10 @@ commands:
14
14
  executors:
15
15
  ruby:
16
16
  docker:
17
- - image: alexfalkowski/ruby:3.17
17
+ - image: alexfalkowski/ruby:3.19
18
18
  release:
19
19
  docker:
20
- - image: alexfalkowski/release:8.18
20
+ - image: alexfalkowski/release:8.19
21
21
  alpine:
22
22
  docker:
23
23
  - image: alpine:latest
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- nonnative (3.10.0)
4
+ nonnative (3.13.0)
5
5
  concurrent-ruby (>= 1, < 2)
6
6
  config (>= 5, < 6)
7
7
  cucumber (>= 7, < 12)
data/README.md CHANGED
@@ -11,7 +11,7 @@ It helps you:
11
11
  - start **OS processes** (e.g. your Go/Java/Rust service binary),
12
12
  - start **in-process Ruby servers** (e.g. small HTTP/TCP/gRPC fakes for dependencies),
13
13
  - optionally start **service proxies** for fault-injection in front of externally managed dependencies,
14
- - wait for readiness/shutdown using **TCP port checks**.
14
+ - wait for readiness/shutdown using **TCP port checks**, optional process HTTP/gRPC checks, and optional service TCP checks.
15
15
 
16
16
  Once started, you can test however you like (TCP, HTTP, gRPC, etc).
17
17
 
@@ -38,6 +38,18 @@ Or install it yourself as:
38
38
  gem install nonnative
39
39
  ```
40
40
 
41
+ ## 🛠️ Contributor Bootstrap
42
+
43
+ Fresh clones need the shared `bin/` submodule before Make targets can load:
44
+
45
+ ```bash
46
+ git submodule sync && git submodule update --init
47
+ make help
48
+ ```
49
+
50
+ Use `make dep` before local validation when dependencies are missing. The CI-parity checks are
51
+ `make lint`, `make sec`, `make features`, and `make benchmarks`.
52
+
41
53
  ## 🚀 Usage
42
54
 
43
55
  Nonnative is configured via `Nonnative.configure` (programmatic) or `config.load_file(...)` (YAML).
@@ -67,15 +79,20 @@ Process/server fields:
67
79
  - `log`: per-runner log file used by process output redirection or server implementations.
68
80
 
69
81
  Process-only fields:
70
- - `readiness`: optional HTTP startup readiness check with explicit `port` and `path`.
82
+ - `readiness`: optional list of startup readiness checks. Supported kinds are `http` and `grpc`.
83
+ HTTP checks require explicit `port` and path-only `path`. gRPC checks require explicit `port` and
84
+ health `service`.
71
85
 
72
86
  Service fields:
73
- - `port`: client-facing service port. Services do not get TCP readiness/shutdown checks from Nonnative.
87
+ - `port`: client-facing service port.
88
+ - `timeout`: max time (seconds) for opt-in service readiness checks. Defaults to `1.0`.
89
+ - `readiness`: optional list of startup readiness checks. Supported kind is `tcp`, which requires
90
+ explicit `host` and `port`.
74
91
 
75
- Nonnative readiness and shutdown checks are TCP port checks by default. Configure process/server ports that are dedicated to the test run; if another process is already listening on the same endpoint, results are undefined. Processes can also opt into an HTTP readiness check that runs after TCP readiness succeeds.
92
+ Nonnative readiness and shutdown checks are TCP port checks by default. Configure process/server ports that are dedicated to the test run; if another process is already listening on the same endpoint, results are undefined. Processes can also opt into HTTP and gRPC readiness checks that run after TCP readiness succeeds. Services do not get automatic TCP readiness/shutdown checks, but can opt into TCP startup readiness for externally managed dependencies. HTTP readiness paths must be path-only values, such as `/test/readyz`; absolute URLs and scheme-relative URLs are rejected.
76
93
 
77
94
  > [!WARNING]
78
- > TCP readiness and shutdown checks only prove that a TCP port opened or closed. HTTP readiness is process-only, checks for a 2xx response, and does not verify gRPC health, schema readiness, migrations, or other application-specific health.
95
+ > TCP readiness and shutdown checks only prove that a TCP port opened or closed. HTTP and gRPC readiness are process-only. Service readiness is TCP-only and should target the dependency endpoint that must be reachable before managed servers/processes start.
79
96
 
80
97
  Start and stop Nonnative around the test scope that should own the configured runners:
81
98
 
@@ -98,6 +115,37 @@ Nonnative.stop
98
115
  > Call `Nonnative.clear` before reconfiguring Nonnative or starting a new lifecycle in the same Ruby process.
99
116
  > `Nonnative.clear` clears memoized configuration, logger, observability client, and pool.
100
117
 
118
+ ### 🧩 Test framework setup
119
+
120
+ For Cucumber, load and configure Nonnative in `features/support/env.rb`, then use lifecycle tags on scenarios:
121
+
122
+ ```ruby
123
+ require 'nonnative'
124
+
125
+ Nonnative.configure do |config|
126
+ config.load_file('configuration.yml')
127
+ end
128
+ ```
129
+
130
+ ```cucumber
131
+ @startup
132
+ Scenario: run with Nonnative around this scenario
133
+ ```
134
+
135
+ For RSpec or another suite that should start Nonnative once per test run, configure first and then require the startup integration:
136
+
137
+ ```ruby
138
+ require 'nonnative'
139
+
140
+ Nonnative.configure do |config|
141
+ config.load_file('configuration.yml')
142
+ end
143
+
144
+ require 'nonnative/startup'
145
+ ```
146
+
147
+ `nonnative/startup` calls `Nonnative.start` immediately and registers an `at_exit` stop, so load configuration before requiring it.
148
+
101
149
  ### 📈 Observability
102
150
 
103
151
  `Nonnative.observability` is an HTTP client for common service endpoints under the configured `name` and `url`:
@@ -119,11 +167,24 @@ response = Nonnative.observability.health(
119
167
  expect(response.code).to eq(200)
120
168
  ```
121
169
 
170
+ `Nonnative.grpc_health` is a helper for the standard gRPC health checking protocol:
171
+
172
+ ```ruby
173
+ health = Nonnative.grpc_health(
174
+ host: '127.0.0.1',
175
+ port: 12_322,
176
+ service: 'example.v1.ExampleService',
177
+ timeout: 2
178
+ )
179
+
180
+ expect(health.serving?).to eq(true)
181
+ ```
182
+
122
183
  ### 🔁 Lifecycle strategies (Cucumber integration)
123
184
 
124
185
  Nonnative ships Cucumber hooks (when loaded) that support these tags/strategies:
125
186
  - `@startup`: start before scenario; stop after scenario
126
- - `@manual`: stop after scenario (start is expected to be triggered manually in steps)
187
+ - `@manual`: stop after scenario; use `When I start the system` to start manually
127
188
  - `@clear`: clears memoized configuration, logger, observability client, and pool before scenario
128
189
  - `@reset`: resets proxies after scenario
129
190
 
@@ -140,14 +201,6 @@ The repo’s own Cucumber suite also uses taxonomy tags to classify coverage:
140
201
 
141
202
  Requiring `nonnative` is enough; the Cucumber hooks and step definitions are installed lazily once Cucumber’s Ruby DSL is ready.
142
203
 
143
- If you want "start once per test run", require:
144
-
145
- ```ruby
146
- require 'nonnative/startup'
147
- ```
148
-
149
- This calls `Nonnative.start` immediately and registers an `at_exit` stop.
150
-
151
204
  ### ⚙️ Processes
152
205
 
153
206
  A process is some sort of command that you would run locally.
@@ -175,7 +228,10 @@ Nonnative.configure do |config|
175
228
  p.ports = [12_321]
176
229
  p.log = '12_321.log'
177
230
  p.signal = 'INT' # Possible values are described in Signal.list.keys.
178
- p.readiness = { port: 12_321, path: '/test/readyz' }
231
+ p.readiness = [
232
+ { kind: 'http', port: 12_321, path: '/test/readyz' },
233
+ { kind: 'grpc', port: 12_322, service: 'example.v1.ExampleService' }
234
+ ]
179
235
  p.environment = { # Pass environment variables to process.
180
236
  'TEST' => 'true'
181
237
  }
@@ -212,8 +268,12 @@ processes:
212
268
  log: 12_321.log
213
269
  signal: INT # Possible values are described in Signal.list.keys.
214
270
  readiness:
215
- port: 12321
216
- path: /test/readyz
271
+ - kind: http
272
+ port: 12321
273
+ path: /test/readyz
274
+ - kind: grpc
275
+ port: 12322
276
+ service: example.v1.ExampleService
217
277
  environment: # Pass environment variables to process.
218
278
  TEST: true
219
279
  -
@@ -558,7 +618,7 @@ end
558
618
 
559
619
  A service is an external dependency to your system that you **do not** want Nonnative to start (no OS process, no Ruby thread).
560
620
 
561
- Services do not get process lifecycle management or TCP readiness/shutdown checks from Nonnative. They provide a named endpoint for a dependency that another tool already manages.
621
+ Services do not get process lifecycle management or automatic TCP readiness/shutdown checks from Nonnative. They provide a named endpoint for a dependency that another tool already manages, and can opt into TCP startup readiness when the dependency must be reachable before managed servers/processes start.
562
622
 
563
623
  Set it up programmatically:
564
624
 
@@ -575,6 +635,8 @@ Nonnative.configure do |config|
575
635
  s.name = 'postgres'
576
636
  s.host = '127.0.0.1'
577
637
  s.port = 5432
638
+ s.timeout = 5
639
+ s.readiness = [{ kind: 'tcp', host: '127.0.0.1', port: 5432 }]
578
640
  end
579
641
 
580
642
  config.service do |s|
@@ -597,6 +659,11 @@ services:
597
659
  name: postgres
598
660
  host: 127.0.0.1
599
661
  port: 5432
662
+ timeout: 5
663
+ readiness:
664
+ - kind: tcp
665
+ host: 127.0.0.1
666
+ port: 5432
600
667
  -
601
668
  name: redis
602
669
  host: 127.0.0.1
@@ -630,6 +697,7 @@ Nonnative.proxies['custom'] = CustomProxy
630
697
  ```
631
698
 
632
699
  Only services support proxies. For `fault_injection`, keep the service `host`/`port` as the client-facing proxy endpoint and use nested `proxy.host`/`proxy.port` for the upstream target behind the proxy.
700
+ When service readiness is configured for a proxied dependency, set the readiness `host`/`port` to the upstream dependency, not the client-facing proxy listener.
633
701
 
634
702
  ##### 🧩 Service Proxies
635
703
 
@@ -653,6 +721,8 @@ config.service do |s|
653
721
  delay: 5
654
722
  }
655
723
  }
724
+
725
+ s.readiness = [{ kind: 'tcp', host: '127.0.0.1', port: 6379 }]
656
726
  end
657
727
  ```
658
728
 
@@ -666,6 +736,10 @@ services:
666
736
  name: redis
667
737
  host: 127.0.0.1
668
738
  port: 16379
739
+ readiness:
740
+ - kind: tcp
741
+ host: 127.0.0.1
742
+ port: 6379
669
743
  proxy:
670
744
  kind: fault_injection
671
745
  host: 127.0.0.1
@@ -22,7 +22,7 @@ module Nonnative
22
22
  # p.ports = [8080, 9090]
23
23
  # p.timeout = 10
24
24
  # p.log = 'api.log'
25
- # p.readiness = { port: 8080, path: '/example/readyz' }
25
+ # p.readiness = [{ kind: 'http', port: 8080, path: '/example/readyz' }]
26
26
  # end
27
27
  # end
28
28
  #
@@ -177,11 +177,11 @@ module Nonnative
177
177
  def add_services(cfg)
178
178
  services = cfg.services || []
179
179
  services.each do |loaded_service|
180
- reject_readiness(loaded_service, 'services')
181
-
182
180
  service do |service_config|
183
181
  service_config.name = loaded_service.name
184
182
  service_config.host = loaded_service.host if loaded_service.host
183
+ service_config.timeout = loaded_service.timeout if loaded_service.timeout
184
+ service_config.readiness = loaded_service.readiness if loaded_service.readiness
185
185
  assign_service_port(service_config, loaded_service)
186
186
 
187
187
  assign_proxy(service_config, loaded_service.proxy)
@@ -28,7 +28,7 @@ module Nonnative
28
28
  # @return [Hash, nil] environment variables to pass to the spawned process
29
29
  attr_accessor :environment
30
30
 
31
- # @return [Nonnative::ConfigurationReadiness, nil] optional HTTP readiness check
31
+ # @return [Array<Nonnative::ConfigurationReadiness>] optional readiness checks
32
32
  attr_reader :readiness
33
33
 
34
34
  # Creates a process configuration with bounded lifecycle defaults.
@@ -41,14 +41,23 @@ module Nonnative
41
41
  super
42
42
 
43
43
  self.timeout = DEFAULT_TIMEOUT
44
+ self.readiness = []
44
45
  end
45
46
 
46
- # Sets optional HTTP readiness configuration.
47
+ # Sets optional process readiness checks.
47
48
  #
48
- # @param value [Hash, #to_h, nil] readiness attributes with required `port` and `path`
49
+ # @param value [Array<Hash, #to_h>, nil] readiness checks with required `kind` and `port`
49
50
  # @return [void]
50
51
  def readiness=(value)
51
- @readiness = value.nil? ? nil : Nonnative::ConfigurationReadiness.new(value)
52
+ @readiness = value.nil? ? [] : build_readiness(value)
53
+ end
54
+
55
+ private
56
+
57
+ def build_readiness(value)
58
+ raise ArgumentError, 'Process readiness must be a list of checks' unless value.is_a?(Array)
59
+
60
+ value.map { |check| Nonnative::ConfigurationReadiness.new(check) }
52
61
  end
53
62
  end
54
63
  end
@@ -1,26 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nonnative
4
- # HTTP readiness configuration for a managed process.
4
+ # Readiness check configuration for a managed process.
5
5
  #
6
- # Readiness is optional. When present, both `port` and `path` are required so the startup
7
- # check has an explicit application endpoint to poll after TCP readiness succeeds.
6
+ # Readiness is optional. When present, each check declares a `kind` and explicit endpoint details
7
+ # to poll after TCP readiness succeeds.
8
8
  class ConfigurationReadiness
9
- # @return [Integer] process HTTP readiness port
9
+ KINDS = %w[http grpc].freeze
10
+
11
+ # @return [String] readiness check kind
12
+ attr_accessor :kind
13
+
14
+ # @return [Integer] process readiness port
10
15
  attr_accessor :port
11
16
 
12
- # @return [String] HTTP readiness path
17
+ # @return [String] path-only HTTP readiness path
13
18
  attr_accessor :path
14
19
 
15
- # @param value [Hash, #to_h] readiness attributes
20
+ # @return [String] gRPC health service name
21
+ attr_accessor :service
22
+
23
+ # @param value [Hash, #to_h] readiness check attributes
16
24
  def initialize(value)
17
25
  attributes = value.respond_to?(:to_h) ? value.to_h : value
26
+ self.kind = attribute(attributes, :kind)&.to_s
18
27
  self.port = attribute(attributes, :port)
19
28
  self.path = attribute(attributes, :path)
29
+ self.service = attribute(attributes, :service)
20
30
 
21
31
  validate!
22
32
  end
23
33
 
34
+ def http?
35
+ kind == 'http'
36
+ end
37
+
38
+ def grpc?
39
+ kind == 'grpc'
40
+ end
41
+
24
42
  private
25
43
 
26
44
  def attribute(attributes, name)
@@ -28,8 +46,29 @@ module Nonnative
28
46
  end
29
47
 
30
48
  def validate!
49
+ raise ArgumentError, "Process readiness requires 'kind'" if kind.nil?
50
+ raise ArgumentError, "Process readiness kind must be one of: #{KINDS.join(', ')}" unless KINDS.include?(kind)
31
51
  raise ArgumentError, "Process readiness requires 'port'" if port.nil?
52
+
53
+ validate_http! if http?
54
+ validate_grpc! if grpc?
55
+ end
56
+
57
+ def validate_http!
32
58
  raise ArgumentError, "Process readiness requires 'path'" if path.nil?
59
+ raise ArgumentError, 'Process readiness path must be path-only' unless path_only?
60
+ end
61
+
62
+ def validate_grpc!
63
+ raise ArgumentError, "Process readiness requires 'service'" if service.nil?
64
+ end
65
+
66
+ def path_only?
67
+ uri = URI.parse(path.to_s)
68
+
69
+ uri.scheme.nil? && uri.host.nil?
70
+ rescue URI::InvalidURIError
71
+ false
33
72
  end
34
73
  end
35
74
  end
@@ -14,11 +14,17 @@ module Nonnative
14
14
  # @return [Integer] client-facing port used by the service proxy
15
15
  attr_accessor :port
16
16
 
17
+ # @return [Numeric] readiness timeout (seconds) used when waiting for service readiness
18
+ attr_accessor :timeout
19
+
17
20
  # Proxy configuration for this service.
18
21
  #
19
22
  # @return [Nonnative::ConfigurationProxy]
20
23
  attr_reader :proxy
21
24
 
25
+ # @return [Array<Nonnative::ConfigurationServiceReadiness>] optional service readiness checks
26
+ attr_reader :readiness
27
+
22
28
  # Creates a service configuration with defaults.
23
29
  #
24
30
  # @return [void]
@@ -26,9 +32,19 @@ module Nonnative
26
32
  super
27
33
 
28
34
  self.port = 0
35
+ self.timeout = DEFAULT_TIMEOUT
36
+ @readiness = []
29
37
  @proxy = Nonnative::ConfigurationProxy.new
30
38
  end
31
39
 
40
+ # Sets optional service readiness checks.
41
+ #
42
+ # @param value [Array<Hash, #to_h>, nil] readiness checks with required `kind`, `host`, and `port`
43
+ # @return [void]
44
+ def readiness=(value)
45
+ @readiness = value.nil? ? [] : build_readiness(value)
46
+ end
47
+
32
48
  # Sets proxy configuration using a hash-like value.
33
49
  #
34
50
  # This is primarily used when loading YAML configuration files, where proxy attributes are
@@ -67,5 +83,13 @@ module Nonnative
67
83
  def ports=(_value)
68
84
  raise ArgumentError, "Use 'port' instead of 'ports' for service '#{name}'"
69
85
  end
86
+
87
+ private
88
+
89
+ def build_readiness(value)
90
+ raise ArgumentError, 'Service readiness must be a list of checks' unless value.is_a?(Array)
91
+
92
+ value.map { |check| Nonnative::ConfigurationServiceReadiness.new(check) }
93
+ end
70
94
  end
71
95
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Service readiness check configuration.
5
+ #
6
+ # Service readiness is for externally managed dependencies. The TCP target should be the dependency
7
+ # endpoint that must be reachable before managed servers and processes start.
8
+ class ConfigurationServiceReadiness
9
+ KINDS = %w[tcp].freeze
10
+
11
+ # @return [String] readiness check kind
12
+ attr_reader :kind
13
+
14
+ # @return [String] readiness target host
15
+ attr_reader :host
16
+
17
+ # @return [Integer] readiness target port
18
+ attr_reader :port
19
+
20
+ # @param value [Hash, #to_h] readiness check attributes
21
+ # @return [void]
22
+ def initialize(value)
23
+ attributes = value.to_h.transform_keys(&:to_sym)
24
+
25
+ @kind = attributes[:kind]&.to_s
26
+ @host = attributes[:host]
27
+ @port = attributes[:port]
28
+
29
+ validate!
30
+ end
31
+
32
+ # Returns whether this is a TCP readiness check.
33
+ #
34
+ # @return [Boolean]
35
+ def tcp?
36
+ kind == 'tcp'
37
+ end
38
+
39
+ private
40
+
41
+ def validate!
42
+ raise ArgumentError, "Service readiness requires 'kind'" if kind.nil?
43
+ raise ArgumentError, "Service readiness kind must be one of: #{KINDS.join(', ')}" unless KINDS.include?(kind)
44
+ raise ArgumentError, "Service readiness requires 'host'" if host.nil?
45
+ raise ArgumentError, "Service readiness requires 'port'" if port.nil?
46
+ end
47
+ end
48
+ end
@@ -43,10 +43,11 @@ module Nonnative
43
43
  end
44
44
 
45
45
  def install_hooks
46
+ # Register @clear before startup hooks so combined tags reset stale state before creating a pool.
47
+ Before('@clear') { Nonnative.clear }
46
48
  Before('@startup') { Nonnative.start }
47
49
  After('@startup') { Nonnative.stop }
48
50
  After('@manual') { Nonnative.stop }
49
- Before('@clear') { Nonnative.clear }
50
51
  After('@reset') { Nonnative.reset }
51
52
  end
52
53
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Client helper for the standard gRPC health checking protocol.
5
+ class GRPCHealth
6
+ NETWORK_ERRORS = [
7
+ GRPC::BadStatus,
8
+ GRPC::Core::CallError
9
+ ].freeze
10
+
11
+ # @param host [String] gRPC server host
12
+ # @param port [Integer] gRPC server port
13
+ # @param service [String] gRPC health service name
14
+ # @param timeout [Numeric] default call timeout in seconds
15
+ def initialize(host:, port:, service:, timeout: 1)
16
+ @host = host
17
+ @port = port
18
+ @service = service.to_s
19
+ @timeout = timeout
20
+ end
21
+
22
+ # Calls the gRPC health check endpoint.
23
+ #
24
+ # @param deadline [Time] gRPC deadline
25
+ # @return [Grpc::Health::V1::HealthCheckResponse]
26
+ def check(deadline: Time.now + timeout)
27
+ stub.check(request, deadline: deadline)
28
+ end
29
+
30
+ # Returns true when the gRPC health endpoint reports SERVING.
31
+ #
32
+ # @param deadline [Time] gRPC deadline
33
+ # @return [Boolean]
34
+ def serving?(deadline: Time.now + timeout)
35
+ check(deadline: deadline).status == :SERVING
36
+ rescue *NETWORK_ERRORS
37
+ false
38
+ end
39
+
40
+ # Returns the checked gRPC health endpoint for lifecycle diagnostics.
41
+ #
42
+ # @return [String]
43
+ def endpoint
44
+ service.empty? ? "grpc://#{host}:#{port}" : "grpc://#{host}:#{port}/#{service}"
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :host, :port, :service, :timeout
50
+
51
+ def request
52
+ Grpc::Health::V1::HealthCheckRequest.new(service: service)
53
+ end
54
+
55
+ def stub
56
+ @stub ||= Grpc::Health::V1::Health::Stub.new("#{host}:#{port}", :this_channel_is_insecure)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Probes a managed process gRPC health endpoint.
5
+ class GRPCProbe
6
+ # @param process [Nonnative::ConfigurationProcess] process configuration
7
+ # @param readiness [Nonnative::ConfigurationReadiness] gRPC readiness attributes
8
+ def initialize(process, readiness)
9
+ @health = Nonnative::GRPCHealth.new(
10
+ host: process.host,
11
+ port: readiness.port,
12
+ service: readiness.service,
13
+ timeout: process.timeout
14
+ )
15
+ @timeout = Nonnative::Timeout.new(process.timeout)
16
+ end
17
+
18
+ # Returns whether the configured gRPC health endpoint reports SERVING before timeout.
19
+ #
20
+ # @return [Boolean]
21
+ def ready?
22
+ Nonnative.logger.info "checking if readiness '#{endpoint}' is ready"
23
+
24
+ timeout.perform do
25
+ raise Nonnative::Error unless health.serving?
26
+
27
+ true
28
+ rescue Nonnative::Error
29
+ sleep_interval
30
+ retry
31
+ end
32
+ end
33
+
34
+ # Returns the gRPC health endpoint for lifecycle diagnostics.
35
+ #
36
+ # @return [String]
37
+ def endpoint
38
+ health.endpoint
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :health, :timeout
44
+
45
+ def sleep_interval
46
+ sleep 0.01
47
+ end
48
+ end
49
+ end
@@ -12,9 +12,10 @@ module Nonnative
12
12
  RestClient::ServerBrokeConnection
13
13
  ].freeze
14
14
 
15
- # @param process [Nonnative::ConfigurationProcess] process configuration with readiness attributes
16
- def initialize(process)
17
- @readiness = process.readiness
15
+ # @param process [Nonnative::ConfigurationProcess] process configuration
16
+ # @param readiness [Nonnative::ConfigurationReadiness] HTTP readiness attributes
17
+ def initialize(process, readiness)
18
+ @readiness = readiness
18
19
  @base_url = "http://#{process.host}:#{readiness.port}"
19
20
  @timeout = Nonnative::Timeout.new(process.timeout)
20
21
 
@@ -28,7 +29,7 @@ module Nonnative
28
29
  Nonnative.logger.info "checking if readiness '#{endpoint}' is ready"
29
30
 
30
31
  timeout.perform do
31
- response = get(readiness.path)
32
+ response = get(path)
32
33
  raise Nonnative::Error unless ready_response?(response)
33
34
 
34
35
  true
@@ -4,8 +4,8 @@ module Nonnative
4
4
  # No-op proxy implementation.
5
5
  #
6
6
  # This is the default proxy when `service.proxy.kind` is `"none"`.
7
- # It does not bind/listen or alter traffic; it simply exposes the underlying runner's configured
8
- # `host` and primary `port`.
7
+ # It does not bind/listen or alter traffic; it simply exposes the service configuration's
8
+ # client-facing `host` and `port`.
9
9
  #
10
10
  # Services can always call `start`, `stop`, and `reset` safely on this proxy.
11
11
  #
@@ -41,7 +41,7 @@ module Nonnative
41
41
 
42
42
  # Returns the host clients should connect to.
43
43
  #
44
- # For {NoProxy}, this is the underlying runner configuration host.
44
+ # For {NoProxy}, this is the service configuration's client-facing `host`.
45
45
  #
46
46
  # @return [String]
47
47
  def host
@@ -50,7 +50,7 @@ module Nonnative
50
50
 
51
51
  # Returns the port clients should connect to.
52
52
  #
53
- # For {NoProxy}, this is the first underlying runner configuration port.
53
+ # For {NoProxy}, this is the service configuration's client-facing `port`.
54
54
  #
55
55
  # @return [Integer]
56
56
  def port
@@ -25,7 +25,8 @@ module Nonnative
25
25
 
26
26
  # Starts all configured runners and yields results for each process/server.
27
27
  #
28
- # Services are started first (proxy-only), then servers and processes are started and checked for readiness.
28
+ # Services are started first (proxy-only) and checked for opt-in readiness, then servers and processes are
29
+ # started and checked for readiness.
29
30
  #
30
31
  # @yieldparam name [String, nil] runner name
31
32
  # @yieldparam values [Object] runner-specific return value from `start` (e.g. `[pid, running]` for processes)
@@ -36,6 +37,10 @@ module Nonnative
36
37
  errors = []
37
38
 
38
39
  errors.concat(service_lifecycle(services, :start, :start))
40
+ service_readiness_errors = check_service_readiness(services)
41
+ errors.concat(service_readiness_errors)
42
+ return errors if service_readiness_errors.any?
43
+
39
44
  [servers, processes].each { |runners| errors.concat(run_lifecycle_checks(runners, :start, :open?, :start, &)) }
40
45
 
41
46
  errors
@@ -180,6 +185,15 @@ module Nonnative
180
185
  end
181
186
  end
182
187
 
188
+ def check_service_readiness(all)
189
+ all.each_with_object([]) do |service, errors|
190
+ next unless service.respond_to?(:readiness)
191
+
192
+ checks = service.readiness.map { |readiness| Nonnative::TCPProbe.new(readiness, timeout: service.timeout) }
193
+ errors << service_readiness_error(service, checks) unless checks.all?(&:ready?)
194
+ end
195
+ end
196
+
183
197
  def run_lifecycle_checks(runners, lifecycle_method, port_method, action, &)
184
198
  checks = []
185
199
  errors = []
@@ -220,6 +234,10 @@ module Nonnative
220
234
  "#{check.capitalize} check failed for #{runner_name(type)}: #{error.class} - #{error.message}"
221
235
  end
222
236
 
237
+ def service_readiness_error(service, checks)
238
+ "Started #{runner_name(service)}, though did not respond in time for readiness: #{checks.map(&:endpoint).join(', ')}"
239
+ end
240
+
223
241
  def runner_name(type)
224
242
  name = type.name
225
243
  return "runner '#{name}'" if name
@@ -12,14 +12,14 @@ module Nonnative
12
12
  def initialize(runner)
13
13
  @runner = runner
14
14
  @ports = runner.ports.map { |port| Nonnative::Port.new(runner, port) }
15
- @readiness = Nonnative::HTTPProbe.new(runner) if runner.respond_to?(:readiness) && runner.readiness
15
+ @readiness = readiness_probes
16
16
  end
17
17
 
18
18
  # Returns whether all configured ports become connectable before their timeouts elapse.
19
19
  #
20
20
  # @return [Boolean]
21
21
  def open?
22
- ports.all?(&:open?) && (readiness.nil? || readiness.ready?)
22
+ ports.all?(&:open?) && readiness.all?(&:ready?)
23
23
  end
24
24
 
25
25
  # Returns whether all configured ports become non-connectable before their timeouts elapse.
@@ -41,7 +41,7 @@ module Nonnative
41
41
  # @return [String]
42
42
  def description
43
43
  details = []
44
- details << "readiness: #{readiness.endpoint}" if readiness
44
+ details << "readiness: #{readiness.map(&:endpoint).join(', ')}" if readiness.any?
45
45
  log = runner.log if runner.respond_to?(:log)
46
46
  details << "log: #{log}" if log
47
47
 
@@ -51,5 +51,20 @@ module Nonnative
51
51
  private
52
52
 
53
53
  attr_reader :ports, :readiness, :runner
54
+
55
+ def readiness_probes
56
+ return [] unless runner.respond_to?(:readiness)
57
+
58
+ runner.readiness.map { |check| readiness_probe(check) }
59
+ end
60
+
61
+ def readiness_probe(check)
62
+ case check.kind
63
+ when 'http'
64
+ Nonnative::HTTPProbe.new(runner, check)
65
+ when 'grpc'
66
+ Nonnative::GRPCProbe.new(runner, check)
67
+ end
68
+ end
54
69
  end
55
70
  end
@@ -24,6 +24,20 @@ module Nonnative
24
24
  @proxy = Nonnative::ProxyFactory.create(service)
25
25
  end
26
26
 
27
+ # Returns configured service readiness checks.
28
+ #
29
+ # @return [Array<Nonnative::ConfigurationServiceReadiness>]
30
+ def readiness
31
+ service.readiness
32
+ end
33
+
34
+ # Returns the configured service readiness timeout.
35
+ #
36
+ # @return [Numeric]
37
+ def timeout
38
+ service.timeout
39
+ end
40
+
27
41
  # Starts the configured proxy (if any).
28
42
  #
29
43
  # @return [void]
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Starts the configured Nonnative pool immediately and registers an `at_exit` stop hook.
4
+ #
5
+ # Configure Nonnative before requiring this file; it is intended for suites that keep one
6
+ # Nonnative lifecycle open for the whole Ruby process.
7
+
3
8
  at_exit do
4
9
  Nonnative.stop
5
10
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Probes a TCP readiness endpoint.
5
+ class TCPProbe
6
+ Target = Struct.new(:host, :timeout, keyword_init: true)
7
+
8
+ # @param readiness [#host, #port] TCP readiness attributes
9
+ # @param timeout [Numeric] maximum time to wait for the TCP endpoint
10
+ def initialize(readiness, timeout:)
11
+ @port = Nonnative::Port.new(Target.new(host: readiness.host, timeout:), readiness.port)
12
+ end
13
+
14
+ # Returns whether the TCP endpoint becomes connectable before the timeout elapses.
15
+ #
16
+ # @return [Boolean]
17
+ def ready?
18
+ port.open?
19
+ end
20
+
21
+ # Returns the checked endpoint for lifecycle diagnostics.
22
+ #
23
+ # @return [String]
24
+ def endpoint
25
+ port.endpoint
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :port
31
+ end
32
+ end
@@ -4,5 +4,5 @@ module Nonnative
4
4
  # The current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '3.10.0'
7
+ VERSION = '3.13.0'
8
8
  end
data/lib/nonnative.rb CHANGED
@@ -6,7 +6,7 @@
6
6
  # It can:
7
7
  #
8
8
  # - start external processes and in-process servers
9
- # - wait for readiness via port checks and optional process HTTP readiness
9
+ # - wait for readiness via port checks, optional process HTTP/gRPC readiness, and optional service TCP readiness
10
10
  # - optionally run fault-injection proxies in front of services
11
11
  #
12
12
  # The public entry points are exposed as module-level methods on {Nonnative}.
@@ -27,7 +27,7 @@
27
27
  # p.ports = [8080, 9090]
28
28
  # p.timeout = 10
29
29
  # p.log = 'api.log'
30
- # p.readiness = { port: 8080, path: '/example/readyz' }
30
+ # p.readiness = [{ kind: 'http', port: 8080, path: '/example/readyz' }]
31
31
  # end
32
32
  # end
33
33
  #
@@ -48,8 +48,10 @@ require 'yaml'
48
48
  require 'open3'
49
49
  require 'securerandom'
50
50
  require 'shellwords'
51
+ require 'uri'
51
52
 
52
53
  require 'grpc'
54
+ require 'grpc/health/v1/health_services_pb'
53
55
  require 'sinatra'
54
56
  require 'rest-client'
55
57
  require 'retriable'
@@ -74,6 +76,7 @@ require 'nonnative/configuration_file'
74
76
  require 'nonnative/configuration'
75
77
  require 'nonnative/configuration_runner'
76
78
  require 'nonnative/configuration_readiness'
79
+ require 'nonnative/configuration_service_readiness'
77
80
  require 'nonnative/configuration_process'
78
81
  require 'nonnative/configuration_server'
79
82
  require 'nonnative/configuration_service'
@@ -84,7 +87,10 @@ require 'nonnative/server'
84
87
  require 'nonnative/service'
85
88
  require 'nonnative/pool'
86
89
  require 'nonnative/http_client'
90
+ require 'nonnative/tcp_probe'
87
91
  require 'nonnative/http_probe'
92
+ require 'nonnative/grpc_health'
93
+ require 'nonnative/grpc_probe'
88
94
  require 'nonnative/http_server'
89
95
  require 'nonnative/http_proxy_server'
90
96
  require 'nonnative/grpc_server'
@@ -189,9 +195,16 @@ module Nonnative
189
195
  # Returns an HTTP client for common health/readiness endpoints.
190
196
  #
191
197
  # @return [Nonnative::Observability]
192
- def observability
193
- @observability ||= Nonnative::Observability.new(configuration.url)
194
- end
198
+ def observability = (@observability ||= Nonnative::Observability.new(configuration.url))
199
+
200
+ # Returns a client helper for the standard gRPC health checking protocol.
201
+ #
202
+ # @param host [String] gRPC server host
203
+ # @param port [Integer] gRPC server port
204
+ # @param service [String] gRPC health service name
205
+ # @param timeout [Numeric] default call timeout in seconds
206
+ # @return [Nonnative::GRPCHealth]
207
+ def grpc_health(host:, port:, service:, timeout: 1) = Nonnative::GRPCHealth.new(host: host, port: port, service: service, timeout: timeout)
195
208
 
196
209
  # Returns the configured proxy kinds mapped to proxy classes.
197
210
  #
@@ -204,8 +217,12 @@ module Nonnative
204
217
 
205
218
  # Resolves a proxy implementation for a configured kind.
206
219
  #
220
+ # `nil` and `"none"` resolve to {Nonnative::NoProxy}; any other kind must be registered in
221
+ # {Nonnative.proxies}.
222
+ #
207
223
  # @param kind [String] proxy kind name (for example `"fault_injection"`)
208
224
  # @return [Class] a subclass of {Nonnative::Proxy}
225
+ # @raise [ArgumentError] if the kind is not `"none"` and has not been registered
209
226
  def proxy(kind)
210
227
  kind.nil? || kind == 'none' ? NoProxy : proxies.fetch(kind) { raise ArgumentError, "Unsupported proxy kind '#{kind}'" }
211
228
  end
data/nonnative.gemspec CHANGED
@@ -11,8 +11,8 @@ Gem::Specification.new do |spec|
11
11
  spec.authors = ['Alejandro Falkowski']
12
12
  spec.email = ['alexrfalkowski@gmail.com']
13
13
 
14
- spec.summary = 'Allows you to keep using the power of Ruby to test other systems'
15
- spec.description = spec.summary
14
+ spec.summary = 'Ruby-first end-to-end harness for testing systems implemented in other languages'
15
+ spec.description = 'Starts OS processes, in-process Ruby servers, and proxy-only services with TCP readiness checks and fault-injection proxies.'
16
16
  spec.homepage = 'https://github.com/alexfalkowski/nonnative'
17
17
  spec.license = 'MIT'
18
18
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nonnative
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.0
4
+ version: 3.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alejandro Falkowski
@@ -263,7 +263,8 @@ dependencies:
263
263
  - - "<"
264
264
  - !ruby/object:Gem::Version
265
265
  version: '5'
266
- description: Allows you to keep using the power of Ruby to test other systems
266
+ description: Starts OS processes, in-process Ruby servers, and proxy-only services
267
+ with TCP readiness checks and fault-injection proxies.
267
268
  email:
268
269
  - alexrfalkowski@gmail.com
269
270
  executables: []
@@ -295,11 +296,14 @@ files:
295
296
  - lib/nonnative/configuration_runner.rb
296
297
  - lib/nonnative/configuration_server.rb
297
298
  - lib/nonnative/configuration_service.rb
299
+ - lib/nonnative/configuration_service_readiness.rb
298
300
  - lib/nonnative/cucumber.rb
299
301
  - lib/nonnative/delay_socket_pair.rb
300
302
  - lib/nonnative/error.rb
301
303
  - lib/nonnative/fault_injection_proxy.rb
302
304
  - lib/nonnative/go_executable.rb
305
+ - lib/nonnative/grpc_health.rb
306
+ - lib/nonnative/grpc_probe.rb
303
307
  - lib/nonnative/grpc_server.rb
304
308
  - lib/nonnative/header.rb
305
309
  - lib/nonnative/http_client.rb
@@ -324,6 +328,7 @@ files:
324
328
  - lib/nonnative/start_error.rb
325
329
  - lib/nonnative/startup.rb
326
330
  - lib/nonnative/stop_error.rb
331
+ - lib/nonnative/tcp_probe.rb
327
332
  - lib/nonnative/timeout.rb
328
333
  - lib/nonnative/version.rb
329
334
  - nonnative.gemspec
@@ -351,5 +356,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
351
356
  requirements: []
352
357
  rubygems_version: 4.0.11
353
358
  specification_version: 4
354
- summary: Allows you to keep using the power of Ruby to test other systems
359
+ summary: Ruby-first end-to-end harness for testing systems implemented in other languages
355
360
  test_files: []