nonnative 2.0 → 2.8.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.
@@ -20,7 +20,8 @@ module Nonnative
20
20
  # @return [String, nil] path to proxy log file (implementation-dependent)
21
21
  # @return [Numeric] wait interval (seconds) after proxy state changes (defaults to `0.1`)
22
22
  # @return [Hash] proxy implementation options (implementation-dependent)
23
- attr_accessor :kind, :host, :port, :log, :wait, :options
23
+ attr_accessor :kind, :host, :port, :log, :wait
24
+ attr_reader :options
24
25
 
25
26
  # Creates a proxy configuration with defaults.
26
27
  #
@@ -39,5 +40,16 @@ module Nonnative
39
40
  self.wait = 0.1
40
41
  self.options = {}
41
42
  end
43
+
44
+ # Stores proxy implementation options.
45
+ #
46
+ # Nil is normalized to an empty hash so callers loading partial configuration do not erase the
47
+ # default options container.
48
+ #
49
+ # @param value [Hash, nil]
50
+ # @return [void]
51
+ def options=(value)
52
+ @options = value || {}
53
+ end
42
54
  end
43
55
  end
@@ -1,105 +1,201 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- World(RSpec::Benchmark::Matchers)
4
- World(RSpec::Matchers)
5
- World(RSpec::Wait)
6
-
7
- Before('@startup') do
8
- Nonnative.start
9
- end
10
-
11
- After('@startup') do
12
- Nonnative.stop
13
- end
14
-
15
- After('@manual') do
16
- Nonnative.stop
17
- end
18
-
19
- Before('@clear') do
20
- Nonnative.clear
21
- end
22
-
23
- After('@reset') do
24
- Nonnative.reset
25
- end
26
-
27
- Given('I set the proxy for process {string} to {string}') do |name, operation|
28
- process = Nonnative.pool.process_by_name(name)
29
- process.proxy.send(operation)
30
- end
31
-
32
- Given('I set the proxy for server {string} to {string}') do |name, operation|
33
- server = Nonnative.pool.server_by_name(name)
34
- server.proxy.send(operation)
35
- end
36
-
37
- Given('I set the proxy for service {string} to {string}') do |name, operation|
38
- service = Nonnative.pool.service_by_name(name)
39
- service.proxy.send(operation)
40
- end
41
-
42
- Given('I start the system') do
43
- Nonnative.start
44
- end
45
-
46
- Given('I should see {string} as unhealthy') do |service|
47
- opts = {
48
- headers: { content_type: :json, accept: :json },
49
- read_timeout: 10, open_timeout: 10
50
- }
51
-
52
- wait_for { Nonnative.observability.health(opts).code }.to eq(503)
53
- wait_for { Nonnative.observability.health(opts).body }.to include(service)
54
- end
55
-
56
- Then('I should reset the proxy for process {string}') do |name|
57
- process = Nonnative.pool.process_by_name(name)
58
- process.proxy.reset
59
- end
60
-
61
- Then('I should reset the proxy for server {string}') do |name|
62
- server = Nonnative.pool.server_by_name(name)
63
- server.proxy.reset
64
- end
65
-
66
- Then('I should reset the proxy for service {string}') do |name|
67
- service = Nonnative.pool.service_by_name(name)
68
- service.proxy.reset
69
- end
70
-
71
- Then('the process {string} should consume less than {string} of memory') do |name, mem|
72
- process = Nonnative.pool.process_by_name(name)
73
- _, size, type = mem.split(/(\d+)/)
74
- actual = process.memory.send(type)
75
- size = size.to_i
76
-
77
- expect(actual).to be < size
78
- end
79
-
80
- Then('starting the system should raise an error') do
81
- expect { Nonnative.start }.to raise_error(Nonnative::StartError)
82
- end
83
-
84
- Then('stopping the system should raise an error') do
85
- expect { Nonnative.stop }.to raise_error(Nonnative::StopError)
86
- end
87
-
88
- Then('I should see a log entry of {string} for process {string}') do |message, process|
89
- process = Nonnative.configuration.process_by_name(process)
90
- expect(Nonnative.log_lines(process.log, ->(l) { l.include?(message) }).first).to include(message)
91
- end
92
-
93
- Then('I should see a log entry of {string} in the file {string}') do |message, path|
94
- expect(Nonnative.log_lines(path, ->(l) { l.include?(message) }).first).to include(message)
95
- end
96
-
97
- Then('I should see {string} as healthy') do |service|
98
- opts = {
99
- headers: { content_type: :json, accept: :json },
100
- read_timeout: 10, open_timeout: 10
101
- }
102
-
103
- wait_for { Nonnative.observability.health(opts).code }.to eq(200)
104
- wait_for { Nonnative.observability.health(opts).body }.to_not include(service)
105
- end
3
+ require 'cucumber'
4
+
5
+ module Nonnative
6
+ # Lazily installs the Cucumber integration once the Cucumber Ruby DSL is ready.
7
+ #
8
+ # Requiring `nonnative` outside a running Cucumber environment should not fail, but when Cucumber
9
+ # does finish booting its support-code registry this installer still needs to register the hooks
10
+ # and step definitions defined here.
11
+ module Cucumber
12
+ module LanguageHook
13
+ def rb_language=(value)
14
+ super.tap { ::Nonnative::Cucumber.install! }
15
+ end
16
+ end
17
+
18
+ module WorldHooks
19
+ def install_world
20
+ World(::RSpec::Benchmark::Matchers)
21
+ World(::RSpec::Matchers)
22
+ World(::RSpec::Wait)
23
+ end
24
+
25
+ def install_hooks
26
+ Before('@startup') { Nonnative.start }
27
+ After('@startup') { Nonnative.stop }
28
+ After('@manual') { Nonnative.stop }
29
+ Before('@clear') { Nonnative.clear }
30
+ After('@reset') { Nonnative.reset }
31
+ end
32
+ end
33
+
34
+ module ProxySteps
35
+ def install_proxy_steps
36
+ install_proxy_mutation_steps
37
+ install_proxy_reset_steps
38
+ end
39
+
40
+ def install_proxy_mutation_steps
41
+ Given('I set the proxy for process {string} to {string}') do |name, operation|
42
+ process = Nonnative.pool.process_by_name(name)
43
+ process.proxy.send(operation)
44
+ end
45
+
46
+ Given('I set the proxy for server {string} to {string}') do |name, operation|
47
+ server = Nonnative.pool.server_by_name(name)
48
+ server.proxy.send(operation)
49
+ end
50
+
51
+ Given('I set the proxy for service {string} to {string}') do |name, operation|
52
+ service = Nonnative.pool.service_by_name(name)
53
+ service.proxy.send(operation)
54
+ end
55
+ end
56
+
57
+ def install_proxy_reset_steps
58
+ Then('I should reset the proxy for process {string}') do |name|
59
+ process = Nonnative.pool.process_by_name(name)
60
+ process.proxy.reset
61
+ end
62
+
63
+ Then('I should reset the proxy for server {string}') do |name|
64
+ server = Nonnative.pool.server_by_name(name)
65
+ server.proxy.reset
66
+ end
67
+
68
+ Then('I should reset the proxy for service {string}') do |name|
69
+ service = Nonnative.pool.service_by_name(name)
70
+ service.proxy.reset
71
+ end
72
+ end
73
+ end
74
+
75
+ module LifecycleSteps
76
+ def install_state_steps
77
+ install_start_step
78
+ install_unhealthy_step
79
+ install_healthy_step
80
+ end
81
+
82
+ def install_start_step
83
+ Given('I start the system') do
84
+ Nonnative.start
85
+ end
86
+ end
87
+
88
+ def install_unhealthy_step
89
+ opts = observability_options
90
+
91
+ Given('I should see {string} as unhealthy') do |service|
92
+ wait_for { Nonnative.observability.health(opts).code }.to eq(503)
93
+ wait_for { Nonnative.observability.health(opts).body }.to include(service)
94
+ end
95
+ end
96
+
97
+ def install_healthy_step
98
+ opts = observability_options
99
+
100
+ Then('I should see {string} as healthy') do |service|
101
+ wait_for { Nonnative.observability.health(opts).code }.to eq(200)
102
+ wait_for { Nonnative.observability.health(opts).body }.to_not include(service)
103
+ end
104
+ end
105
+
106
+ def observability_options
107
+ {
108
+ headers: { content_type: :json, accept: :json },
109
+ read_timeout: 10,
110
+ open_timeout: 10
111
+ }
112
+ end
113
+ end
114
+
115
+ module Assertions
116
+ def install_assertion_steps
117
+ install_memory_assertion_step
118
+ install_error_assertion_steps
119
+ install_log_assertion_steps
120
+ end
121
+
122
+ def install_memory_assertion_step
123
+ Then('the process {string} should consume less than {string} of memory') do |name, mem|
124
+ process = Nonnative.pool.process_by_name(name)
125
+ _, size, type = mem.split(/(\d+)/)
126
+ actual = process.memory.send(type)
127
+ size = size.to_i
128
+
129
+ expect(actual).to be < size
130
+ end
131
+ end
132
+
133
+ def install_error_assertion_steps
134
+ Then('starting the system should raise an error') do
135
+ expect { Nonnative.start }.to raise_error(Nonnative::StartError)
136
+ end
137
+
138
+ Then('stopping the system should raise an error') do
139
+ expect { Nonnative.stop }.to raise_error(Nonnative::StopError)
140
+ end
141
+ end
142
+
143
+ def install_log_assertion_steps
144
+ Then('I should see a log entry of {string} for process {string}') do |message, process|
145
+ process = Nonnative.configuration.process_by_name(process)
146
+ expect(Nonnative.log_lines(process.log, ->(l) { l.include?(message) }).first).to include(message)
147
+ end
148
+
149
+ Then('I should see a log entry of {string} in the file {string}') do |message, path|
150
+ expect(Nonnative.log_lines(path, ->(l) { l.include?(message) }).first).to include(message)
151
+ end
152
+ end
153
+ end
154
+
155
+ module Registration
156
+ extend ::Cucumber::Glue::Dsl
157
+ extend WorldHooks
158
+ extend ProxySteps
159
+ extend LifecycleSteps
160
+ extend Assertions
161
+
162
+ class << self
163
+ def install!
164
+ install_world
165
+ install_hooks
166
+ install_proxy_steps
167
+ install_state_steps
168
+ install_assertion_steps
169
+ end
170
+ end
171
+ end
172
+
173
+ class << self
174
+ def bootstrap!
175
+ return if @bootstrapped
176
+
177
+ dsl_singleton = ::Cucumber::Glue::Dsl.singleton_class
178
+ dsl_singleton.prepend(LanguageHook) unless dsl_singleton.ancestors.include?(LanguageHook)
179
+
180
+ @bootstrapped = true
181
+ install!
182
+ end
183
+
184
+ def install!
185
+ return if @installed
186
+ return unless ready?
187
+
188
+ Registration.install!
189
+ @installed = true
190
+ end
191
+
192
+ private
193
+
194
+ def ready?
195
+ !::Cucumber::Glue::Dsl.instance_variable_get(:@rb_language).nil?
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ Nonnative::Cucumber.bootstrap!
@@ -30,10 +30,11 @@ module Nonnative
30
30
  # @param request [Sinatra::Request] the incoming request
