nonnative 3.11.0 → 3.14.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: 6a7d0e5ea6dab1d00eedd363fc81d05d2dd3e4401a97729043bcd3132a071432
4
- data.tar.gz: 53c589eda9e895f3c815446003c97e77859ccec3b0c021182dc1437d0632b267
3
+ metadata.gz: 802f0ee020ba8e7c6cef867895d30c6491ace313fec8103aa6b99726c8402666
4
+ data.tar.gz: 70d522685a3c9cfbc872ee36549d9ba8274be60138a79db68e64c83115fd7cb5
5
5
  SHA512:
6
- metadata.gz: be0b0b0d4cf1f606fe973f7bb6e38b3f2679b8445d6f24f7f05c4ca37eea62fc36f8910f6d0bcf44c1329b71d6638625d7d4a0d3226a14912662d91980523c1e
7
- data.tar.gz: 15caeabfe50acdf2513ac2519bc953fb70effcc662c948c293be646d949bdb60c570359fb0817a6eb528207004f6a7cadfd440628026b58a0e9375003f650389
6
+ metadata.gz: a0b3e8502437cfd6d3a556a8a6f10901a2a89d85a4689fbad3c68d6819e03b4cccf5a51de3cfd66b86827d66f768891f68ee41c91b3ea504345c7f25d22178c8
7
+ data.tar.gz: b16ef6cfe6099e9a62be954178c71a743fb297ece661be2651239c8501707af30e1609e58abfe99bc58d007777a45c99505eb374484c0ba9788e886d0b876e42
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.20
21
21
  alpine:
22
22
  docker:
23
23
  - image: alpine:latest
data/AGENTS.md CHANGED
@@ -79,6 +79,32 @@ of dependencies.
79
79
  thread. Do not flag the listener `join` as an unbounded reliability gap unless
80
80
  the task is explicitly about proxy shutdown behavior or there is a normal-use
81
81
  reproducer, failing CI evidence, or platform-specific hang evidence.
82
+ - Service readiness is intentionally TCP-only for externally managed
83
+ dependencies. Do not flag missing HTTP/gRPC service readiness as a feature gap
84
+ unless the task is explicitly about changing the `Nonnative::Service`
85
+ readiness model. HTTP/gRPC readiness belongs to managed processes, where the
86
+ user explicitly models the application health endpoints.
87
+ - Process runners intentionally inherit the parent working directory. Nonnative
88
+ is normally run from the test folder that owns `test/nonnative.yml`, relative
89
+ config paths, logs, and reports. Do not flag missing process cwd/chdir support
90
+ as a feature gap unless the task is explicitly about changing process working
91
+ directory behavior.
92
+ - Message-specific start/stop assertions and attempted-stop success assertions
93
+ in `features/step_definitions/lifecycle_steps.rb` are repository-local
94
+ Cucumber helpers. Do not flag their absence from `lib/nonnative/cucumber.rb`
95
+ as a feature gap unless downstream usage evidence exists or the task is
96
+ explicitly about expanding public lifecycle assertion steps.
97
+ - Liveness, readiness, and metrics endpoint assertion steps in
98
+ `features/step_definitions/servers_steps.rb` are repository-local Cucumber
99
+ helpers for testing nonnative's own framework assumptions. Do not flag their
100
+ absence from `lib/nonnative/cucumber.rb` as a feature gap unless downstream
101
+ usage evidence exists or the task is explicitly about expanding public
102
+ observability assertion steps.
103
+ - `Nonnative::Configuration` intentionally exposes `process_by_name` without
104
+ matching `server_by_name` or `service_by_name` helpers. Do not flag missing
105
+ server/service configuration lookup helpers as a feature gap unless downstream
106
+ usage evidence exists or the task is explicitly about changing the
107
+ pre-start configuration lookup API.
82
108
 
83
109
  ## Runtime Model
