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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
|
|
5
|
+
module Overseer
|
|
6
|
+
module TestingControl
|
|
7
|
+
module Rails
|
|
8
|
+
class ConfigurationError < StandardError; end
|
|
9
|
+
|
|
10
|
+
class Configuration
|
|
11
|
+
State = Data.define(:id, :version, :title, :description, :idempotency, :input_schema, :output_schema, :handler)
|
|
12
|
+
Probe = Data.define(:id, :version, :title, :description, :input_schema, :output_schema, :handler)
|
|
13
|
+
Sink = Data.define(
|
|
14
|
+
:id, :version, :title, :description, :effect_kinds, :outcomes, :query_input_schema,
|
|
15
|
+
:record_schema, :query_matcher
|
|
16
|
+
)
|
|
17
|
+
HandlerContext = Data.define(:run_id, :correlation_id, :step_id, :idempotency_key)
|
|
18
|
+
FEATURES = %w[
|
|
19
|
+
correlation-propagation
|
|
20
|
+
deterministic-replay
|
|
21
|
+
reset
|
|
22
|
+
schema-validation
|
|
23
|
+
sink-clearing
|
|
24
|
+
].freeze
|
|
25
|
+
RESET_STRATEGIES = %w[in-process runtime-restart].freeze
|
|
26
|
+
IDEMPOTENCY = %w[required supported none].freeze
|
|
27
|
+
EFFECT_KINDS = %w[email sms webhook document-delivery integration-command custom].freeze
|
|
28
|
+
DEFAULT_SOURCE_PROVIDER = -> { ENV.fetch('OVERSEER_TESTING_CONTROL_APPLICATION_VERSION', '') }
|
|
29
|
+
DEFAULT_ENVIRONMENT_PROVIDER = -> { ENV.fetch('OVERSEER_TESTING_CONTROL_ENVIRONMENT_ID', '') }
|
|
30
|
+
DEFAULT_LIMITS = {
|
|
31
|
+
'maxRequestBytes' => 65_536,
|
|
32
|
+
'maxResponseBytes' => 262_144,
|
|
33
|
+
'maxSchemaBytes' => 65_536,
|
|
34
|
+
'maxSinkRecordsPerQuery' => 25,
|
|
35
|
+
'maxSinkRecordBytes' => 16_384
|
|
36
|
+
}.freeze
|
|
37
|
+
LIMIT_RANGES = {
|
|
38
|
+
'maxRequestBytes' => (1_024..16_777_216),
|
|
39
|
+
'maxResponseBytes' => (1_024..16_777_216),
|
|
40
|
+
'maxSchemaBytes' => (1_024..1_048_576),
|
|
41
|
+
'maxSinkRecordsPerQuery' => (1..1_000),
|
|
42
|
+
'maxSinkRecordBytes' => (256..1_048_576)
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
attr_reader :states, :probes, :sinks, :idempotency_store, :sink_store, :reset_strategy,
|
|
46
|
+
:reset_handler, :max_sink_records, :limits, :request_lock
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@request_lock = Monitor.new
|
|
50
|
+
@identity = {
|
|
51
|
+
application: nil,
|
|
52
|
+
source: DEFAULT_SOURCE_PROVIDER,
|
|
53
|
+
environment: DEFAULT_ENVIRONMENT_PROVIDER,
|
|
54
|
+
kind: 'test',
|
|
55
|
+
isolated: true
|
|
56
|
+
}
|
|
57
|
+
@reset_strategy = 'runtime-restart'
|
|
58
|
+
@reset_handler = nil
|
|
59
|
+
@states = []
|
|
60
|
+
@probes = []
|
|
61
|
+
@sinks = []
|
|
62
|
+
@limits = DEFAULT_LIMITS
|
|
63
|
+
@idempotency_limit = 128
|
|
64
|
+
@max_sink_records = 250
|
|
65
|
+
rebuild_stores!
|
|
66
|
+
@finalized = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def identity(
|
|
70
|
+
application:,
|
|
71
|
+
source: DEFAULT_SOURCE_PROVIDER,
|
|
72
|
+
environment: DEFAULT_ENVIRONMENT_PROVIDER,
|
|
73
|
+
kind: 'test',
|
|
74
|
+
isolated: true
|
|
75
|
+
)
|
|
76
|
+
mutable!
|
|
77
|
+
@identity = { application:, source:, environment:, kind:, isolated: }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reset(strategy:, handler: nil)
|
|
81
|
+
mutable!
|
|
82
|
+
@reset_strategy = strategy
|
|
83
|
+
@reset_handler = handler
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def bounds(
|
|
87
|
+
max_request_bytes: limits.fetch('maxRequestBytes'),
|
|
88
|
+
max_response_bytes: limits.fetch('maxResponseBytes'),
|
|
89
|
+
max_schema_bytes: limits.fetch('maxSchemaBytes'),
|
|
90
|
+
max_sink_records_per_query: limits.fetch('maxSinkRecordsPerQuery'),
|
|
91
|
+
max_sink_record_bytes: limits.fetch('maxSinkRecordBytes'),
|
|
92
|
+
idempotency_entries: @idempotency_limit,
|
|
93
|
+
sink_records: max_sink_records
|
|
94
|
+
)
|
|
95
|
+
mutable!
|
|
96
|
+
candidate = {
|
|
97
|
+
'maxRequestBytes' => max_request_bytes,
|
|
98
|
+
'maxResponseBytes' => max_response_bytes,
|
|
99
|
+
'maxSchemaBytes' => max_schema_bytes,
|
|
100
|
+
'maxSinkRecordsPerQuery' => max_sink_records_per_query,
|
|
101
|
+
'maxSinkRecordBytes' => max_sink_record_bytes
|
|
102
|
+
}
|
|
103
|
+
validate_limits!(candidate, idempotency_entries:, sink_records:)
|
|
104
|
+
@limits = candidate.freeze
|
|
105
|
+
@idempotency_limit = idempotency_entries
|
|
106
|
+
@max_sink_records = sink_records
|
|
107
|
+
rebuild_stores!
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def register_state(id:, version:, title:, idempotency:, input_schema:, output_schema:, handler:,
|
|
111
|
+
description: nil)
|
|
112
|
+
mutable!
|
|
113
|
+
@states << State.new(
|
|
114
|
+
id:, version:, title:, description:, idempotency:, input_schema:, output_schema:, handler:
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def register_probe(id:, version:, title:, input_schema:, output_schema:, handler:, description: nil)
|
|
119
|
+
mutable!
|
|
120
|
+
@probes << Probe.new(id:, version:, title:, description:, input_schema:, output_schema:, handler:)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def register_sink(
|
|
124
|
+
id:, version:, title:, effect_kinds:, outcomes:, query_input_schema:, record_schema:,
|
|
125
|
+
description: nil, query_matcher: nil
|
|
126
|
+
)
|
|
127
|
+
mutable!
|
|
128
|
+
@sinks << Sink.new(
|
|
129
|
+
id:, version:, title:, description:, effect_kinds:, outcomes:, query_input_schema:,
|
|
130
|
+
record_schema:, query_matcher:
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def finalize!
|
|
135
|
+
return self if @finalized
|
|
136
|
+
|
|
137
|
+
validate!
|
|
138
|
+
@states.freeze
|
|
139
|
+
@probes.freeze
|
|
140
|
+
@sinks.freeze
|
|
141
|
+
@finalized = true
|
|
142
|
+
self
|
|
143
|
+
rescue ProtocolV3::ValidationError => e
|
|
144
|
+
raise ConfigurationError, e.message
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def runtime_identity_valid?
|
|
148
|
+
validate_identity!
|
|
149
|
+
true
|
|
150
|
+
rescue ConfigurationError, ProtocolV3::ValidationError, KeyError, TypeError
|
|
151
|
+
false
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def application_id
|
|
155
|
+
resolve_identity(:application)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def environment_id
|
|
159
|
+
resolve_identity(:environment)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def application_version
|
|
163
|
+
resolve_identity(:source)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def environment_kind
|
|
167
|
+
resolve_identity(:kind)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def environment_isolated
|
|
171
|
+
resolve_identity(:isolated)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def capabilities_data
|
|
175
|
+
finalize!
|
|
176
|
+
{
|
|
177
|
+
'application' => { 'id' => application_id, 'version' => application_version },
|
|
178
|
+
'environment' => {
|
|
179
|
+
'id' => environment_id,
|
|
180
|
+
'kind' => environment_kind,
|
|
181
|
+
'isolated' => environment_isolated
|
|
182
|
+
},
|
|
183
|
+
'reset' => { 'strategy' => reset_strategy, 'clearsSinks' => true },
|
|
184
|
+
'features' => FEATURES,
|
|
185
|
+
'limits' => limits,
|
|
186
|
+
'safety' => {
|
|
187
|
+
'syntheticDataRequired' => true,
|
|
188
|
+
'realCredentialsAllowed' => false,
|
|
189
|
+
'secretPersistenceAllowed' => false,
|
|
190
|
+
'externalNetwork' => 'blocked'
|
|
191
|
+
},
|
|
192
|
+
'states' => states.map { |state| state_document(state) },
|
|
193
|
+
'probes' => probes.map { |probe| probe_document(probe) },
|
|
194
|
+
'sinks' => sinks.map { |sink| sink_document(sink) }
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def state!(id, version)
|
|
199
|
+
states.find { |item| item.id == id && item.version == version } ||
|
|
200
|
+
raise(ProtocolV3::ValidationError, "state capability #{id}@#{version} is unavailable")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def probe!(id, version)
|
|
204
|
+
probes.find { |item| item.id == id && item.version == version } ||
|
|
205
|
+
raise(ProtocolV3::ValidationError, "probe capability #{id}@#{version} is unavailable")
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def sink!(id, version)
|
|
209
|
+
sinks.find { |item| item.id == id && item.version == version } ||
|
|
210
|
+
raise(ProtocolV3::ValidationError, "sink capability #{id}@#{version} is unavailable")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def clear_stores!
|
|
214
|
+
idempotency_store.clear
|
|
215
|
+
sink_store.clear
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def mutable!
|
|
221
|
+
return unless @finalized
|
|
222
|
+
|
|
223
|
+
raise ConfigurationError,
|
|
224
|
+
'Overseer Rails testing-control adapter configuration is already active'
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def validate!
|
|
228
|
+
validate_identity!
|
|
229
|
+
raise ConfigurationError, 'reset strategy is invalid' unless RESET_STRATEGIES.include?(reset_strategy)
|
|
230
|
+
raise ConfigurationError, 'in-process reset requires a reset handler' if
|
|
231
|
+
reset_strategy == 'in-process' && !reset_handler.respond_to?(:call)
|
|
232
|
+
|
|
233
|
+
validate_limits!(limits, idempotency_entries: @idempotency_limit, sink_records: max_sink_records)
|
|
234
|
+
|
|
235
|
+
validate_family!(states, 'state') { |item| validate_state!(item) }
|
|
236
|
+
validate_family!(probes, 'probe') { |item| validate_probe!(item) }
|
|
237
|
+
validate_family!(sinks, 'sink') { |item| validate_sink!(item) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_family!(items, kind)
|
|
241
|
+
seen = {}
|
|
242
|
+
items.each do |item|
|
|
243
|
+
key = "#{item.id}@#{item.version}"
|
|
244
|
+
raise ConfigurationError, "duplicate #{kind} capability #{key}" if seen[key]
|
|
245
|
+
|
|
246
|
+
seen[key] = true
|
|
247
|
+
validate_common!(item, kind)
|
|
248
|
+
yield(item)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def validate_common!(item, kind)
|
|
253
|
+
ProtocolV3.capability_id!(item.id, label: "#{kind} ID")
|
|
254
|
+
ProtocolV3.capability_version!(item.version, label: "#{kind} version")
|
|
255
|
+
raise ConfigurationError, "#{kind} title is invalid" unless
|
|
256
|
+
item.title.is_a?(String) && item.title.bytesize.between?(1, 160)
|
|
257
|
+
raise ConfigurationError, "#{kind} description is invalid" if
|
|
258
|
+
item.description && (!item.description.is_a?(String) || item.description.bytesize > 1000)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def validate_state!(state)
|
|
262
|
+
unless IDEMPOTENCY.include?(state.idempotency)
|
|
263
|
+
raise ConfigurationError,
|
|
264
|
+
'state idempotency declaration is invalid'
|
|
265
|
+
end
|
|
266
|
+
raise ConfigurationError, 'state handler must be callable' unless state.handler.respond_to?(:call)
|
|
267
|
+
|
|
268
|
+
validate_schema_pair!(state, :input_schema, :output_schema, label: "state #{state.id}@#{state.version}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def validate_probe!(probe)
|
|
272
|
+
raise ConfigurationError, 'probe handler must be callable' unless probe.handler.respond_to?(:call)
|
|
273
|
+
|
|
274
|
+
validate_schema_pair!(probe, :input_schema, :output_schema, label: "probe #{probe.id}@#{probe.version}")
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def validate_sink!(sink)
|
|
278
|
+
valid_kinds = sink.effect_kinds.is_a?(Array) && !sink.effect_kinds.empty? &&
|
|
279
|
+
sink.effect_kinds.uniq == sink.effect_kinds &&
|
|
280
|
+
sink.effect_kinds.all? { |kind| EFFECT_KINDS.include?(kind) }
|
|
281
|
+
raise ConfigurationError, 'sink effect kinds are invalid' unless valid_kinds
|
|
282
|
+
|
|
283
|
+
valid_outcomes = sink.outcomes.is_a?(Array) && !sink.outcomes.empty? &&
|
|
284
|
+
sink.outcomes.uniq == sink.outcomes
|
|
285
|
+
raise ConfigurationError, 'sink outcomes are invalid' unless valid_outcomes
|
|
286
|
+
|
|
287
|
+
sink.outcomes.each { |outcome| ProtocolV3.capability_id!(outcome, label: 'sink outcome') }
|
|
288
|
+
validate_schema_pair!(sink, :query_input_schema, :record_schema, label: "sink #{sink.id}@#{sink.version}")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def validate_schema_pair!(item, *names, label:)
|
|
292
|
+
names.each do |name|
|
|
293
|
+
ProtocolV3.validate_schema!(
|
|
294
|
+
item.public_send(name),
|
|
295
|
+
label: "#{label} #{name}",
|
|
296
|
+
max_bytes: limits.fetch('maxSchemaBytes')
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def validate_identity!
|
|
302
|
+
ProtocolV3.capability_id!(application_id, label: 'application ID')
|
|
303
|
+
ProtocolV3.application_version!(application_version)
|
|
304
|
+
ProtocolV3.identifier!(environment_id, label: 'environment ID')
|
|
305
|
+
raise ConfigurationError, 'environment kind must be test' unless environment_kind == 'test'
|
|
306
|
+
raise ConfigurationError, 'environment isolation witness must be true' unless environment_isolated == true
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_limits!(candidate, idempotency_entries:, sink_records:)
|
|
310
|
+
LIMIT_RANGES.each do |name, range|
|
|
311
|
+
value = candidate.fetch(name)
|
|
312
|
+
raise ConfigurationError, "#{name} is outside the protocol v3 range" unless
|
|
313
|
+
value.is_a?(Integer) && range.cover?(value)
|
|
314
|
+
end
|
|
315
|
+
raise ConfigurationError, 'idempotency entry limit must be between 1 and 1000' unless
|
|
316
|
+
idempotency_entries.is_a?(Integer) && idempotency_entries.between?(1, 1_000)
|
|
317
|
+
raise ConfigurationError, 'fake-sink record limit must be between 1 and 1000' unless
|
|
318
|
+
sink_records.is_a?(Integer) && sink_records.between?(1, 1_000)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def resolve_identity(name)
|
|
322
|
+
value = @identity.fetch(name)
|
|
323
|
+
value.respond_to?(:call) ? value.call : value
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def rebuild_stores!
|
|
327
|
+
@idempotency_store = IdempotencyStore.new(limit: @idempotency_limit)
|
|
328
|
+
@sink_store = SinkStore.new(self)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def state_document(state)
|
|
332
|
+
{
|
|
333
|
+
'id' => state.id,
|
|
334
|
+
'version' => state.version,
|
|
335
|
+
'title' => state.title,
|
|
336
|
+
**(state.description ? { 'description' => state.description } : {}),
|
|
337
|
+
'idempotency' => state.idempotency,
|
|
338
|
+
'inputSchema' => state.input_schema,
|
|
339
|
+
'outputSchema' => state.output_schema
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def probe_document(probe)
|
|
344
|
+
{
|
|
345
|
+
'id' => probe.id,
|
|
346
|
+
'version' => probe.version,
|
|
347
|
+
'title' => probe.title,
|
|
348
|
+
**(probe.description ? { 'description' => probe.description } : {}),
|
|
349
|
+
'readOnly' => true,
|
|
350
|
+
'inputSchema' => probe.input_schema,
|
|
351
|
+
'outputSchema' => probe.output_schema
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def sink_document(sink)
|
|
356
|
+
{
|
|
357
|
+
'id' => sink.id,
|
|
358
|
+
'version' => sink.version,
|
|
359
|
+
'title' => sink.title,
|
|
360
|
+
**(sink.description ? { 'description' => sink.description } : {}),
|
|
361
|
+
'effectKinds' => sink.effect_kinds,
|
|
362
|
+
'outcomes' => sink.outcomes,
|
|
363
|
+
'queryInputSchema' => sink.query_input_schema,
|
|
364
|
+
'recordSchema' => sink.record_schema
|
|
365
|
+
}
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
class CorrelationMiddleware
|
|
7
|
+
def initialize(app)
|
|
8
|
+
@app = app
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(environment)
|
|
12
|
+
return @app.call(environment) unless Overseer::TestingControl::Rails.active?
|
|
13
|
+
|
|
14
|
+
# Memory stores belong to one process. Serialize its public and control
|
|
15
|
+
# requests so reset cannot race a mutation or capture in that process.
|
|
16
|
+
Overseer::TestingControl::Rails.configuration.request_lock.synchronize do
|
|
17
|
+
correlated_call(environment)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def correlated_call(environment)
|
|
24
|
+
context = context_from(environment)
|
|
25
|
+
RequestContext.with(context) do
|
|
26
|
+
status, headers, body = @app.call(environment)
|
|
27
|
+
headers[ProtocolV3::HEADERS.fetch(:correlation_id)] = context.correlation_id if context&.correlation_id
|
|
28
|
+
[status, headers, body]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def context_from(environment)
|
|
33
|
+
correlation_id = header(environment, :correlation_id)
|
|
34
|
+
return unless valid_identifier?(correlation_id)
|
|
35
|
+
|
|
36
|
+
run_id = header(environment, :run_id)
|
|
37
|
+
step_id = header(environment, :step_id)
|
|
38
|
+
RequestContext::Context.new(
|
|
39
|
+
run_id: valid_identifier?(run_id) ? run_id : nil,
|
|
40
|
+
correlation_id:,
|
|
41
|
+
step_id: valid_identifier?(step_id) ? step_id : nil
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def header(environment, name)
|
|
46
|
+
rack_name = "HTTP_#{ProtocolV3::HEADERS.fetch(name).upcase.tr('-', '_')}"
|
|
47
|
+
environment[rack_name]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def valid_identifier?(value)
|
|
51
|
+
value.is_a?(String) && value.match?(ProtocolV3::IDENTIFIER_PATTERN)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller/railtie'
|
|
4
|
+
|
|
5
|
+
module Overseer
|
|
6
|
+
module TestingControl
|
|
7
|
+
module Rails
|
|
8
|
+
class Engine < ::Rails::Engine
|
|
9
|
+
isolate_namespace Overseer::TestingControl::Rails
|
|
10
|
+
|
|
11
|
+
initializer 'overseer-testing-control-rails.correlation-middleware' do |application|
|
|
12
|
+
application.middleware.insert_before(0, CorrelationMiddleware)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Overseer
|
|
4
|
+
module TestingControl
|
|
5
|
+
module Rails
|
|
6
|
+
module RequestContext
|
|
7
|
+
Context = Data.define(:run_id, :correlation_id, :step_id)
|
|
8
|
+
THREAD_KEY = :overseer_testing_control_rails_request_context
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def current
|
|
13
|
+
Thread.current[THREAD_KEY]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def with(context)
|
|
17
|
+
previous = current
|
|
18
|
+
Thread.current[THREAD_KEY] = context
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
Thread.current[THREAD_KEY] = previous
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Overseer
|
|
7
|
+
module TestingControl
|
|
8
|
+
module Rails
|
|
9
|
+
class CaptureError < StandardError; end
|
|
10
|
+
class IdempotencyConflict < StandardError; end
|
|
11
|
+
class StoreLimitExceeded < StandardError; end
|
|
12
|
+
|
|
13
|
+
class IdempotencyStore
|
|
14
|
+
Entry = Data.define(:digest, :value)
|
|
15
|
+
|
|
16
|
+
def initialize(limit:)
|
|
17
|
+
@limit = limit
|
|
18
|
+
@entries = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_or_store(key:, digest:)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
existing = @entries[key]
|
|
25
|
+
if existing
|
|
26
|
+
raise IdempotencyConflict, 'Idempotency key was reused with different input' unless
|
|
27
|
+
existing.digest == digest
|
|
28
|
+
|
|
29
|
+
return deep_copy(existing.value)
|
|
30
|
+
end
|
|
31
|
+
raise StoreLimitExceeded, 'Idempotency storage reached its bounded limit' if @entries.length >= @limit
|
|
32
|
+
|
|
33
|
+
value = yield
|
|
34
|
+
@entries[key] = Entry.new(digest:, value: deep_copy(value))
|
|
35
|
+
deep_copy(value)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear
|
|
40
|
+
@mutex.synchronize { @entries.clear }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def deep_copy(value)
|
|
46
|
+
JSON.parse(JSON.generate(value))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class SinkStore
|
|
51
|
+
def initialize(configuration)
|
|
52
|
+
@configuration = configuration
|
|
53
|
+
@records = []
|
|
54
|
+
@mutex = Mutex.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def capture(capability:, version:, effect_kind:, summary:, payload:, context:, outcome: nil, metadata: nil)
|
|
58
|
+
definition = @configuration.sink!(capability, version)
|
|
59
|
+
ProtocolV3.identifier!(context.run_id, label: 'capture run ID')
|
|
60
|
+
ProtocolV3.identifier!(context.correlation_id, label: 'capture correlation ID')
|
|
61
|
+
ProtocolV3.identifier!(context.step_id, label: 'capture step ID') if context.step_id
|
|
62
|
+
validate_capture!(definition, effect_kind:, outcome:, summary:, payload:, metadata:)
|
|
63
|
+
record = {
|
|
64
|
+
'id' => "effect-#{SecureRandom.hex(12)}",
|
|
65
|
+
'capability' => { 'id' => capability, 'version' => version },
|
|
66
|
+
'effectKind' => effect_kind,
|
|
67
|
+
**(outcome ? { 'outcome' => outcome } : {}),
|
|
68
|
+
'correlationId' => context.correlation_id,
|
|
69
|
+
**(context.step_id ? { 'stepId' => context.step_id } : {}),
|
|
70
|
+
'capturedAt' => ProtocolV3.timestamp,
|
|
71
|
+
'summary' => summary,
|
|
72
|
+
**(metadata ? { 'metadata' => ProtocolV3.stringify(metadata) } : {}),
|
|
73
|
+
'payload' => ProtocolV3.stringify(payload)
|
|
74
|
+
}
|
|
75
|
+
validate_record_size!(record)
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
raise StoreLimitExceeded, 'Fake-sink storage reached its bounded limit' if
|
|
78
|
+
@records.length >= @configuration.max_sink_records
|
|
79
|
+
|
|
80
|
+
@records << { run_id: context.run_id, record: deep_copy(record) }
|
|
81
|
+
end
|
|
82
|
+
deep_copy(record)
|
|
83
|
+
rescue ProtocolV3::ValidationError => e
|
|
84
|
+
raise CaptureError, e.message
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def query(capability:, version:, run_id:, input:, filter:, cursor:, limit:)
|
|
88
|
+
definition = @configuration.sink!(capability, version)
|
|
89
|
+
ProtocolV3.identifier!(run_id, label: 'sink query run ID')
|
|
90
|
+
ProtocolV3.validate_value!(definition.query_input_schema, input, label: 'sink query input')
|
|
91
|
+
if !input.empty? && !definition.query_matcher
|
|
92
|
+
raise ProtocolV3::ValidationError, 'non-empty sink input requires a product query matcher'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
offset = cursor ? Integer(cursor, 10) : 0
|
|
96
|
+
raise ProtocolV3::ValidationError, 'sink cursor is outside the bounded result' if offset.negative?
|
|
97
|
+
|
|
98
|
+
matched = @mutex.synchronize do
|
|
99
|
+
selected = @records.filter_map do |entry|
|
|
100
|
+
next unless entry.fetch(:run_id) == run_id
|
|
101
|
+
|
|
102
|
+
record = entry.fetch(:record)
|
|
103
|
+
next unless matches_filter?(record, capability, version, filter)
|
|
104
|
+
next unless matches_input?(definition, record, input)
|
|
105
|
+
|
|
106
|
+
record
|
|
107
|
+
end
|
|
108
|
+
selected.map { |record| deep_copy(record) }
|
|
109
|
+
end
|
|
110
|
+
records = matched.slice(offset, limit) || []
|
|
111
|
+
next_offset = offset + records.length
|
|
112
|
+
[records, next_offset < matched.length ? next_offset.to_s : nil]
|
|
113
|
+
rescue ArgumentError
|
|
114
|
+
raise ProtocolV3::ValidationError, 'sink cursor is invalid'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def clear
|
|
118
|
+
@mutex.synchronize { @records.clear }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def matches_filter?(record, capability, version, filter)
|
|
124
|
+
record.dig('capability', 'id') == capability &&
|
|
125
|
+
record.dig('capability', 'version') == version &&
|
|
126
|
+
record['correlationId'] == filter.fetch('correlationId') &&
|
|
127
|
+
(!filter['stepId'] || record['stepId'] == filter['stepId']) &&
|
|
128
|
+
(!filter['effectKinds'] || filter['effectKinds'].include?(record['effectKind'])) &&
|
|
129
|
+
(!filter['capturedAfter'] || Time.iso8601(record['capturedAt']) > Time.iso8601(filter['capturedAfter']))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def matches_input?(definition, record, input)
|
|
133
|
+
!definition.query_matcher || definition.query_matcher.call(deep_copy(record.fetch('payload')),
|
|
134
|
+
deep_copy(input))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_capture!(definition, effect_kind:, outcome:, summary:, payload:, metadata:)
|
|
138
|
+
raise ProtocolV3::ValidationError, 'fake effect kind is not registered for the sink' unless
|
|
139
|
+
definition.effect_kinds.include?(effect_kind)
|
|
140
|
+
raise ProtocolV3::ValidationError, 'fake effect outcome is not registered for the sink' if
|
|
141
|
+
outcome && !definition.outcomes.include?(outcome)
|
|
142
|
+
raise ProtocolV3::ValidationError, 'fake effect summary is invalid' unless
|
|
143
|
+
summary.is_a?(String) && summary.bytesize.between?(1, 500)
|
|
144
|
+
if metadata && !metadata.is_a?(Hash)
|
|
145
|
+
raise ProtocolV3::ValidationError,
|
|
146
|
+
'fake effect metadata must be an object'
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
ProtocolV3.validate_value!(definition.record_schema, ProtocolV3.stringify(payload),
|
|
150
|
+
label: 'fake effect payload')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_record_size!(record)
|
|
154
|
+
return if JSON.generate(record).bytesize <= @configuration.limits.fetch('maxSinkRecordBytes')
|
|
155
|
+
|
|
156
|
+
raise CaptureError, 'Fake effect record exceeds the bounded record size'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def deep_copy(value)
|
|
160
|
+
JSON.parse(JSON.generate(value))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|