31
31
  # @return [Hash{String=>String}] headers to forward to the upstream
32
32
  def retrieve_headers(request)
33
- headers = request.env.map do |header, value|
34
- [header[5..].split('_').map(&:capitalize).join('-'), value] if header.start_with?('HTTP_')
33
+ headers = request.env.each_with_object({}) do |(header, value), result|
34
+ next unless forward_header?(header)
35
+
36
+ result[normalized_header_name(header)] = value
35
37
  end
36
- headers = headers.compact.to_h
37
38
 
38
39
  headers.except('Host', 'Accept-Encoding', 'Version')
39
40
  end
@@ -53,19 +54,45 @@ module Nonnative
53
54
  # @param uri [String] upstream URI
54
55
  # @param opts [Hash] RestClient options (e.g. headers)
55
56
  # @return [RestClient::Response] response for error statuses, otherwise RestClient return value
56
- def api_response(verb, uri, opts)
57
- client = RestClient::Resource.new(uri, opts)
57
+ def api_response(method:, url:, headers:, payload: nil)
58
+ options = { method:, url:, headers: }
59
+ options[:payload] = payload unless payload.nil?
58
60
 
59
- client.send(verb)
61
+ RestClient::Request.execute(options)
60
62
  rescue RestClient::Exception => e
61
63
  e.response
