overseer-testing-control-rails 0.1.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 +7 -0
- data/MIT-LICENSE +19 -0
- data/README.md +81 -0
- data/app/controllers/overseer/testing_control/rails/application_controller.rb +298 -0
- data/app/controllers/overseer/testing_control/rails/capabilities_controller.rb +14 -0
- data/app/controllers/overseer/testing_control/rails/probes_controller.rb +41 -0
- data/app/controllers/overseer/testing_control/rails/resets_controller.rb +36 -0
- data/app/controllers/overseer/testing_control/rails/sinks_controller.rb +71 -0
- data/app/controllers/overseer/testing_control/rails/states_controller.rb +90 -0
- data/config/routes.rb +9 -0
- data/lib/overseer/testing_control/rails/configuration.rb +370 -0
- data/lib/overseer/testing_control/rails/correlation_middleware.rb +56 -0
- data/lib/overseer/testing_control/rails/engine.rb +17 -0
- data/lib/overseer/testing_control/rails/protocol_v3.rb +11 -0
- data/lib/overseer/testing_control/rails/request_context.rb +26 -0
- data/lib/overseer/testing_control/rails/stores.rb +165 -0
- data/lib/overseer/testing_control/rails/version.rb +9 -0
- data/lib/overseer/testing_control/rails.rb +57 -0
- metadata +94 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a8a82dda4e7a0fd6d97c395b6639d49da084140ef72f072ed1c713189bc02207
|
|
4
|
+
data.tar.gz: 5e3e0951f138eee9ab614e1656abae3e6314594ffd01790264dd581c1662e948
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3a39c6a9bb3e5044c286ce1266e671bbf84d6c303bc812162a38fe1dcad45182e015e5b56ee033a9a9555ab854e3ec3251b4d8ed677a4b96612d53b487da7425
|
|
7
|
+
data.tar.gz: 482c65e907157763aafba1763a741b5d3d7f374af3f06186b890376c8f2f1c121ebec9a10cdaaa93d14f965d75eab4e111d843fde0c1da2d80aadfc7d20d4003
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright 2026 Olistik
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Overseer testing-control Rails helper
|
|
2
|
+
|
|
3
|
+
An optional Rails adapter for [Overseer testing protocol](https://source.olisti.co/overseer/testing-protocol)
|
|
4
|
+
v3. The protocol owns schemas, wire conventions and HTTP conformance. This gem
|
|
5
|
+
owns Rails routing, safe JSON responses, correlation and bounded memory stores.
|
|
6
|
+
It works with frontend tests, curl and CI without Overseer Studio.
|
|
7
|
+
|
|
8
|
+
For a checkout before a published release, the repository includes an exact
|
|
9
|
+
protocol package snapshot under `vendor/`. Applications should pin reviewed
|
|
10
|
+
package snapshots or released versions in their test-only bundle:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
group :test do
|
|
14
|
+
gem 'overseer-testing-protocol', path: 'vendor/gems/overseer-testing-protocol', require: false
|
|
15
|
+
gem 'overseer-testing-control-rails', path: 'vendor/gems/overseer-testing-control-rails',
|
|
16
|
+
require: 'overseer/testing_control/rails'
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Mount `Overseer::TestingControl::Rails::Engine` at `/v1/testing` only in test.
|
|
21
|
+
Configure it in a test-only initializer:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
Overseer::TestingControl::Rails.configure do |config|
|
|
25
|
+
config.identity(application: 'my-api', source: -> { ENV.fetch('TEST_SOURCE') },
|
|
26
|
+
environment: -> { ENV.fetch('TEST_RUNTIME_ID') })
|
|
27
|
+
config.reset(strategy: 'runtime-restart')
|
|
28
|
+
config.register_state(id: 'open-item', version: '1', title: 'Open synthetic item',
|
|
29
|
+
idempotency: 'required', input_schema: OPEN_ITEM_INPUT,
|
|
30
|
+
output_schema: OPEN_ITEM_OUTPUT, handler: Testing::OpenItem.method(:call))
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The adapter fails closed unless Rails is in `test`,
|
|
35
|
+
`OVERSEER_TESTING_CONTROL_ENABLED=true`, and configured identity providers are
|
|
36
|
+
valid. The host must additionally verify its isolation, database identity,
|
|
37
|
+
synthetic data and credential policy. The helper cannot establish network
|
|
38
|
+
containment or safely identify a database on its own.
|
|
39
|
+
|
|
40
|
+
Register probes with input/output schemas and a read-only `handler` callable
|
|
41
|
+
receiving `(input, context)`. Register sinks with effect kinds, outcomes,
|
|
42
|
+
`query_input_schema`, `record_schema` and a `query_matcher` receiving
|
|
43
|
+
`(payload, input)`. A matcher must return true for each record to retain.
|
|
44
|
+
Non-empty query input without a matcher is rejected rather than ignored.
|
|
45
|
+
Matchers and probe handlers must have no side effects.
|
|
46
|
+
|
|
47
|
+
At a product-owned fake boundary, call
|
|
48
|
+
`Overseer::TestingControl::Rails.capture_effect(capability:, version:,
|
|
49
|
+
effect_kind:, summary:, payload:, outcome:)`. Supply only sanitized fields
|
|
50
|
+
allowed by the registered schema. The helper records the current public
|
|
51
|
+
request's run and correlation; step IDs are optional. Records are partitioned
|
|
52
|
+
by run and queries do not consume them. Storage is bounded and fails on
|
|
53
|
+
exhaustion. It does not silently discard records.
|
|
54
|
+
|
|
55
|
+
Internal spies are product-owned bounded event stores exposed through probes.
|
|
56
|
+
There is no arbitrary method interception, model inspection or `/spies` route.
|
|
57
|
+
Product semantics and safe fields remain in the application.
|
|
58
|
+
|
|
59
|
+
This implementation supports **one process**, with synchronous request effects.
|
|
60
|
+
Its middleware serializes active public and control requests so reset cannot
|
|
61
|
+
race them. Independent test suites need independent runtimes. Worker processes,
|
|
62
|
+
streaming effects and automatic Active Job/Sidekiq correlation propagation are
|
|
63
|
+
not implemented. Do not advertise worker completion using a pre-enqueue record.
|
|
64
|
+
|
|
65
|
+
For an in-process reset, `config.reset(strategy: 'in-process', handler: ...)`
|
|
66
|
+
requires a handler returning exactly `true` after all product state is reset.
|
|
67
|
+
False, nil, a failure object or an exception cannot report success; helper stores
|
|
68
|
+
are retained on failure. A successful reset clears observations and idempotency.
|
|
69
|
+
The product must own file, job, cache, database and external fake cleanup.
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
bundle install
|
|
73
|
+
bundle exec rake test
|
|
74
|
+
gem build overseer-testing-control-rails.gemspec
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run the protocol package's HTTP conformance command against a disposable host
|
|
78
|
+
application to check the complete adapter. The package has no Studio,
|
|
79
|
+
container orchestration or product persistence dependency.
|
|
80
|
+
|
|
81
|
+
Made with ❤️ by [olistik](https://olisti.co)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
class ProtocolFailure < StandardError
|
|
7
|
+
attr_reader :status, :code, :title, :detail, :pointer, :retryable
|
|
8
|
+
|
|
9
|
+
def initialize(status:, code:, title:, detail:, pointer: nil, retryable: false)
|
|
10
|
+
@status = status
|
|
11
|
+
@code = code
|
|
12
|
+
@title = title
|
|
13
|
+
@detail = detail
|
|
14
|
+
@pointer = pointer
|
|
15
|
+
@retryable = retryable
|
|
16
|
+
super(detail)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class ApplicationController < ::ActionController::API
|
|
21
|
+
before_action :require_activation!
|
|
22
|
+
before_action :prepare_identity!
|
|
23
|
+
|
|
24
|
+
rescue_from StandardError, with: :render_internal_error
|
|
25
|
+
rescue_from ConfigurationError, with: :render_configuration_error
|
|
26
|
+
rescue_from StoreLimitExceeded, with: :render_store_limit
|
|
27
|
+
rescue_from IdempotencyConflict, with: :render_idempotency_conflict
|
|
28
|
+
rescue_from JSON::ParserError, with: :render_invalid_json
|
|
29
|
+
rescue_from ProtocolFailure, with: :render_protocol_failure
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def configuration
|
|
34
|
+
Overseer::TestingControl::Rails.configuration.finalize!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def require_activation!
|
|
38
|
+
head :not_found unless Overseer::TestingControl::Rails.active?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def prepare_identity!
|
|
42
|
+
version = request.headers[ProtocolV3::HEADERS.fetch(:version)]
|
|
43
|
+
if version.nil?
|
|
44
|
+
raise ProtocolFailure.new(
|
|
45
|
+
status: :bad_request,
|
|
46
|
+
code: 'invalid-request',
|
|
47
|
+
title: 'Protocol version required',
|
|
48
|
+
detail: 'The testing-control protocol version header is required.'
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
unless version == ProtocolV3::VERSION
|
|
52
|
+
raise ProtocolFailure.new(
|
|
53
|
+
status: :conflict,
|
|
54
|
+
code: 'protocol-version-unsupported',
|
|
55
|
+
title: 'Unsupported protocol version',
|
|
56
|
+
detail: 'The request must use testing-control protocol version 3.'
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
run_id = validate_header!(:run_id, required: true)
|
|
61
|
+
correlation_id = validate_header!(:correlation_id, required: true)
|
|
62
|
+
step_id = validate_header!(:step_id, required: false)
|
|
63
|
+
validate_header!(:idempotency_key, required: false)
|
|
64
|
+
@identity = { run_id:, correlation_id:, step_id: }
|
|
65
|
+
response.set_header(ProtocolV3::HEADERS.fetch(:version), ProtocolV3::VERSION)
|
|
66
|
+
response.set_header(ProtocolV3::HEADERS.fetch(:correlation_id), correlation_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_header!(name, required:)
|
|
70
|
+
value = request.headers[ProtocolV3::HEADERS.fetch(name)]
|
|
71
|
+
return if !required && value.nil?
|
|
72
|
+
|
|
73
|
+
ProtocolV3.identifier!(value, label: ProtocolV3::HEADERS.fetch(name))
|
|
74
|
+
rescue ProtocolV3::ValidationError
|
|
75
|
+
raise ProtocolFailure.new(
|
|
76
|
+
status: :bad_request,
|
|
77
|
+
code: 'invalid-request',
|
|
78
|
+
title: 'Invalid request identity',
|
|
79
|
+
detail: 'A required testing-control identity header is missing or invalid.'
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def request_document(kind)
|
|
84
|
+
unless request.media_type == 'application/json'
|
|
85
|
+
raise ProtocolFailure.new(
|
|
86
|
+
status: :bad_request,
|
|
87
|
+
code: 'invalid-request',
|
|
88
|
+
title: 'JSON request required',
|
|
89
|
+
detail: 'The testing-control request must use application/json.'
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
raw = bounded_body
|
|
93
|
+
if raw.bytesize > configuration.limits.fetch('maxRequestBytes')
|
|
94
|
+
raise ProtocolFailure.new(
|
|
95
|
+
status: :content_too_large,
|
|
96
|
+
code: 'payload-too-large',
|
|
97
|
+
title: 'Payload too large',
|
|
98
|
+
detail: 'The testing-control request exceeds the advertised byte limit.'
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
document = JSON.parse(raw)
|
|
103
|
+
validate_request_envelope!(kind, document)
|
|
104
|
+
validate_request_meta!(document.fetch('meta'))
|
|
105
|
+
document
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def bounded_body
|
|
109
|
+
# Rack may already have buffered the body; otherwise read only limit + 1.
|
|
110
|
+
request.get_header('RAW_POST_DATA') ||
|
|
111
|
+
request.body&.read(configuration.limits.fetch('maxRequestBytes') + 1).to_s
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def require_empty_body!
|
|
115
|
+
return if bounded_body.empty?
|
|
116
|
+
|
|
117
|
+
raise ProtocolFailure.new(
|
|
118
|
+
status: :bad_request,
|
|
119
|
+
code: 'invalid-request',
|
|
120
|
+
title: 'Request body is not allowed',
|
|
121
|
+
detail: 'This testing-control operation does not accept a request body.'
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_request_meta!(meta)
|
|
126
|
+
expected = ProtocolV3.meta(**@identity)
|
|
127
|
+
return if meta == expected
|
|
128
|
+
|
|
129
|
+
raise ProtocolFailure.new(
|
|
130
|
+
status: :bad_request,
|
|
131
|
+
code: 'invalid-request',
|
|
132
|
+
title: 'Request identity mismatch',
|
|
133
|
+
detail: 'The protocol envelope metadata must match the request headers.',
|
|
134
|
+
pointer: '/meta'
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handler_context
|
|
139
|
+
Configuration::HandlerContext.new(
|
|
140
|
+
**@identity,
|
|
141
|
+
idempotency_key: request.headers[ProtocolV3::HEADERS.fetch(:idempotency_key)]
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_capability_input!(schema, value, label:)
|
|
146
|
+
ProtocolV3.validate_value!(schema, value, label:)
|
|
147
|
+
rescue ProtocolV3::ValidationError => e
|
|
148
|
+
raise ProtocolFailure.new(
|
|
149
|
+
status: :unprocessable_entity,
|
|
150
|
+
code: 'schema-validation-failed',
|
|
151
|
+
title: 'Schema validation failed',
|
|
152
|
+
detail: e.message.byteslice(0, 1000)
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_capability_output!(schema, value, label:)
|
|
157
|
+
ProtocolV3.validate_value!(schema, value, label:)
|
|
158
|
+
rescue ProtocolV3::ValidationError
|
|
159
|
+
raise ProtocolFailure.new(
|
|
160
|
+
status: :internal_server_error,
|
|
161
|
+
code: 'internal-error',
|
|
162
|
+
title: 'Testing-control handler failed',
|
|
163
|
+
detail: 'A product handler returned an invalid bounded result.'
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_data(kind, data, status: :ok)
|
|
168
|
+
document = ProtocolV3.envelope(data:, **@identity)
|
|
169
|
+
ProtocolV3.validate_document!(kind, document)
|
|
170
|
+
render_json(document, status:)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def render_json(document, status:)
|
|
174
|
+
encoded = JSON.generate(document)
|
|
175
|
+
if encoded.bytesize > configuration.limits.fetch('maxResponseBytes')
|
|
176
|
+
raise ProtocolFailure.new(
|
|
177
|
+
status: :unprocessable_entity,
|
|
178
|
+
code: 'limit-exceeded',
|
|
179
|
+
title: 'Response limit exceeded',
|
|
180
|
+
detail: 'The bounded testing-control response could not be produced.'
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
render body: encoded, content_type: 'application/json', status:
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def capability_reference(data)
|
|
187
|
+
reference = data.fetch('capability')
|
|
188
|
+
[reference.fetch('id'), reference.fetch('version')]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def render_protocol_failure(error)
|
|
192
|
+
identity = safe_identity
|
|
193
|
+
document = ProtocolV3.error_envelope(
|
|
194
|
+
errors: [
|
|
195
|
+
{
|
|
196
|
+
'code' => error.code,
|
|
197
|
+
'title' => error.title,
|
|
198
|
+
'detail' => error.detail,
|
|
199
|
+
**(error.pointer ? { 'pointer' => error.pointer } : {}),
|
|
200
|
+
'retryable' => error.retryable
|
|
201
|
+
}
|
|
202
|
+
],
|
|
203
|
+
**identity
|
|
204
|
+
)
|
|
205
|
+
response.set_header(ProtocolV3::HEADERS.fetch(:version), ProtocolV3::VERSION)
|
|
206
|
+
response.set_header(ProtocolV3::HEADERS.fetch(:correlation_id), identity.fetch(:correlation_id))
|
|
207
|
+
ProtocolV3.validate_document!(:error, document)
|
|
208
|
+
render body: JSON.generate(document), content_type: 'application/json', status: error.status
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def safe_identity
|
|
212
|
+
return @identity if @identity
|
|
213
|
+
|
|
214
|
+
run_id = request.headers[ProtocolV3::HEADERS.fetch(:run_id)]
|
|
215
|
+
correlation_id = request.headers[ProtocolV3::HEADERS.fetch(:correlation_id)]
|
|
216
|
+
step_id = request.headers[ProtocolV3::HEADERS.fetch(:step_id)]
|
|
217
|
+
{
|
|
218
|
+
run_id: valid_identifier?(run_id) ? run_id : 'invalid-run',
|
|
219
|
+
correlation_id: valid_identifier?(correlation_id) ? correlation_id : 'invalid-correlation',
|
|
220
|
+
step_id: valid_identifier?(step_id) ? step_id : nil
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def valid_identifier?(value)
|
|
225
|
+
value.is_a?(String) && value.match?(ProtocolV3::IDENTIFIER_PATTERN)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def render_invalid_json(_error)
|
|
229
|
+
render_protocol_failure(
|
|
230
|
+
ProtocolFailure.new(
|
|
231
|
+
status: :bad_request,
|
|
232
|
+
code: 'invalid-request',
|
|
233
|
+
title: 'Invalid JSON request',
|
|
234
|
+
detail: 'The testing-control request body is not valid JSON.'
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def render_idempotency_conflict(_error)
|
|
240
|
+
render_protocol_failure(
|
|
241
|
+
ProtocolFailure.new(
|
|
242
|
+
status: :bad_request,
|
|
243
|
+
code: 'invalid-request',
|
|
244
|
+
title: 'Idempotency conflict',
|
|
245
|
+
detail: 'The idempotency key was reused with different input.'
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def render_store_limit(_error)
|
|
251
|
+
render_protocol_failure(
|
|
252
|
+
ProtocolFailure.new(
|
|
253
|
+
status: :unprocessable_entity,
|
|
254
|
+
code: 'limit-exceeded',
|
|
255
|
+
title: 'Bounded storage limit reached',
|
|
256
|
+
detail: 'The adapter storage limit was reached; reset or restart the isolated runtime.',
|
|
257
|
+
retryable: true
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def render_configuration_error(_error)
|
|
263
|
+
render_protocol_failure(
|
|
264
|
+
ProtocolFailure.new(
|
|
265
|
+
status: :service_unavailable,
|
|
266
|
+
code: 'runtime-unavailable',
|
|
267
|
+
title: 'Testing runtime unavailable',
|
|
268
|
+
detail: 'The testing-control adapter configuration is unavailable.'
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def render_internal_error(error)
|
|
274
|
+
::Rails.logger.error("Overseer Rails testing-control adapter request failed (#{error.class.name})")
|
|
275
|
+
render_protocol_failure(
|
|
276
|
+
ProtocolFailure.new(
|
|
277
|
+
status: :internal_server_error,
|
|
278
|
+
code: 'internal-error',
|
|
279
|
+
title: 'Testing-control request failed',
|
|
280
|
+
detail: 'The isolated testing-control request could not be completed.'
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def validate_request_envelope!(kind, document)
|
|
286
|
+
ProtocolV3.validate_document!(kind, document)
|
|
287
|
+
rescue ProtocolV3::ValidationError
|
|
288
|
+
raise ProtocolFailure.new(
|
|
289
|
+
status: :bad_request,
|
|
290
|
+
code: 'invalid-request',
|
|
291
|
+
title: 'Invalid request envelope',
|
|
292
|
+
detail: 'The testing-control request does not match the closed protocol v3 envelope.'
|
|
293
|
+
)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
class CapabilitiesController < ApplicationController
|
|
7
|
+
def show
|
|
8
|
+
require_empty_body!
|
|
9
|
+
render_data(:capabilities, configuration.capabilities_data)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
class ProbesController < ApplicationController
|
|
7
|
+
def create
|
|
8
|
+
document = request_document(:probe_request)
|
|
9
|
+
data = document.fetch('data')
|
|
10
|
+
id, version = capability_reference(data)
|
|
11
|
+
probe = find_probe(id, version)
|
|
12
|
+
input = data.fetch('input')
|
|
13
|
+
validate_capability_input!(probe.input_schema, input, label: "probe #{id}@#{version} input")
|
|
14
|
+
projection = ProtocolV3.stringify(probe.handler.call(input, handler_context))
|
|
15
|
+
validate_capability_output!(probe.output_schema, projection, label: "probe #{id}@#{version} output")
|
|
16
|
+
render_data(
|
|
17
|
+
:probe_response,
|
|
18
|
+
{
|
|
19
|
+
'capability' => { 'id' => id, 'version' => version },
|
|
20
|
+
'observedAt' => ProtocolV3.timestamp,
|
|
21
|
+
'projection' => projection
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def find_probe(id, version)
|
|
29
|
+
configuration.probe!(id, version)
|
|
30
|
+
rescue ProtocolV3::ValidationError
|
|
31
|
+
raise ProtocolFailure.new(
|
|
32
|
+
status: :not_found,
|
|
33
|
+
code: 'capability-not-found',
|
|
34
|
+
title: 'Probe capability not found',
|
|
35
|
+
detail: 'The requested probe capability is not registered.'
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
class ResetsController < ApplicationController
|
|
7
|
+
def update
|
|
8
|
+
require_empty_body!
|
|
9
|
+
if configuration.reset_strategy == 'runtime-restart'
|
|
10
|
+
raise ProtocolFailure.new(
|
|
11
|
+
status: :conflict,
|
|
12
|
+
code: 'reset-requires-restart',
|
|
13
|
+
title: 'Runtime restart required',
|
|
14
|
+
detail: 'The test runner must recreate this isolated runtime to reset it.',
|
|
15
|
+
retryable: true
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
unless configuration.reset_handler.call == true
|
|
20
|
+
raise ConfigurationError, 'The product reset handler did not confirm a successful reset'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
configuration.clear_stores!
|
|
24
|
+
render_data(
|
|
25
|
+
:reset,
|
|
26
|
+
{
|
|
27
|
+
'resetAt' => ProtocolV3.timestamp,
|
|
28
|
+
'strategy' => 'in-process',
|
|
29
|
+
'sinksCleared' => true
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
class SinksController < ApplicationController
|
|
7
|
+
def query
|
|
8
|
+
document = request_document(:sink_query_request)
|
|
9
|
+
data = document.fetch('data')
|
|
10
|
+
id, version = capability_reference(data)
|
|
11
|
+
sink = find_sink(id, version)
|
|
12
|
+
validate_capability_input!(
|
|
13
|
+
sink.query_input_schema,
|
|
14
|
+
data.fetch('input'),
|
|
15
|
+
label: "sink #{id}@#{version} query input"
|
|
16
|
+
)
|
|
17
|
+
limit = data.fetch('limit', configuration.limits.fetch('maxSinkRecordsPerQuery'))
|
|
18
|
+
if limit > configuration.limits.fetch('maxSinkRecordsPerQuery')
|
|
19
|
+
raise ProtocolFailure.new(
|
|
20
|
+
status: :unprocessable_entity,
|
|
21
|
+
code: 'limit-exceeded',
|
|
22
|
+
title: 'Sink query limit exceeded',
|
|
23
|
+
detail: 'The sink query limit exceeds the advertised maximum.'
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
records, cursor = query_store(
|
|
27
|
+
capability: id,
|
|
28
|
+
version:,
|
|
29
|
+
run_id: @identity.fetch(:run_id),
|
|
30
|
+
input: data.fetch('input'),
|
|
31
|
+
filter: data.fetch('filter'),
|
|
32
|
+
cursor: data['cursor'],
|
|
33
|
+
limit:
|
|
34
|
+
)
|
|
35
|
+
render_data(
|
|
36
|
+
:sink_query_response,
|
|
37
|
+
{
|
|
38
|
+
'capability' => { 'id' => id, 'version' => version },
|
|
39
|
+
'records' => records,
|
|
40
|
+
**(cursor ? { 'nextCursor' => cursor } : {})
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def find_sink(id, version)
|
|
48
|
+
configuration.sink!(id, version)
|
|
49
|
+
rescue ProtocolV3::ValidationError
|
|
50
|
+
raise ProtocolFailure.new(
|
|
51
|
+
status: :not_found,
|
|
52
|
+
code: 'capability-not-found',
|
|
53
|
+
title: 'Sink capability not found',
|
|
54
|
+
detail: 'The requested sink capability is not registered.'
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def query_store(**)
|
|
59
|
+
configuration.sink_store.query(**)
|
|
60
|
+
rescue ProtocolV3::ValidationError
|
|
61
|
+
raise ProtocolFailure.new(
|
|
62
|
+
status: :bad_request,
|
|
63
|
+
code: 'invalid-request',
|
|
64
|
+
title: 'Invalid sink query',
|
|
65
|
+
detail: 'The fake-sink cursor or filter is invalid.'
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Overseer
|
|
6
|
+
module TestingControl
|
|
7
|
+
module Rails
|
|
8
|
+
class StatesController < ApplicationController
|
|
9
|
+
def create
|
|
10
|
+
document = request_document(:state_request)
|
|
11
|
+
data = document.fetch('data')
|
|
12
|
+
id, version = capability_reference(data)
|
|
13
|
+
state = find_state(id, version)
|
|
14
|
+
input = data.fetch('input')
|
|
15
|
+
validate_capability_input!(state.input_schema, input, label: "state #{id}@#{version} input")
|
|
16
|
+
idempotency_key = validated_idempotency_key(state)
|
|
17
|
+
result = execute_state(state, input, idempotency_key)
|
|
18
|
+
render_data(
|
|
19
|
+
:state_response,
|
|
20
|
+
{
|
|
21
|
+
'capability' => { 'id' => id, 'version' => version },
|
|
22
|
+
'identifiers' => result.fetch('identifiers'),
|
|
23
|
+
'output' => result.fetch('output')
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def find_state(id, version)
|
|
31
|
+
configuration.state!(id, version)
|
|
32
|
+
rescue ProtocolV3::ValidationError
|
|
33
|
+
raise ProtocolFailure.new(
|
|
34
|
+
status: :not_found,
|
|
35
|
+
code: 'capability-not-found',
|
|
36
|
+
title: 'State capability not found',
|
|
37
|
+
detail: 'The requested state capability is not registered.'
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validated_idempotency_key(state)
|
|
42
|
+
key = request.headers[ProtocolV3::HEADERS.fetch(:idempotency_key)]
|
|
43
|
+
if state.idempotency == 'required' && key.nil?
|
|
44
|
+
raise ProtocolFailure.new(
|
|
45
|
+
status: :bad_request,
|
|
46
|
+
code: 'invalid-request',
|
|
47
|
+
title: 'Idempotency key required',
|
|
48
|
+
detail: 'The requested state requires an Overseer idempotency key.'
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
return if key.nil?
|
|
52
|
+
|
|
53
|
+
ProtocolV3.identifier!(key, label: 'idempotency key')
|
|
54
|
+
rescue ProtocolV3::ValidationError
|
|
55
|
+
raise ProtocolFailure.new(
|
|
56
|
+
status: :bad_request,
|
|
57
|
+
code: 'invalid-request',
|
|
58
|
+
title: 'Invalid idempotency key',
|
|
59
|
+
detail: 'The Overseer idempotency key must be a valid opaque identifier.'
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute_state(state, input, idempotency_key)
|
|
64
|
+
operation = lambda do
|
|
65
|
+
raw = state.handler.call(input, handler_context)
|
|
66
|
+
result = ProtocolV3.stringify(raw)
|
|
67
|
+
validate_state_result!(state, result)
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
return operation.call if state.idempotency == 'none' || idempotency_key.nil?
|
|
71
|
+
|
|
72
|
+
configuration.idempotency_store.fetch_or_store(
|
|
73
|
+
key: [@identity.fetch(:run_id), state.id, state.version, idempotency_key].freeze,
|
|
74
|
+
digest: Digest::SHA256.hexdigest(::Overseer::TestingControl::Discovery.canonical_json(input)),
|
|
75
|
+
&operation
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_state_result!(state, result)
|
|
80
|
+
unless result.is_a?(Hash) && result['identifiers'].is_a?(Hash) && !result['identifiers'].empty? &&
|
|
81
|
+
result['output'].is_a?(Hash)
|
|
82
|
+
raise ProtocolV3::ValidationError, 'state handler must return identifiers and output objects'
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
validate_capability_output!(state.output_schema, result.fetch('output'), label: 'state output')
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Overseer::TestingControl::Rails::Engine.routes.draw do
|
|
4
|
+
get '/capabilities', to: 'capabilities#show'
|
|
5
|
+
put '/reset', to: 'resets#update'
|
|
6
|
+
post '/states', to: 'states#create'
|
|
7
|
+
post '/probes', to: 'probes#create'
|
|
8
|
+
post '/sinks/query', to: 'sinks#query'
|
|
9
|
+
end
|