84
110
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- nonnative (3.11.0)
4
+ nonnative (3.14.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
 
@@ -79,15 +79,20 @@ Process/server fields:
79
79
  - `log`: per-runner log file used by process output redirection or server implementations.
80
80
 
81
81
  Process-only fields:
82
- - `readiness`: optional HTTP startup readiness check with explicit `port` and path-only `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`.
83
85
 
84
86
  Service fields:
85
- - `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`.
86
91
 
87
- 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. HTTP readiness paths must be path-only values, such as `/test/readyz`; absolute URLs and scheme-relative URLs are rejected.
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.
88
93
 
89
94
  > [!WARNING]
90
- > 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.
91
96
 
92
97
  Start and stop Nonnative around the test scope that should own the configured runners:
93
98
 
@@ -162,6 +167,19 @@ response = Nonnative.observability.health(
162
167
  expect(response.code).to eq(200)
163
168
  ```
164
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
+
165
183
  ### 🔁 Lifecycle strategies (Cucumber integration)
166
184
 
167
185
  Nonnative ships Cucumber hooks (when loaded) that support these tags/strategies:
@@ -210,7 +228,10 @@ Nonnative.configure do |config|
210
228
  p.ports = [12_321]
211
229
  p.log = '12_321.log'
212
230
  p.signal = 'INT' # Possible values are described in Signal.list.keys.
213
- 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
+ ]
214
235
  p.environment = { # Pass environment variables to process.
215
236
  'TEST' => 'true'
216
237
  }
@@ -247,8 +268,12 @@ processes:
247
268
  log: 12_321.log
248
269
  signal: INT # Possible values are described in Signal.list.keys.
249
270
  readiness:
250
- port: 12321
251
- path: /test/readyz
271
+ - kind: http
272
+ port: 12321
273
+ path: /test/readyz
274
+ - kind: grpc
275
+ port: 12322
276
+ service: example.v1.ExampleService
252
277
  environment: # Pass environment variables to process.
253
278
  TEST: true
254
279
  -
@@ -593,7 +618,7 @@ end
593
618
 
594
619
  A service is an external dependency to your system that you **do not** want Nonnative to start (no OS process, no Ruby thread).
595
620
 
596
- 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.
597
622
 
598
623
  Set it up programmatically:
599
624
 
@@ -610,6 +635,8 @@ Nonnative.configure do |config|
610
635
  s.name = 'postgres'
611
636
  s.host = '127.0.0.1'
612
637
  s.port = 5432
638
+ s.timeout = 5
639
+ s.readiness = [{ kind: 'tcp', host: '127.0.0.1', port: 5432 }]
613
640
  end
614
641
 
615
642
  config.service do |s|
@@ -632,6 +659,11 @@ services:
632
659
  name: postgres
633
660
  host: 127.0.0.1
634
661
  port: 5432
662
+ timeout: 5
663
+ readiness:
664
+ - kind: tcp
665
+ host: 127.0.0.1
666
+ port: 5432
635
667
  -
636
668
  name: redis
637
669
  host: 127.0.0.1
@@ -665,6 +697,7 @@ Nonnative.proxies['custom'] = CustomProxy
665
697
  ```
666
698
 
667
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.
668
701
 
669
702
  ##### 🧩 Service Proxies
670
703
 
@@ -688,6 +721,8 @@ config.service do |s|
688
721
  delay: 5
689
722
  }
690
723
  }
724
+
725
+ s.readiness = [{ kind: 'tcp', host: '127.0.0.1', port: 6379 }]
691
726
  end