62
64
  end
63
65
 
66
+ # Extracts the request payload for verbs that can carry a body.
67
+ #
68
+ # @param request [Sinatra::Request] the incoming request
69
+ # @param verb [String] HTTP verb name (e.g. `"post"`)
70
+ # @return [String, nil] request payload for body-carrying verbs
71
+ def retrieve_payload(request, verb)
72
+ return unless %w[post put patch delete].include?(verb)
73
+
74
+ payload = request.body.read
75
+ payload unless payload.empty?
76
+ end
77
+
78
+ private
79
+
80
+ def forward_header?(header)
81
+ header.start_with?('HTTP_') || %w[CONTENT_TYPE CONTENT_LENGTH].include?(header)
82
+ end
83
+
84
+ def normalized_header_name(header)
85
+ header.delete_prefix('HTTP_').split('_').map(&:capitalize).join('-')
86
+ end
87
+
64
88
  %w[get post put patch delete].each do |verb|
65
89
  send(verb, /.*/) do
66
- uri = build_url(request, settings)
67
- opts = { headers: retrieve_headers(request) }
68
- res = api_response(verb, uri, opts)
90
+ res = api_response(
91
+ method: verb.to_sym,
92
+ url: build_url(request, settings),
93
+ headers: retrieve_headers(request),
94
+ payload: retrieve_payload(request, verb)
95
+ )
69
96
 
