nonnative 3.2.1 → 3.9.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: 522cec08aee3855943f6ef6ba38bec3d85acd00aeeb2d6640a6869cf55014518
4
- data.tar.gz: ebebc8d342a2111f8fb8538c0bf5b615c549b61127761c3e2ada47397294b47c
3
+ metadata.gz: ef654d45571b49eaf151e67b60a8eaa5c7b6df2b1a2e877d774a30ec80cc3281
4
+ data.tar.gz: 3526ed68b510ee72ac7b2d92764109bbd729653b5e1ba06b8b7a0f1f60b44c07
5
5
  SHA512:
6
- metadata.gz: e4df243398b167fb955324fe12894f8bb5ecaf4ec612f8c55ad73e6cf95d6e04868ad1ee2ff64c1fbbc68322ea96dc953a0329b01f609a31b5409fb38542f09a
7
- data.tar.gz: 419ca92810588c41e281cd4d77e49651bee8c412d7285689f196c176473364f1562eabbe1944a121e238e79c9cfb0de6335bbcde05b6e068478c45db4414cef2
6
+ metadata.gz: f28ca3884eef23d8db41bcef405205e5afca8369622627f415a845340579e54b17102cbdcabd3d44933ae7630a26eb3c6b3ba05c3e50da08092b84ec873a1629
7
+ data.tar.gz: 69166708a716e13658f5f25c86ccea83c201cccdeeec11296249fc318e7cabbec78816048ca266f9c890538b920d091968bff3275888a2c7bf7d9853343313f6
data/.circleci/config.yml CHANGED
@@ -1,15 +1,33 @@
1
1
  version: 2.1
2
2
 
3
+ commands:
4
+ sync-submodules:
5
+ steps:
6
+ - run: git submodule sync
7
+ - run: git submodule update --init
8
+ checkout-submodules:
9
+ steps:
10
+ - checkout:
11
+ method: blobless
12
+ - sync-submodules
13
+
14
+ executors:
15
+ ruby:
16
+ docker:
17
+ - image: alexfalkowski/ruby:3.17
18
+ release:
19
+ docker:
20
+ - image: alexfalkowski/release:8.18
21
+ alpine:
22
+ docker:
23
+ - image: alpine:latest
24
+
3
25
  jobs:
4
26
  build:
5
- docker:
6
- - image: alexfalkowski/ruby:3.14
27
+ executor: ruby
7
28
  working_directory: ~/nonnative
8
29
  steps:
9
- - checkout:
10
- method: blobless
11
- - run: git submodule sync
12
- - run: git submodule update --init
30
+ - checkout-submodules
13
31
  - run: make source-key
14
32
  - restore_cache:
15
33
  name: restore deps
@@ -34,34 +52,25 @@ jobs:
34
52
  - run: make codecov-upload
35
53
  resource_class: arm.large
36
54
  sync:
37
- docker:
38
- - image: alexfalkowski/release:8.15
55
+ executor: release
39
56
  working_directory: ~/nonnative
40
57
  steps:
41
- - checkout:
42
- method: blobless
43
- - run: git submodule sync
44
- - run: git submodule update --init
58
+ - checkout-submodules
45
59
  - run: make sync push
46
60
  resource_class: arm.large
47
61
  version:
48
- docker:
49
- - image: alexfalkowski/release:8.15
62
+ executor: release
50
63
  working_directory: ~/nonnative
51
64
  steps:
52
- - checkout:
53
- method: blobless
54
- - run: git submodule sync
55
- - run: git submodule update --init
65
+ - checkout-submodules
56
66
  - run: version
57
67
  - run: package
58
68
  resource_class: arm.large
59
69
  wait-all:
60
- docker:
61
- - image: alexfalkowski/ruby:3.14
70
+ executor: alpine
71
+ resource_class: small
62
72
  steps:
63
73
  - run: echo "all applicable jobs finished"
64
- resource_class: arm.large
65
74
 
66
75
  workflows:
67
76
  nonnative:
data/.gitignore CHANGED
@@ -1,9 +1,18 @@
1
1
  /.bundle/
2
+ .crush/
3
+ .ruby-lsp/
4
+ .source-key
2
5
  /.yardoc
3
6
  /_yardoc/
4
7
  /doc/
5
8
  /pkg/