692
727
  ```
693
728
 
@@ -701,6 +736,10 @@ services:
701
736
  name: redis
702
737
  host: 127.0.0.1
703
738
  port: 16379
739
+ readiness:
740
+ - kind: tcp
741
+ host: 127.0.0.1
742
+ port: 6379
704
743
  proxy:
705
744
  kind: fault_injection
706
745
  host: 127.0.0.1
@@ -719,6 +758,7 @@ Clients connect to the service `host`/`port`, while the proxy forwards traffic t
719
758
 
720
759
  - `close_all` - Closes the socket as soon as it connects.
721
760
  - `delay` - Delays traffic on the connection. Defaults to 2 seconds and can be configured through options.
761
+ - `timeout` - Accepts the connection and stalls traffic until reset or stop closes the connection, so clients exercise their own read timeout behavior.
722
762
  - `invalid_data` - Forwards client requests unchanged, then corrupts upstream responses before they reach the client.
723
763
 
724
764
  ###### 🧩 Fault Injection Services
@@ -730,6 +770,7 @@ name = 'name of service in configuration'
730
770
  service = Nonnative.pool.service_by_name(name)
731
771
 
732
772
  service.proxy.close_all # To use close_all.
773
+ service.proxy.timeout # To stall traffic until reset or stop.
733
774
  service.proxy.reset # To reset it back to a good state.
734
775
  ```
735
776
 
@@ -737,6 +778,7 @@ Use the Cucumber proxy steps:
737
778
 
738
779
  ```cucumber
739
780
  Given I set the proxy for service 'service_1' to 'close_all'
781
+ Given I set the proxy for service 'service_1' to 'timeout'
740
782
  Then I should reset the proxy for service 'service_1'
741
783
  ```
742
784
 
@@ -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
@@ -4,8 +4,8 @@ module Nonnative
4
4
  # Proxy configuration attached to a service configuration.
5
5
  #
6
6
  # A proxy allows you to interpose behavior between a client and a real service. For example,
7
- # the built-in `"fault_injection"` proxy can close connections, introduce delays, or corrupt data
8
- # for resilience testing.
7
+ # the built-in `"fault_injection"` proxy can close connections, introduce delays, stall traffic,
8
+ # or corrupt data for resilience testing.
9
9
  #
10
10
  # This object is created automatically for each service via {Nonnative::ConfigurationService}.
11
11
  # When `kind` is set to `"none"`, no proxy is started and the service will use its configured
@@ -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
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,11 +46,23 @@ 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?
33
59
  raise ArgumentError, 'Process readiness path must be path-only' unless path_only?
34
60
  end
35
61
 
62
+ def validate_grpc!
63
+ raise ArgumentError, "Process readiness requires 'service'" if service.nil?
64
+ end
65
+
36
66
  def path_only?
37
67
  uri = URI.parse(path.to_s)
38
68
 
@@ -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
@@ -56,6 +56,7 @@ module Nonnative
56
56
  PROXY_OPERATIONS = {
57
57
  'close_all' => :close_all,
58
58
  'delay' => :delay,
59
+ 'timeout' => :timeout,
59
60
  'invalid_data' => :invalid_data,
60
61
  'reset' => :reset
61
62
  }.freeze
@@ -11,6 +11,7 @@ module Nonnative
11
11
  #
12
12
  # - {#close_all}: close connections immediately on accept
13
13
  # - {#delay}: delay reads by a configured duration (default: 2 seconds)
14
+ # - {#timeout}: accept connections and keep them silent until clients time out
14
15
  # - {#invalid_data}: forward requests unchanged and mutate upstream responses before they reach clients
15
16
  # - {#reset}: return to healthy pass-through behavior
16
17
  #
@@ -107,6 +108,17 @@ module Nonnative
107
108
  apply_state :delay
108
109
  end
109
110
 
111
+ # Accepts connections and stalls without forwarding bytes.
112
+ #
113
+ # This simulates a dependency that accepts a TCP connection but leaves clients waiting until
114
+ # their own read timeout fires. The proxy keeps the connection silent until reset or stop closes
115
+ # active connections.
116
+ #
117
+ # @return [void]
118
+ def timeout
119
+ apply_state :timeout
120
+ end
121
+
110
122
  # Mutates upstream responses while forwarding client requests unchanged.
111
123
  #
112
124
  # @return [void]
@@ -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
 
@@ -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]
@@ -16,6 +16,7 @@ module Nonnative
16
16
  # @see Nonnative::SocketPairFactory