70
97
  status res.code
71
98
  res.body
@@ -18,6 +18,9 @@ module Nonnative
18
18
  # @param configuration [Nonnative::Configuration] the configuration to run
19
19
  def initialize(configuration)
20
20
  @configuration = configuration
21
+ @services = nil
22
+ @servers = nil
23
+ @processes = nil
21
24
  end
22
25
 
23
26
  # Starts all configured runners and yields results for each process/server.
@@ -27,10 +30,14 @@ module Nonnative
27
30
  # @yieldparam name [String, nil] runner name
28
31
  # @yieldparam values [Object] runner-specific return value from `start` (e.g. `[pid, running]` for processes)
29
32
  # @yieldparam result [Boolean] result of the port readiness check (`true` if ready in time)
30
- # @return [void]
33
+ # @return [Array<String>] lifecycle and readiness-check errors collected while starting
31
34
  def start(&)
32
- services.each(&:start)
33
- [servers, processes].each { |t| process(t, :start, :open?, &) }
35
+ errors = []
36
+
37
+ errors.concat(service_lifecycle(services, :start, :start))
38
+ [servers, processes].each { |t| errors.concat(process(t, :start, :open?, :start, &)) }
39
+
40
+ errors
34
41
  end
35
42
 
36
43
  # Stops all configured runners and yields results for each process/server.
@@ -40,10 +47,32 @@ module Nonnative
40
47
  # @yieldparam name [String, nil] runner name
41
48
  # @yieldparam id [Object] runner-specific identifier returned by `stop` (e.g. pid or object_id)
42
49
  # @yieldparam result [Boolean] result of the port shutdown check (`true` if closed in time)
43
- # @return [void]
50
+ # @return [Array<String>] lifecycle and shutdown-check errors collected while stopping
44
51
  def stop(&)
45
- [processes, servers].each { |t| process(t, :stop, :closed?, &) }
46
- services.each(&:stop)
52
+ errors = []
53
+
54
+ [processes, servers].each { |t| errors.concat(process(t, :stop, :closed?, :stop, &)) }
55
+ errors.concat(service_lifecycle(services, :stop, :stop))
56
+
57
+ errors
58
+ end
59
+
60
+ # Stops only runners that have already been instantiated in this pool.
61
+ #
62
+ # This is used to rollback partial startup after a failed {#start} without constructing new runner
63
+ # wrappers as a side effect.
64
+ #
65
+ # @yieldparam name [String, nil] runner name
66
+ # @yieldparam id [Object] runner-specific identifier returned by `stop`
67
+ # @yieldparam result [Boolean] result of the port shutdown check (`true` if closed in time)
68
+ # @return [Array<String>] lifecycle and shutdown-check errors collected while rolling back
69
+ def rollback(&)
70
+ errors = []
71
+
72
+ [existing_processes, existing_servers].each { |t| errors.concat(process(t, :stop, :closed?, :stop, &)) }
73
+ errors.concat(service_lifecycle(existing_services, :stop, :stop))
74
+
75
+ errors
47
76
  end
48
77
 
49
78
  # Finds a running process runner by configured name.
@@ -96,41 +125,103 @@ module Nonnative
96
125
  end
97
126
 
98
127
  def processes