9
+ ISSUES.md
10
+ TESTS.md
11
+ DOCS.md
12
+ FEATURES.md
13
+ PROJECTS.md
14
+ RELIABILITY.md
6
15
  test/reports/*
16
+ !test/reports/.keep
7
17
  /tmp/
8
- vendor
9
- ISSUES.md
18
+ vendor/
data/AGENTS.md CHANGED
@@ -1,10 +1,8 @@
1
1
  # AGENTS.md
2
2
 
3
- ## Shared skills
3
+ ## Shared guidance
4
4
 
5
- This repository uses the shared skills from `bin/skills/`. Read
6
- `bin/AGENTS.md` for the canonical shared skill list and use the smallest
7
- matching skill for the task.
5
+ Use `bin/AGENTS.md` for shared skills and cross-repository defaults.
8
6
 
9
7
  `nonnative` is a Ruby gem for end-to-end testing systems implemented in other
10
8
  languages. It starts processes, in-process servers, and proxy-only services,
@@ -18,18 +16,16 @@ of dependencies.
18
16
  - Generated gRPC test stubs: `test/grpc/**/*`
19
17
  - Test protos: `test/nonnative/v1/*.proto`
20
18
  - Build wiring: root `Makefile` includes `bin/build/make/*.mak`
21
- - Required submodule: `bin/` from `git@github.com:alexfalkowski/bin.git`; missing SSH/submodule setup breaks `make`
19
+ - Required submodule: `bin/`; missing submodule setup breaks `make`
22
20
  - Install deps: `make dep`
23
21
  - Lint: `make lint`
22
+ - Security checks: `make sec`
24
23
  - Features: `make features`
25
24
  - Benchmarks only: `make benchmarks`
26
25
  - Cleanup: `make clean-dep`, `make clean-reports`
27
26
 
28
27
  ## Intentional Design Choices
29
28
 
30
- - Root `make` compatibility is GNU Make 4+/`gmake` oriented through the shared
31
- `bin/build/make/*.mak` fragments. Do not flag GNU Make 3.81 parsing failures
32
- as code issues unless the task is explicitly about supporting GNU Make 3.81.
33
29
  - The configured HTTP proxy feature intentionally uses the external
34
30
  `www.afalkowski.com` host through `features/support/http_proxy_server.rb`.
35
31
  Do not flag this external dependency as a code issue unless the task is
@@ -54,6 +50,10 @@ of dependencies.
54
50
  test proto changes. Do not flag the absence of an automatic generated-stub
55
51
  freshness check as a test gap unless the task is explicitly about changing
56
52
  test proto generation or generated-file validation.
53
+ - Generated gRPC test stubs may carry manual require-path adjustments after
54
+ generation. Do not treat `make -C test stale` as a required validation target
55
+ unless the task is explicitly about changing test proto generation; verify
56
+ the generated Ruby loads before accepting generator-only rewrites.
57
57
  - Buf linting for the test-only proto module is intentionally not part of the
58
58
  required local validation surface. Do not flag missing root-level validation
59
59
  for `test/buf.yaml` as a test gap unless the task is explicitly about test
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- nonnative (3.2.1)
4
+ nonnative (3.9.0)
5
5
  concurrent-ruby (>= 1, < 2)
6
6
  config (>= 5, < 6)
7
7
  cucumber (>= 7, < 12)
@@ -26,7 +26,7 @@ GEM
26
26
  benchmark-trend (0.4.0)
27
27
  bigdecimal (4.1.2)
28
28
  builder (3.3.0)
29
- concurrent-ruby (1.3.6)
29
+ concurrent-ruby (1.3.7)
30
30
  config (5.6.1)
31
31
  deep_merge (~> 1.2, >= 1.2.1)
32
32
  ostruct
@@ -64,24 +64,24 @@ GEM
64
64
  get_process_mem (1.0.0)
65
65
  bigdecimal (>= 2.0)
66
66
  ffi (~> 1.0)
67
- google-protobuf (4.35.0-x86_64-darwin)
67
+ google-protobuf (4.35.1-x86_64-darwin)
68
68
  bigdecimal
69
69
  rake (~> 13.3)
70
- google-protobuf (4.35.0-x86_64-linux-gnu)
70
+ google-protobuf (4.35.1-x86_64-linux-gnu)
71
71
  bigdecimal
72
72
  rake (~> 13.3)
73
73
  googleapis-common-protos-types (1.23.0)
74
74
  google-protobuf (~> 4.26)
75
- grpc (1.81.0-x86_64-darwin)
75
+ grpc (1.81.1-x86_64-darwin)
76
76
  google-protobuf (>= 3.25, < 5.0)
77
77
  googleapis-common-protos-types (~> 1.0)
78
- grpc (1.81.0-x86_64-linux-gnu)
78
+ grpc (1.81.1-x86_64-linux-gnu)
79
79
  google-protobuf (>= 3.25, < 5.0)
80
80
  googleapis-common-protos-types (~> 1.0)
81
81
  http-accept (1.7.0)
82
82
  http-cookie (1.1.6)
83
83
  domain_name (~> 0.5)
84
- json (2.19.8)
84
+ json (2.20.0)
85
85
  language_server-protocol (3.17.0.5)
86
86
  lint_roller (1.1.0)
87
87
  logger (1.7.0)
@@ -114,7 +114,7 @@ GEM
114
114
  rack (>= 3.0.0)
115
115
  rainbow (3.1.1)
116
116
  rake (13.4.2)
117
- rbs (4.0.2)
117
+ rbs (4.0.3)
118
118
  logger
119
119
  prism (>= 1.6.0)
120
120
  tsort
@@ -146,7 +146,7 @@ GEM
146
146
  rspec-support (3.13.7)
147
147
  rspec-wait (1.0.2)
148
148
  rspec (>= 3.4)
149
- rubocop (1.87.0)
149
+ rubocop (1.88.0)
150
150
  json (~> 2.3)
151
151
  language_server-protocol (~> 3.17.0.2)
152
152
  lint_roller (~> 1.1.0)
@@ -169,7 +169,7 @@ GEM
169
169
  docile (~> 1.1)
170
170
  simplecov-html (~> 0.11)
171
171
  simplecov_json_formatter (~> 0.1)
172
- simplecov-cobertura (3.1.0)
172
+ simplecov-cobertura (3.2.0)
173
173
  rexml
174
174
  simplecov (~> 0.19)
175
175
  simplecov-html (0.13.2)
data/README.md CHANGED
@@ -62,17 +62,20 @@ Common runner fields:
62
62
 
63
63
  Process/server fields:
64
64
  - `ports`: client-facing ports. These are also used for readiness/shutdown port checks.
65
- - `timeout`: max time (seconds) for readiness/shutdown port checks.
65
+ - `timeout`: max time (seconds) for readiness/shutdown port checks. Defaults to `1.0`.
66
66
  - `wait`: small sleep (seconds) between lifecycle steps.
67
67
  - `log`: per-runner log file used by process output redirection or server implementations.
68
68
 
69
+ Process-only fields:
70
+ - `readiness`: optional HTTP startup readiness check with explicit `port` and `path`.
71
+
69
72
  Service fields:
70
73
  - `port`: client-facing service port. Services do not get TCP readiness/shutdown checks from Nonnative.
71
74
 
72
- Nonnative readiness and shutdown checks are TCP-only. Configure process/server ports that are dedicated to the test run; if another process is already listening on the same endpoint, results are undefined.
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.
73
76
 
74
77
  > [!WARNING]
75
- > Readiness and shutdown checks only prove that a TCP port opened or closed. They do not verify HTTP status, gRPC health, schema readiness, migrations, or application-specific health.
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.
76
79
 
77
80
  Start and stop Nonnative around the test scope that should own the configured runners:
78
81
 
@@ -164,12 +167,13 @@ Nonnative.configure do |config|
164
167
 
165
168
  config.process do |p|
166
169
  p.name = 'start_1'
167
- p.command = -> { ['features/support/bin/start', '12_321'] }
170
+ p.command = -> { ['bin/start-test-service', '12_321'] }
168
171
  p.timeout = 5
169
172
  p.wait = 0.1
170
173
  p.ports = [12_321]
171
174
  p.log = '12_321.log'
172
175
  p.signal = 'INT' # Possible values are described in Signal.list.keys.
176
+ p.readiness = { port: 12_321, path: '/test/readyz' }
173
177
  p.environment = { # Pass environment variables to process.
174
178
  'TEST' => 'true'
175
179
  }
@@ -177,7 +181,7 @@ Nonnative.configure do |config|
177
181
 
178
182
  config.process do |p|
179
183
  p.name = 'start_2'
180
- p.command = -> { ['features/support/bin/start', '12_322'] }
184
+ p.command = -> { ['bin/start-test-service', '12_322'] }
181
185
  p.timeout = 0.5
182
186
  p.wait = 0.1
183
187
  p.ports = [12_322]
@@ -197,7 +201,7 @@ processes:
197
201
  -
198
202
  name: start_1
199
203
  command:
200
- - features/support/bin/start
204
+ - bin/start-test-service
201
205
  - "12_321"
202
206
  timeout: 5
203
207
  wait: 1
@@ -205,12 +209,15 @@ processes:
205
209
  - 12321
206
210
  log: 12_321.log
207
211
  signal: INT # Possible values are described in Signal.list.keys.
212
+ readiness:
213
+ port: 12321
214
+ path: /test/readyz
208
215
  environment: # Pass environment variables to process.
209
216
  TEST: true
210
217
  -
211
218
  name: start_2
212
219
  command:
213
- - features/support/bin/start
220
+ - bin/start-test-service
214
221
  - "12_322"
215
222
  timeout: 5
216
223
  wait: 1
@@ -474,6 +481,8 @@ end
474
481
 
475
482
  Define your server:
476
483
 
484
+ Assume the gRPC service base class and response types below come from your generated gRPC stubs.
485
+
477
486
  ```ruby
478
487
  module Nonnative
479
488
  module Features
@@ -530,6 +539,9 @@ servers:
530
539
  log: grpc_server_1.log
531
540
  ```
532
541
 
542
+ The `grpc` gem uses a global logger, so per-server gRPC log files are not independent. The first
543
+ initialized gRPC server sets the logger used by later gRPC servers in the same Ruby process.
544
+
533
545
  Then load the file with:
534
546
 
535
547
  ```ruby
@@ -607,7 +619,7 @@ These proxies can simulate different situations. Available proxy kinds are:
607
619
  - `fault_injection`
608
620
 
609
621
  > [!WARNING]
610
- > Unknown proxy kinds fall back to `none`. If fault injection is not taking effect, check the `kind` spelling or register the custom kind before loading the configuration.
622
+ > Unknown proxy kinds raise an error. If fault injection is not taking effect, check the `kind` spelling or register the custom kind before starting the system.
611
623
 
612
624
  Custom proxy kinds can be registered through `Nonnative.proxies`:
613
625
 
@@ -22,6 +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
26
  # end
26
27
  # end
27
28
  #
@@ -42,13 +43,25 @@ module Nonnative
42
43
  end
43
44
 
44
45
  # @return [String, nil] logical system name (used for observability endpoints)
46
+ attr_accessor :name
47
+
45
48
  # @return [String, nil] configuration version
49
+ attr_accessor :version
50
+
46
51
  # @return [String, nil] base URL for observability queries (for example `"http://127.0.0.1:8080"`)
52
+ attr_accessor :url
53
+
47
54
  # @return [String, nil] path to the Nonnative log file
55
+ attr_accessor :log
56
+
48
57
  # @return [Array<Nonnative::ConfigurationProcess>] configured processes
58
+ attr_accessor :processes
59
+
49
60
  # @return [Array<Nonnative::ConfigurationServer>] configured in-process servers
61
+ attr_accessor :servers
62
+
50
63
  # @return [Array<Nonnative::ConfigurationService>] configured services (proxy-only)
51
- attr_accessor :name, :version, :url, :log, :processes, :servers, :services
64
+ attr_accessor :services
52
65
 
53
66
  # Loads a configuration file and appends its runners to this instance.
54
67
  #
@@ -130,6 +143,7 @@ module Nonnative
130
143
  process_config.command = command(loaded_process)
131
144
  process_config.signal = loaded_process.signal
132
145
  process_config.environment = loaded_process.environment
146
+ process_config.readiness = loaded_process.readiness if loaded_process.readiness
133
147
  runner_attributes(process_config, loaded_process)
134
148
  end
135
149
  end
@@ -151,6 +165,7 @@ module Nonnative
151
165
  servers = cfg.servers || []
152
166
  servers.each do |loaded_server|
153
167
  reject_proxy(loaded_server, 'servers')
168
+ reject_readiness(loaded_server, 'servers')
154
169
 
155
170
  server do |server_config|
156
171
  server_config.klass = Object.const_get(server_class_name(loaded_server))
@@ -162,6 +177,8 @@ module Nonnative
162
177
  def add_services(cfg)
163
178
  services = cfg.services || []
164
179
  services.each do |loaded_service|
180
+ reject_readiness(loaded_service, 'services')
181
+
165
182
  service do |service_config|
166
183
  service_config.name = loaded_service.name
167
184
  service_config.host = loaded_service.host if loaded_service.host
@@ -179,7 +196,8 @@ module Nonnative
179
196
 
180
197
  def runner_attributes(runner, loaded)
181
198
  runner.name = loaded.name
182
- runner.timeout = loaded.timeout
199
+ timeout = loaded.timeout
200
+ runner.timeout = timeout unless timeout.nil?
183
201
  runner.wait = loaded.wait if loaded.wait
184
202
  runner.host = loaded.host if loaded.host
185
203
  assign_ports(runner, loaded)
@@ -207,6 +225,13 @@ module Nonnative
207
225
  raise ArgumentError, "Use 'services' for proxy configuration; #{kind} do not support 'proxy'"
208
226
  end
209
227
 
228
+ def reject_readiness(loaded, kind)
229
+ values = loaded.to_h
230
+ return unless values.key?(:readiness) || values.key?('readiness')
231
+
232
+ raise ArgumentError, "Use 'processes' for readiness configuration; #{kind} do not support 'readiness'"
233
+ end
234
+
210
235
  def assign_proxy(service, loaded_proxy)
211
236
  return unless loaded_proxy
212
237
 
@@ -19,7 +19,7 @@ module Nonnative
19
19
  # @return [String, nil] signal name to use for stopping (defaults to `"INT"` when not set)
20
20
  attr_accessor :signal
21
21
 
22
- # @return [Numeric] readiness timeout (seconds) used when waiting for ports to open/close
22
+ # @return [Numeric] readiness timeout (seconds) used when waiting for ports to open/close (defaults to `1.0`)
23
23
  attr_accessor :timeout
24
24
 
25
25
  # @return [String] log file path to append process stdout/stderr to
@@ -27,5 +27,28 @@ module Nonnative
27
27
 
28
28
  # @return [Hash, nil] environment variables to pass to the spawned process
29
29
  attr_accessor :environment
30
+
31
+ # @return [Nonnative::ConfigurationReadiness, nil] optional HTTP readiness check
32
+ attr_reader :readiness
33
+
34
+ # Creates a process configuration with bounded lifecycle defaults.
35
+ #
36
+ # Defaults:
37
+ # - `timeout`: `1.0`
38
+ #
39
+ # @return [void]
40
+ def initialize
41
+ super
42
+
43
+ self.timeout = DEFAULT_TIMEOUT
44
+ end
45
+
46
+ # Sets optional HTTP readiness configuration.
47
+ #
48
+ # @param value [Hash, #to_h, nil] readiness attributes with required `port` and `path`
49
+ # @return [void]
50
+ def readiness=(value)
51
+ @readiness = value.nil? ? nil : Nonnative::ConfigurationReadiness.new(value)
52
+ end
30
53
  end
31
54
  end
@@ -15,12 +15,21 @@ module Nonnative
15
15
  # @see Nonnative.proxies
16
16
  class ConfigurationProxy
17
17
  # @return [String] proxy kind name (for example `"none"` or `"fault_injection"`)
18
+ attr_accessor :kind
19
+
18
20
  # @return [String] upstream host used by proxy implementations (defaults to `"127.0.0.1"`)
21
+ attr_accessor :host
22
+
19
23
  # @return [Integer] upstream port used by proxy implementations (defaults to `0`)
24
+ attr_accessor :port
25
+
20
26
  # @return [String, nil] path to proxy log file (implementation-dependent)
27
+ attr_accessor :log
28
+
21
29
  # @return [Numeric] wait interval (seconds) after proxy state changes (defaults to `0.1`)
30
+ attr_accessor :wait
31
+
22
32
  # @return [Hash] proxy implementation options (implementation-dependent)
23
- attr_accessor :kind, :host, :port, :log, :wait
24
33
  attr_reader :options
25
34
 
26
35
  # Creates a proxy configuration with defaults.
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # HTTP readiness configuration for a managed process.
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.
8
+ class ConfigurationReadiness
9
+ # @return [Integer] process HTTP readiness port
10
+ attr_accessor :port
11
+
12
+ # @return [String] HTTP readiness path
13
+ attr_accessor :path
14
+
15
+ # @param value [Hash, #to_h] readiness attributes
16
+ def initialize(value)
17
+ attributes = value.respond_to?(:to_h) ? value.to_h : value
18
+ self.port = attribute(attributes, :port)
19
+ self.path = attribute(attributes, :path)
20
+
21
+ validate!
22
+ end
23
+
24
+ private
25
+
26
+ def attribute(attributes, name)
27
+ attributes[name] || attributes[name.to_s]
28
+ end
29
+
30
+ def validate!
31
+ raise ArgumentError, "Process readiness requires 'port'" if port.nil?
32
+ raise ArgumentError, "Process readiness requires 'path'" if path.nil?
33
+ end
34
+ end
35
+ end
@@ -12,11 +12,17 @@ module Nonnative
12
12
  # @see Nonnative::ConfigurationServer
13
13
  # @see Nonnative::ConfigurationService
14
14
  class ConfigurationRunner
15
+ # Default bounded readiness/shutdown timeout for process and server runners.
16
+ DEFAULT_TIMEOUT = 1.0
17
+
15
18
  # @return [String, nil] runner name used for lookup (for example via `pool.process_by_name`)
19
+ attr_accessor :name
20
+
16
21
  # @return [String] host to bind/connect to (defaults to `"127.0.0.1"`)
17
- # @return [Array<Integer>] ports to bind/connect to
22
+ attr_accessor :host
23
+
18
24
  # @return [Numeric] wait interval (seconds) used by runners between lifecycle steps
19
- attr_accessor :name, :host, :wait
25
+ attr_accessor :wait
20
26
 
21
27
  # @return [Array<Integer>] client-facing ports used for readiness/shutdown checks
22
28
  attr_reader :ports
@@ -11,9 +11,25 @@ module Nonnative
11
11
  # @see Nonnative::Configuration
12
12
  # @see Nonnative::Server
13
13
  class ConfigurationServer < ConfigurationRunner
14
- # @return [Class] a class that implements `#initialize(service)`, and lifecycle hooks expected by {Nonnative::Server}
15
- # @return [Numeric] readiness timeout (seconds) used when waiting for ports to open/close
14
+ # @return [Class] a class that implements `#initialize(service)` and the hooks expected by {Nonnative::Server}
15
+ attr_accessor :klass
16
+
17
+ # @return [Numeric] readiness timeout (seconds) used when waiting for ports to open/close (defaults to `1.0`)
18
+ attr_accessor :timeout
19
+
16
20
  # @return [String] log file path used by server implementations (for example Puma/gRPC log files)
17
- attr_accessor :klass, :timeout, :log
21
+ attr_accessor :log
22
+
23
+ # Creates a server configuration with bounded lifecycle defaults.
24
+ #
25
+ # Defaults:
26
+ # - `timeout`: `1.0`
27
+ #
28
+ # @return [void]
29
+ def initialize
30
+ super
31
+
32
+ self.timeout = DEFAULT_TIMEOUT
33
+ end
18
34
  end
19
35
  end
@@ -8,6 +8,26 @@ module Nonnative
8
8
  # Requiring `nonnative` outside a running Cucumber environment should not fail, but when Cucumber
9
9
  # does finish booting its support-code registry this installer still needs to register the hooks
10
10
  # and step definitions defined here.
11
+ #
12
+ # Supported hooks:
13
+ # - `@startup`: start before scenario, stop after scenario
14
+ # - `@manual`: stop after scenario; use `When I start the system` to start manually
15
+ # - `@clear`: clear memoized Nonnative state before scenario
16
+ # - `@reset`: reset proxies after scenario
17
+ #
18
+ # Installed step definitions:
19
+ # - `Given I set the proxy for service {string} to {string}`
20
+ # - `Then I should reset the proxy for service {string}`
21
+ # - `When I start the system`
22
+ # - `When I attempt to start the system`
23
+ # - `When I attempt to stop the system`
24
+ # - `Then I should see {string} as unhealthy`
25
+ # - `Then I should see {string} as healthy`
26
+ # - `Then the process {string} should consume less than {string} of memory`
27
+ # - `Then starting the system should raise an error`
28
+ # - `Then stopping the system should raise an error`
29
+ # - `Then I should see a log entry of {string} for process {string}`
30
+ # - `Then I should see a log entry of {string} in the file {string}`
11
31
  module Cucumber
12
32
  module LanguageHook
13
33
  def rb_language=(value)
@@ -11,7 +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
- # - {#invalid_data}: corrupt outbound data by shuffling characters
14
+ # - {#invalid_data}: forward requests unchanged and mutate upstream responses before they reach clients
15
15
  # - {#reset}: return to healthy pass-through behavior
16
16
  #
17
17
  # State changes terminate any active connections so new connections observe the new behavior.
@@ -107,7 +107,7 @@ module Nonnative
107
107
  apply_state :delay
108
108
  end
109
109
 
110
- # Corrupts forwarded data by shuffling characters.
110
+ # Mutates upstream responses while forwarding client requests unchanged.
111
111
  #
112
112
  # @return [void]
113
113
  def invalid_data
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nonnative
4
+ # Probes a managed process HTTP readiness endpoint.
5
+ class HTTPProbe < Nonnative::HTTPClient
6
+ NETWORK_ERRORS = [
7
+ Errno::ECONNREFUSED,
8
+ Errno::EHOSTUNREACH,
9
+ Errno::ECONNRESET,
10
+ SocketError,
11
+ RestClient::Exceptions::Timeout,
12
+ RestClient::ServerBrokeConnection
13
+ ].freeze
14
+
15
+ # @param process [Nonnative::ConfigurationProcess] process configuration with readiness attributes
16
+ def initialize(process)
17
+ @readiness = process.readiness
18
+ @base_url = "http://#{process.host}:#{readiness.port}"
19
+ @timeout = Nonnative::Timeout.new(process.timeout)
20
+
21
+ super(base_url)
22
+ end
23
+
24
+ # Returns whether the configured HTTP endpoint returns a 2xx response before timeout.
25
+ #
26
+ # @return [Boolean]
27
+ def ready?
28
+ Nonnative.logger.info "checking if readiness '#{endpoint}' is ready"
29
+
30
+ timeout.perform do
31
+ response = get(readiness.path)
32
+ raise Nonnative::Error unless ready_response?(response)
33
+
34
+ true
35
+ rescue Nonnative::Error, *NETWORK_ERRORS
36
+ sleep_interval
37
+ retry
38
+ end
39
+ end
40
+
41
+ # Returns the HTTP readiness endpoint for lifecycle diagnostics.
42
+ #
43
+ # @return [String]
44
+ def endpoint
45
+ "#{base_url}#{path}"
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :base_url, :readiness, :timeout
51
+
52
+ def path
53
+ readiness.path.start_with?('/') ? readiness.path : "/#{readiness.path}"
54
+ end
55
+
56
+ def ready_response?(response)
57
+ response.respond_to?(:code) && response.code.to_i.between?(200, 299)
58
+ end
59
+
60
+ def sleep_interval
61
+ sleep 0.01
62
+ end
63
+ end
64
+ end
@@ -23,6 +23,14 @@ module Nonnative
23
23
  #
24
24
  # Supported HTTP verbs: GET, POST, PUT, PATCH, DELETE.
25
25
  class HTTPProxy < Sinatra::Application
26
+ NON_FORWARDABLE_HEADERS = %w[
27
+ Host
28
+ Accept-Encoding
29
+ Version
30
+ Proxy-Authenticate
31
+ Proxy-Authorization
32
+ ].freeze
33
+
26
34
  # Extracts request headers from the Rack environment and normalizes them to standard HTTP names.
27
35
  #
28
36
  # Certain hop-by-hop or proxy-specific headers are removed.
@@ -36,7 +44,7 @@ module Nonnative
36
44
  result[normalized_header_name(header)] = value
37
45
  end
38
46
 
39
- headers.except('Host', 'Accept-Encoding', 'Version')
47
+ headers.except(*NON_FORWARDABLE_HEADERS)
40
48
  end
41
49
 
42
50
  # Builds the upstream URL for the given request.
@@ -9,14 +9,20 @@ module Nonnative
9
9
  # The server is started and stopped by {Nonnative::Server} via {#perform_start} / {#perform_stop}.
10
10
  #
11
11
  # @example Running a Sinatra app
12
- # app = Sinatra.new do
13
- # get('/hello') { 'Hello World!' }
12
+ # class HelloHTTPServer < Nonnative::HTTPServer
13
+ # def initialize(service)
14
+ # app = Sinatra.new do
15
+ # get('/hello') { 'Hello World!' }
16
+ # end
17
+ #
18
+ # super(app, service)
19
+ # end
14
20
  # end
15
21
  #
16
22
  # Nonnative.configure do |config|
17
23
  # config.server do |s|
18
24
  # s.name = 'http'
19
- # s.klass = ->(service) { Nonnative::HTTPServer.new(app, service) }
25
+ # s.klass = HelloHTTPServer
20
26
  # s.timeout = 2
21
27
  # s.host = '127.0.0.1'
22
28
  # s.ports = [4567]
@@ -24,7 +30,7 @@ module Nonnative
24
30
  # end
25
31
  # end
26
32
  #
27
- # Note: In YAML configuration you typically set `class` to a concrete subclass that calls `super(app, service)`.
33
+ # YAML configuration uses the same concrete subclass name in its `class` field.
28
34
  #
29
35
  # @see Nonnative::Server
30
36
  class HTTPServer < Nonnative::Server
@@ -33,7 +39,8 @@ module Nonnative
33
39
  # @param app [#call] a Rack-compatible application (e.g. Sinatra/Rack app)
34
40
  # @param service [Nonnative::ConfigurationServer] server configuration
35
41
  def initialize(app, service)
36
- log = File.open(service.log, 'a')
42
+ # Keep the log IO so the server lifecycle can release Puma's file handle on stop.
43
+ @log = File.open(service.log, 'a')
37
44
  options = {
38
45
  log_writer: Puma::LogWriter.new(log, log),
39
46
  force_shutdown_after: service.timeout
@@ -60,10 +67,16 @@ module Nonnative
60
67
  # @return [void]
61
68
  def perform_stop
62
69
  server.graceful_shutdown
70
+ ensure
71
+ close_log
63
72
  end
64
73
 
65
74
  private
66
75
 
67
- attr_reader :server
76
+ attr_reader :server, :log
77
+
78
+ def close_log
79
+ log.close unless log.closed?
80
+ end
68
81
  end
69
82
  end
@@ -3,7 +3,7 @@
3
3
  module Nonnative
4
4
  # No-op proxy implementation.
5
5
  #
6
- # This is the default proxy when `service.proxy.kind` is `"none"` (or an unknown kind is provided).
6
+ # This is the default proxy when `service.proxy.kind` is `"none"`.
7
7
  # It does not bind/listen or alter traffic; it simply exposes the underlying runner's configured
8
8
  # `host` and primary `port`.
9
9
  #
@@ -30,6 +30,7 @@ module Nonnative
30
30
  # @yieldparam name [String, nil] runner name
31
31
  # @yieldparam values [Object] runner-specific return value from `start` (e.g. `[pid, running]` for processes)
32
32
  # @yieldparam result [Boolean] result of the port readiness check (`true` if ready in time)
33
+ # @yieldparam port [Nonnative::Ports] checked port group
33
34
  # @return [Array<String>] lifecycle and readiness-check errors collected while starting
34
35
  def start(&)
35
36
  errors = []
@@ -47,6 +48,7 @@ module Nonnative
47
48
  # @yieldparam name [String, nil] runner name
48
49
  # @yieldparam id [Object] runner-specific identifier returned by `stop` (e.g. pid or object_id)
49
50
  # @yieldparam result [Boolean] result of the port shutdown check (`true` if closed in time)
51
+ # @yieldparam port [Nonnative::Ports] checked port group
50
52
  # @return [Array<String>] lifecycle and shutdown-check errors collected while stopping
51
53
  def stop(&)
52
54
  errors = []
@@ -65,6 +67,7 @@ module Nonnative
65
67
  # @yieldparam name [String, nil] runner name
66
68
  # @yieldparam id [Object] runner-specific identifier returned by `stop`
67
69
  # @yieldparam result [Boolean] result of the port shutdown check (`true` if closed in time)
70
+ # @yieldparam port [Nonnative::Ports] checked port group
68
71
  # @return [Array<String>] lifecycle and shutdown-check errors collected while rolling back
69
72
  def rollback(&)
70
73
  errors = []
@@ -183,7 +186,7 @@ module Nonnative
183
186
 
184
187
  runners.each do |runner, port|
185
188
  values = runner.send(lifecycle_method)
186
- checks << [runner, values, Thread.new { check_port(port, port_method) }]
189
+ checks << [runner, values, port, Thread.new { check_port(port, port_method) }]
187
190
  rescue StandardError => e
188
191
  errors << lifecycle_error(action, runner, e)
189
192
  end
@@ -198,12 +201,12 @@ module Nonnative
198
201
  end
199
202
 
200
203
  def yield_results(checks, action, &)
201
- checks.each_with_object([]) do |(type, values, thread), errors|
204
+ checks.each_with_object([]) do |(type, values, port, thread), errors|
202
205
  result = thread.value
203
206
  if result[:error]
204
207
  errors << port_error(action, type, result[:error])
205
208
  elsif block_given?
206
- yield type.name, values, result[:result]
209
+ yield type.name, values, result[:result], port
207
210
  end
208
211
  end
209
212
  end
@@ -61,6 +61,13 @@ module Nonnative
61
61
  end
62
62
  end
63
63
 
64
+ # Returns a human-readable endpoint for lifecycle diagnostics.
65
+ #
66
+ # @return [String]
67
+ def endpoint
68
+ "#{process.host}:#{port}"
69
+ end
70
+
64
71
  private
65
72
 
66
73
  attr_reader :process, :port, :timeout
@@ -10,14 +10,16 @@ module Nonnative
10
10
  class Ports
11
11
  # @param runner [#host, #ports, #timeout] runner configuration providing connection details
12
12
  def initialize(runner)
13
+ @runner = runner
13
14
  @ports = runner.ports.map { |port| Nonnative::Port.new(runner, port) }
15
+ @readiness = Nonnative::HTTPProbe.new(runner) if runner.respond_to?(:readiness) && runner.readiness
14
16
  end
15
17
 
16
18
  # Returns whether all configured ports become connectable before their timeouts elapse.
17
19
  #
18
20
  # @return [Boolean]
19
21
  def open?
20
- ports.all?(&:open?)
22
+ ports.all?(&:open?) && (readiness.nil? || readiness.ready?)
21
23
  end
22
24
 
23
25
  # Returns whether all configured ports become non-connectable before their timeouts elapse.
@@ -27,8 +29,27 @@ module Nonnative
27
29
  ports.all?(&:closed?)
28
30
  end
29
31
 
32
+ # Returns the checked endpoints for lifecycle diagnostics.
33
+ #
34
+ # @return [String]
35
+ def endpoints
36
+ ports.map(&:endpoint).join(', ')
37
+ end
38
+
39
+ # Returns endpoint and log context for lifecycle errors.
40
+ #
41
+ # @return [String]
42
+ def description
43
+ details = []
44
+ details << "readiness: #{readiness.endpoint}" if readiness
45
+ log = runner.log if runner.respond_to?(:log)
46
+ details << "log: #{log}" if log
47
+
48
+ details.empty? ? endpoints : "#{endpoints} (#{details.join('; ')})"
49
+ end
50
+
30
51
  private
31
52
 
32
- attr_reader :ports
53
+ attr_reader :ports, :readiness, :runner
33
54
  end
34
55
  end
@@ -49,6 +49,7 @@ module Nonnative
49
49
  if process_exists?
50
50
  process_kill
51
51
  stopped = wait_stop != false
52
+ force_stop unless stopped
52
53
  end
53
54
 
54
55
  [pid, stopped]
@@ -82,6 +83,13 @@ module Nonnative
82
83
  ::Process.kill(signal, pid)
83
84
  end
84
85
 
86
+ def force_stop
87
+ ::Process.kill('KILL', pid)
88
+ wait_stop
89
+ rescue Errno::ESRCH, Errno::ECHILD
90
+ true
91
+ end
92
+
85
93
  def process_spawn
86
94
  environment = service.environment.to_h
87
95
  environment = environment.transform_keys(&:to_s).transform_values(&:to_s)
@@ -6,11 +6,14 @@ module Nonnative
6
6
  # A proxy is responsible for interposing behavior between a client and a target service.
7
7
  # Runtime services create a proxy instance via {Nonnative::ProxyFactory} based on `service.proxy.kind`.
8
8
  #
9
+ # Service configuration `host` and `port` are the client-facing endpoint. Concrete proxy methods are
10
+ # implementation-specific; for example {Nonnative::FaultInjectionProxy#host} and
11
+ # {Nonnative::FaultInjectionProxy#port} return the upstream target behind the proxy.
12
+ #
9
13
  # Concrete proxies typically implement these public methods:
10
14
  # - `start`: begin proxying (bind/listen, start threads, etc)
11
15
  # - `stop`: stop proxying and release resources
12
16
  # - `reset`: return proxy behavior to a healthy/default state
13
- # - `host` / `port`: endpoint clients should connect to when the proxy is enabled
14
17
  #
15
18
  # @see Nonnative::ProxyFactory
16
19
  # @see Nonnative::NoProxy
@@ -6,7 +6,9 @@ module Nonnative
6
6
  # A runtime service constructs a proxy via this factory. The proxy implementation is selected by
7
7
  # `service.proxy.kind` and resolved using {Nonnative.proxy}.
8
8
  #
9
- # If the kind is unknown (or `"none"`), {Nonnative.proxy} returns {Nonnative::NoProxy}.
9
+ # If the kind is `"none"`, {Nonnative.proxy} returns {Nonnative::NoProxy}.
10
+ # Unknown non-`"none"` kinds raise an error so proxy configuration typos do not silently disable
11
+ # fault injection.
10
12
  #
11
13
  # @see Nonnative.proxy
12
14
  # @see Nonnative.proxies
@@ -4,5 +4,5 @@ module Nonnative
4
4
  # The current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '3.2.1'
7
+ VERSION = '3.9.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
9
+ # - wait for readiness via port checks and optional process HTTP 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,6 +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
31
  # end
31
32
  # end
32
33
  #
@@ -36,10 +37,10 @@
36
37
  # # run tests...
37
38
  # Nonnative.stop
38
39
  #
39
- # == Notes
40
+ # == Cucumber integration
40
41
  #
41
- # This file also requires integration helpers used by acceptance tests. If you require `nonnative` outside a
42
- # Cucumber runtime, loading `nonnative/cucumber` may not be desirable for your environment.
42
+ # Requiring `nonnative` loads the lazy Cucumber integration. It is safe outside a booted Cucumber
43
+ # runtime; hooks and step definitions are installed once Cucumber's Ruby DSL is ready.
43
44
  #
44
45
  require 'socket'
45
46
  require 'timeout'
@@ -72,6 +73,7 @@ require 'nonnative/ports'
72
73
  require 'nonnative/configuration_file'
73
74
  require 'nonnative/configuration'
74
75
  require 'nonnative/configuration_runner'
76
+ require 'nonnative/configuration_readiness'
75
77
  require 'nonnative/configuration_process'
76
78
  require 'nonnative/configuration_server'
77
79
  require 'nonnative/configuration_service'
@@ -82,6 +84,7 @@ require 'nonnative/server'
82
84
  require 'nonnative/service'
83
85
  require 'nonnative/pool'
84
86
  require 'nonnative/http_client'
87
+ require 'nonnative/http_probe'
85
88
  require 'nonnative/http_server'
86
89
  require 'nonnative/http_proxy_server'
87
90
  require 'nonnative/grpc_server'
@@ -204,7 +207,7 @@ module Nonnative
204
207
  # @param kind [String] proxy kind name (for example `"fault_injection"`)
205
208
  # @return [Class] a subclass of {Nonnative::Proxy}
206
209
  def proxy(kind)
207
- Nonnative.proxies[kind] || Nonnative::NoProxy
210
+ kind.nil? || kind == 'none' ? NoProxy : proxies.fetch(kind) { raise ArgumentError, "Unsupported proxy kind '#{kind}'" }
208
211
  end
209
212
 
210
213
  # Starts all configured services, servers, and processes, and waits for readiness.
@@ -216,9 +219,9 @@ module Nonnative
216
219
  def start
217
220
  @pool ||= Nonnative::Pool.new(configuration)
218
221
  errors = []
219
- errors.concat(@pool.start do |name, values, result|
222
+ errors.concat(@pool.start do |name, values, result, ports|
220
223
  id, started = values
221
- errors << "Started #{name} with id #{id}, though did not respond in time" if !started || !result
224
+ errors << "Started #{name} with id #{id}, though did not respond in time for #{ports.description}" if !started || !result
222
225
  end)
223
226
  nil
224
227
  rescue StandardError => e
@@ -239,9 +242,9 @@ module Nonnative
239
242
  errors = []
240
243
  return if @pool.nil?
241
244
 
242
- errors.concat(@pool.stop do |name, values, result|
245
+ errors.concat(@pool.stop do |name, values, result, ports|
243
246
  id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
244
- errors << "Stopped #{name} with id #{id}, though did not respond in time" unless result
247
+ errors << "Stopped #{name} with id #{id}, though did not respond in time for #{ports.description}" unless result
245
248
  errors << "Stopped #{name} with id #{id}, though the process did not exit in time" unless stopped
246
249
  end)
247
250
  nil
@@ -305,9 +308,9 @@ module Nonnative
305
308
  errors = []
306
309
  return errors if @pool.nil?
307
310
 
308
- errors.concat(@pool.rollback do |name, values, result|
311
+ errors.concat(@pool.rollback do |name, values, result, ports|
309
312
  id, stopped = Array(values).then { |v| [v.first, v.fetch(1, true)] }
310
- errors << "Rollback failed for #{name} with id #{id}, because it did not stop in time" unless result
313
+ errors << "Rollback failed for #{name} with id #{id}, because it did not stop in time for #{ports.description}" unless result
311
314
  errors << "Rollback failed for #{name} with id #{id}, because the process did not exit in time" unless stopped
312
315
  end)
313
316
  rescue StandardError => e
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.2.1
4
+ version: 3.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alejandro Falkowski
@@ -291,6 +291,7 @@ files:
291
291
  - lib/nonnative/configuration_file.rb
292
292
  - lib/nonnative/configuration_process.rb
293
293
  - lib/nonnative/configuration_proxy.rb
294
+ - lib/nonnative/configuration_readiness.rb
294
295
  - lib/nonnative/configuration_runner.rb
295
296
  - lib/nonnative/configuration_server.rb
296
297
  - lib/nonnative/configuration_service.rb
@@ -302,6 +303,7 @@ files:
302
303
  - lib/nonnative/grpc_server.rb
303
304
  - lib/nonnative/header.rb
304
305
  - lib/nonnative/http_client.rb
306
+ - lib/nonnative/http_probe.rb
305
307
  - lib/nonnative/http_proxy_server.rb
306
308
  - lib/nonnative/http_server.rb
307
309
  - lib/nonnative/invalid_data_socket_pair.rb