17
17
  # @see Nonnative::CloseAllSocketPair
18
18
  # @see Nonnative::DelaySocketPair
19
+ # @see Nonnative::TimeoutSocketPair
19
20
  # @see Nonnative::InvalidDataSocketPair
20
21
  class SocketPair
21
22
  # @param proxy [#host, #port, #options] proxy configuration used to connect upstream
@@ -10,18 +10,20 @@ module Nonnative
10
10
  # - `:none` (or any unknown value) -> {Nonnative::SocketPair} (pass-through)
11
11
  # - `:close_all` -> {Nonnative::CloseAllSocketPair}
12
12
  # - `:delay` -> {Nonnative::DelaySocketPair}
13
+ # - `:timeout` -> {Nonnative::TimeoutSocketPair}
13
14
  # - `:invalid_data` -> {Nonnative::InvalidDataSocketPair}
14
15
  #
15
16
  # @see Nonnative::FaultInjectionProxy
16
17
  # @see Nonnative::SocketPair
17
18
  # @see Nonnative::CloseAllSocketPair
18
19
  # @see Nonnative::DelaySocketPair
20
+ # @see Nonnative::TimeoutSocketPair
19
21
  # @see Nonnative::InvalidDataSocketPair
20
22
  class SocketPairFactory
21
23
  class << self
22
24
  # Creates a socket-pair instance for the given proxy state.
23
25
  #
24
- # @param kind [Symbol] proxy state (e.g. `:none`, `:close_all`, `:delay`, `:invalid_data`)
26
+ # @param kind [Symbol] proxy state (e.g. `:none`, `:close_all`, `:delay`, `:timeout`, `:invalid_data`)
25
27
  # @param proxy [Nonnative::ConfigurationProxy] proxy configuration (host/port/options)
26
28
  # @return [Nonnative::SocketPair] a socket-pair implementation instance
27
29
  def create(kind, proxy)
@@ -30,6 +32,8 @@ module Nonnative
30
32
  CloseAllSocketPair
31
33
  when :delay
32
34
  DelaySocketPair
35
+ when :timeout
36
+ TimeoutSocketPair
33
37
  when :invalid_data
34
38
  InvalidDataSocketPair
35
39
  else
@@ -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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Socket-pair variant used by the fault-injection proxy to simulate read timeouts.
5
+ #
6
+ # When active, the proxy accepts the client connection and keeps it open without forwarding bytes.
7
+ # Clients with read deadlines should observe their own timeout behavior.
8
+ #
9
+ # This behavior is enabled by calling {Nonnative::FaultInjectionProxy#timeout}.
10
+ #
11
+ # @see Nonnative::FaultInjectionProxy
12
+ # @see Nonnative::SocketPairFactory
13
+ # @see Nonnative::SocketPair
14
+ class TimeoutSocketPair < SocketPair
15
+ # Keeps the accepted socket silent until reset or stop closes active proxy connections.
16
+ #
17
+ # @param local_socket [TCPSocket] the accepted client socket
18
+ # @return [void]
19
+ def connect(local_socket)
20
+ @local_socket = local_socket
21
+
22
+ Nonnative.logger.info "stalling socket '#{local_socket.inspect}' for 'timeout' pair"
23
+
24
+ # Do not call super: the base implementation opens the upstream socket and forwards traffic.
25
+ sleep 0.1 until local_socket.closed?
26
+ ensure
27
+ close
28
+ end
29
+ end
30
+ end
@@ -4,5 +4,5 @@ module Nonnative
4
4
  # The current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '3.11.0'
7
+ VERSION = '3.14.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
  #
@@ -51,6 +51,7 @@ require 'shellwords'
51
51
  require 'uri'
52
52
 
53
53
  require 'grpc'
54
+ require 'grpc/health/v1/health_services_pb'
54
55
  require 'sinatra'
55
56
  require 'rest-client'
56
57
  require 'retriable'