99
- @processes ||= configuration.processes.map do |p|
100
- [Nonnative::Process.new(p), Nonnative::Port.new(p)]
128
+ return @processes unless @processes.nil?
129
+
130
+ @processes = []
131
+ configuration.processes.each do |p|
132
+ @processes << [Nonnative::Process.new(p), Nonnative::Port.new(p)]
101
133
  end
134
+
135
+ @processes
102
136
  end
103
137
 
104
138
  def servers
105
- @servers ||= configuration.servers.map do |s|
106
- [s.klass.new(s), Nonnative::Port.new(s)]
139
+ return @servers unless @servers.nil?
140
+
141
+ @servers = []
142
+ configuration.servers.each do |s|
143
+ @servers << [s.klass.new(s), Nonnative::Port.new(s)]
107
144
  end
145
+
146
+ @servers
108
147
  end
109
148
 
110
149
  def services
111
- @services ||= configuration.services.map { |s| Nonnative::Service.new(s) }
150
+ return @services unless @services.nil?
151
+
152
+ @services = []
153
+ configuration.services.each do |s|
154
+ @services << Nonnative::Service.new(s)
155
+ end
156
+
157
+ @services
158
+ end
159
+
160
+ def existing_processes
161
+ @processes || []
162
+ end
163
+
164
+ def existing_servers
165
+ @servers || []
166
+ end
167
+
168
+ def existing_services
169
+ @services || []
112
170
  end
113
171
 
114
- def process(all, type_method, port_method, &)
115
- types = []
116
- pids = []
117
- threads = []
172
+ def service_lifecycle(all, type_method, action)
173
+ all.each_with_object([]) do |service, errors|
174
+ service.send(type_method)
175
+ rescue StandardError => e
176
+ errors << lifecycle_error(action, service, e)
177
+ end
178
+ end
179
+
180
+ def process(all, type_method, port_method, action, &)
181
+ checks = []
182
+ errors = []
118
183
 
119
184
  all.each do |type, port|
120
- types << type
121
- pids << type.send(type_method)
122
- threads << Thread.new { port.send(port_method) }
185
+ values = type.send(type_method)
186
+ checks << [type, values, Thread.new { check_port(port, port_method) }]
187
+ rescue StandardError => e
188
+ errors << lifecycle_error(action, type, e)
123
189
  end
124
190
 
125
- ports = threads.map(&:value)
191
+ errors.concat(yield_results(checks, action, &))
192
+ end
126
193
 
127
- yield_results(types, pids, ports, &)
194
+ def check_port(port, port_method)
195
+ { result: port.send(port_method) }
196
+ rescue StandardError => e
197
+ { error: e }
128
198
  end
129
199
 
130
- def yield_results(all, pids, ports)
131
- all.zip(pids, ports).each do |type, values, result|
132
- yield type.name, values, result
200
+ def yield_results(checks, action, &)
201
+ checks.each_with_object([]) do |(type, values, thread), errors|
202
+ result = thread.value
203
+ if result[:error]
204
+ errors << port_error(action, type, result[:error])
205
+ elsif block_given?
206
+ yield type.name, values, result[:result]
207
+ end
133
208
  end
134
209
  end
210
+
211
+ def lifecycle_error(action, type, error)
212
+ "#{action.to_s.capitalize} failed for #{runner_name(type)}: #{error.class} - #{error.message}"
213
+ end
214
+
215
+ def port_error(action, type, error)
216
+ check = action == :start ? 'readiness' : 'shutdown'
217
+ "#{check.capitalize} check failed for #{runner_name(type)}: #{error.class} - #{error.message}"
218
+ end
219
+
220
+ def runner_name(type)
221
+ name = type.name
222
+ return "runner '#{name}'" if name
223
+
224
+ type.class.to_s
225
+ end
135
226
  end
136
227
  end
@@ -45,11 +45,12 @@ module Nonnative
45
45
  def stop
46
46
  if process_exists?
47
47
  process_kill
48
- proxy.stop
49
48
  wait_stop
50
49
  end
51
50
 
52
51
  pid
52
+ ensure
53
+ proxy.stop
53
54
  end
54
55
 
55
56
  # Returns a memoized memory reader for the spawned process.
@@ -4,5 +4,5 @@ module Nonnative
4
4
  # The current gem version.
5
5
  #
6
6
  # @return [String]
7
- VERSION = '2.0'
7
+ VERSION = '2.8.0'
8
8
  end