@@ -75,6 +76,7 @@ require 'nonnative/configuration_file'
75
76
  require 'nonnative/configuration'
76
77
  require 'nonnative/configuration_runner'
77
78
  require 'nonnative/configuration_readiness'
79
+ require 'nonnative/configuration_service_readiness'
78
80
  require 'nonnative/configuration_process'
79
81
  require 'nonnative/configuration_server'
80
82
  require 'nonnative/configuration_service'
@@ -85,7 +87,10 @@ require 'nonnative/server'
85
87
  require 'nonnative/service'
86
88
  require 'nonnative/pool'
87
89
  require 'nonnative/http_client'
90
+ require 'nonnative/tcp_probe'
88
91
  require 'nonnative/http_probe'
92
+ require 'nonnative/grpc_health'
93
+ require 'nonnative/grpc_probe'
89
94
  require 'nonnative/http_server'
90
95
  require 'nonnative/http_proxy_server'
91
96
  require 'nonnative/grpc_server'
@@ -97,6 +102,7 @@ require 'nonnative/fault_injection_proxy'
97
102
  require 'nonnative/socket_pair'
98
103
  require 'nonnative/close_all_socket_pair'
99
104
  require 'nonnative/delay_socket_pair'
105
+ require 'nonnative/timeout_socket_pair'
100
106
  require 'nonnative/invalid_data_socket_pair'
101
107
  require 'nonnative/socket_pair_factory'
102
108
  require 'nonnative/go_executable'
@@ -190,9 +196,16 @@ module Nonnative
190
196
  # Returns an HTTP client for common health/readiness endpoints.
191
197
  #
192
198
  # @return [Nonnative::Observability]
193
- def observability
194
- @observability ||= Nonnative::Observability.new(configuration.url)
195
- end
199
+ def observability = (@observability ||= Nonnative::Observability.new(configuration.url))
200
+
201
+ # Returns a client helper for the standard gRPC health checking protocol.
202
+ #
203
+ # @param host [String] gRPC server host
204
+ # @param port [Integer] gRPC server port
205
+ # @param service [String] gRPC health service name
206
+ # @param timeout [Numeric] default call timeout in seconds
207
+ # @return [Nonnative::GRPCHealth]
208
+ def grpc_health(host:, port:, service:, timeout: 1) = Nonnative::GRPCHealth.new(host: host, port: port, service: service, timeout: timeout)
196
209
 
197
210
  # Returns the configured proxy kinds mapped to proxy classes.
198
211
  #
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.11.0
4
+ version: 3.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alejandro Falkowski
@@ -296,11 +296,14 @@ files:
296
296
  - lib/nonnative/configuration_runner.rb
297
297
  - lib/nonnative/configuration_server.rb
298
298
  - lib/nonnative/configuration_service.rb
299
+ - lib/nonnative/configuration_service_readiness.rb
299
300
  - lib/nonnative/cucumber.rb
300
301
  - lib/nonnative/delay_socket_pair.rb
301
302
  - lib/nonnative/error.rb
302
303
  - lib/nonnative/fault_injection_proxy.rb
303
304
  - lib/nonnative/go_executable.rb
305
+ - lib/nonnative/grpc_health.rb
306
+ - lib/nonnative/grpc_probe.rb
304
307
  - lib/nonnative/grpc_server.rb
305
308
  - lib/nonnative/header.rb
306
309
  - lib/nonnative/http_client.rb
@@ -325,7 +328,9 @@ files:
325
328
  - lib/nonnative/start_error.rb
326
329
  - lib/nonnative/startup.rb
327
330
  - lib/nonnative/stop_error.rb
331
+ - lib/nonnative/tcp_probe.rb
328
332
  - lib/nonnative/timeout.rb
333
+ - lib/nonnative/timeout_socket_pair.rb
329
334
  - lib/nonnative/version.rb
330
335
  - nonnative.gemspec
331
336
  homepage: https://github.com/alexfalkowski/nonnative