specguard-ruby 0.3.1

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.
@@ -0,0 +1,449 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "zlib"
7
+
8
+ require_relative "configuration"
9
+ require_relative "../version"
10
+
11
+ module SpecGuard
12
+ module RSpec
13
+ # The one HTTP call this gem makes: `POST <endpoint>/api/v1/ingest`,
14
+ # carrying a whole run.
15
+ #
16
+ # == Why this returns a result instead of raising
17
+ #
18
+ # {SpecGuard::RSpecFormatter}'s never-block-CI guard is a `rescue` around
19
+ # each hook, and a `rescue` is structurally blind to the failure that
20
+ # matters most here: `Net::HTTP` hands back `Net::HTTPUnauthorized` as an
21
+ # ordinary return value. A wrong API key therefore raises nothing, warns
22
+ # nothing and logs nothing — the run's telemetry disappears in complete
23
+ # silence, which is precisely the outcome the client-gem spec's "if the API
24
+ # key is wrong … it logs a warning to stderr" forbids.
25
+ #
26
+ # So the two failure families are made the same *shape* rather than left to
27
+ # two different mechanisms: {#deliver} answers a {Result} for a non-2xx
28
+ # response and a {Result} for a raised exception, and the caller has one
29
+ # thing to check. Nothing escapes this class except `Interrupt`,
30
+ # `SignalException` and `SystemExit` — Ctrl-C must stay Ctrl-C.
31
+ #
32
+ # == One request per process, gzipped once it is big enough
33
+ #
34
+ # A run goes in a single POST, and the body is compressed above a size
35
+ # threshold. Both halves are decisions rather than defaults, and the
36
+ # roadmap asked for them to be made deliberately rather than discovered in
37
+ # production, so they are written down here.
38
+ #
39
+ # === Batching: no, and this is settled
40
+ #
41
+ # * `Ingest::Payload` derives `total_specs_count` from the specs of *that*
42
+ # request. Splitting a run across N POSTs with no way to say they are
43
+ # one run would produce N `TestRun` rows with a split denominator,
44
+ # corrupting the headline annotated-ratio metric.
45
+ # * That is qualified rather than absolute, and the qualification is
46
+ # `ci_run_id` + `shard_id`. The platform folds every POST carrying the
47
+ # same run id onto one `TestRun`, keyed by shard so a slice that arrives
48
+ # twice replaces itself, which is what makes a *sharded* run — N
49
+ # processes, N POSTs, one run — land as one row. So the rule this class
50
+ # keeps is narrower than it was: **one process sends one request.**
51
+ # * Batching a single process's own run into several POSTs stays wrong,
52
+ # and not only for the denominator: every part would carry the same
53
+ # `shard_id`, so the parts would overwrite one another and the row would
54
+ # keep only the last. Fixing that means a new part-of-a-shard concept on
55
+ # the platform, which is a schema change bought to solve a problem that
56
+ # compression already solved.
57
+ #
58
+ # === Streaming: no, for the same reason, and the reason is measured
59
+ #
60
+ # The pressure that made batching and streaming look necessary was size,
61
+ # and size is now a number rather than a worry. Measured by running a real
62
+ # 200-file / 20,000-example suite through this gem's own formatter, half of
63
+ # the examples annotated:
64
+ #
65
+ # identity 7,354,782 bytes 7.01 MiB needs 5.9 Mbit/s to write in 10s
66
+ # gzip 346,206 bytes 0.33 MiB needs 0.3 Mbit/s
67
+ # ratio 21.2x 95.3% saved 60 ms to compress
68
+ #
69
+ # Uncompressed, that body has to be *written* inside
70
+ # `Configuration::DEFAULT_TIMEOUT_SECONDS` (10). At 5 Mbit/s of uplink it
71
+ # takes 11.8s and fails as a `write_timeout`; the run then lands in
72
+ # `log/test_results.jsonl` and the platform never sees it. Compressed, the
73
+ # same run takes 0.55s on that link and 2.8s on a 1 Mbit/s one. The 60 ms
74
+ # spent compressing is noise against a suite that took minutes.
75
+ #
76
+ # Two honesties about that ratio. It is *below* the 35x this change was
77
+ # proposed on, because SPGD-159 has since added `id` and `spec_file_path`
78
+ # to every row — re-measure rather than quote, is the lesson. And it is
79
+ # probably *above* what a real suite gets: synthetic example names repeat
80
+ # more than human ones do, so treat 21x as the optimistic end. Nothing here
81
+ # depends on the exact figure. Even a pessimistic 5x moves the 20k case
82
+ # from "cannot ship on a slow link" to "ships with room to spare", and
83
+ # 0.33 MiB is not a payload anyone needs to chunk.
84
+ #
85
+ # So: compression yes, batching and streaming no. Recorded rather than left
86
+ # implicit — an undocumented decision is one that gets re-opened in six
87
+ # months by someone who cannot tell it was ever made.
88
+ #
89
+ # === Why a threshold rather than always
90
+ #
91
+ # Below {GZIP_THRESHOLD_BYTES} the round trip buys nothing worth paying for
92
+ # in opacity: a small run stays identity-encoded, so it is still readable
93
+ # with `curl` and `tcpdump`, and the local-file and stub-server paths still
94
+ # show a human a JSON body. Compression is for the case that could not ship
95
+ # at all, not a uniform policy.
96
+ #
97
+ # Compression also sits *inside* the never-block-CI contract. A `Zlib`
98
+ # failure falls back to the identity body — which the platform still
99
+ # accepts — rather than raising: a run must not be lost to an optimisation.
100
+ #
101
+ # === The version floor this creates, which is a deploy order and not a merge order
102
+ #
103
+ # Sending `Content-Encoding: gzip` requires a platform that can inflate one,
104
+ # and that arrived with `GzipRequestBody` (SPGD-175). "Merge the platform PR
105
+ # first" is the half of this that is easy to say and is *not* sufficient:
106
+ # merge order only settles the source trees. What this gem actually talks to
107
+ # is a **deployment**.
108
+ #
109
+ # So a gem at this version pointed at an installation deployed before
110
+ # `GzipRequestBody` will 400 every run over {GZIP_THRESHOLD_BYTES} — the
111
+ # inflater is not there, the body reaches the JSON parser still gzipped, and
112
+ # `Api::V1::IngestsController` refuses it. {#deliver} answers
113
+ # `Result(outcome: :rejected, code: 400)`; there is no retry-as-identity, so
114
+ # the formatter falls back to `log/test_results.jsonl` and the run is lost
115
+ # to the platform. That is precisely the failure this change exists to
116
+ # close, reintroduced for precisely the large suites it targets — and it is
117
+ # silent apart from one stderr line, because CI still goes green.
118
+ #
119
+ # Written down rather than fixed, deliberately. A 400-triggered retry with
120
+ # the identity body would paper over it, but it doubles the request count on
121
+ # every genuinely-malformed payload and makes a client-side bug look like a
122
+ # flake; that is a contract decision, not a detail to slip in here. The
123
+ # honest statement of the constraint is a version floor: **this gem requires
124
+ # a platform deployment that includes `GzipRequestBody`.**
125
+ class Transport
126
+ # `config/routes.rb` mounts `post "ingest"` under the `/api/v1` scope.
127
+ # Part of the platform's contract, so it is not configurable — the
128
+ # `endpoint` setting is the installation's address and nothing more.
129
+ PATH = "/api/v1/ingest"
130
+ CONTENT_TYPE = "application/json"
131
+ USER_AGENT = "specguard-ruby/#{SpecGuard::VERSION}"
132
+ CONTENT_ENCODING = "gzip"
133
+
134
+ # Bodies at least this large are gzipped; smaller ones go identity. See
135
+ # the class comment for why this is a threshold and not a switch.
136
+ #
137
+ # 256 KiB is roughly where the round trip starts paying — a few thousand
138
+ # examples. It is a judgement call, not a measured optimum, and the only
139
+ # thing that depends on the exact number is how large a run has to be
140
+ # before `curl` stops showing you a readable body.
141
+ GZIP_THRESHOLD_BYTES = 256 * 1024
142
+
143
+ # What the ingest endpoint said, in the one form the caller has to handle.
144
+ #
145
+ # `outcome` is one of:
146
+ #
147
+ # :success a 2xx. `code` carries it (202 in the happy path).
148
+ # :rejected a non-2xx. `code` carries it; nothing was raised.
149
+ # :failed an exception was raised. `error` carries it.
150
+ #
151
+ # `reasons` is the refusal in the platform's own words — the strings
152
+ # `Api::BaseController#render_bad_request` puts on the wire, each naming
153
+ # one offending spec by index, file and line. It is populated only on
154
+ # `:rejected`, and only when the body actually said something this class
155
+ # could read; a proxy's HTML 502 leaves it nil and the reader gets
156
+ # exactly what they got before.
157
+ #
158
+ # `body` is the mirror of that on the accepting side: the 202 document
159
+ # `Api::V1::IngestsController` renders — `test_run_id`, `total_specs`,
160
+ # `annotated_specs`, `annotated_ratio`, `embedding_status` — parsed, and
161
+ # populated only on `:success`. It was discarded until {IngestCLI}
162
+ # needed to say *which run* a re-delivered line landed on, and it is
163
+ # added rather than substituted: nothing that read a {Result} before
164
+ # reads a different one now.
165
+ #
166
+ # It degrades to `nil` on exactly the terms `reasons` does, and for the
167
+ # same reason turned around: a 202 whose body will not parse is still an
168
+ # acceptance, and relabelling it would tell the operator something untrue
169
+ # about a run the platform has already stored. See {#test_run_id}.
170
+ Result = Struct.new(:outcome, :code, :error, :reasons, :body, keyword_init: true) do
171
+ # The status codes worth spelling out, because each implies a different
172
+ # thing for the reader to *do*. A 401 means "rotate or fix the key"; a
173
+ # 400 means "the payload this gem built was refused", which is a bug
174
+ # report and not a credentials problem. Printing a bare number would
175
+ # leave a CI operator to guess which of the two they are looking at.
176
+ ADVICE = {
177
+ 400 => "the endpoint rejected the payload",
178
+ 401 => "the API key was not accepted",
179
+ 403 => "this API key may not write to that repository",
180
+ 404 => "no ingest endpoint at that URL — check SPECGUARD_ENDPOINT",
181
+ 413 => "the payload was too large for the endpoint",
182
+ 429 => "rate limited by the endpoint"
183
+ }.freeze
184
+
185
+ # How many of the platform's reasons are spelled out, with an
186
+ # `and N more` tail standing in for the rest.
187
+ #
188
+ # A cap rather than the whole list, because the list is unbounded at
189
+ # its source: `Ingest::Payload` appends one error *per bad spec*, so a
190
+ # systemic client bug on a 20,000-example suite comes back with
191
+ # ~20,000 strings. Three is enough to see the shape of the failure —
192
+ # they are near-identical when the cause is systemic — and the count
193
+ # tells the reader how widespread it is.
194
+ MAX_RENDERED_REASONS = 3
195
+
196
+ # Ceiling on the spelled-out reasons, before the `and N more` tail,
197
+ # which is appended afterwards so the count can never be the thing that
198
+ # gets truncated away.
199
+ MAX_REASONS_LENGTH = 300
200
+
201
+ def success? = outcome == :success
202
+
203
+ # The id of the `TestRun` this delivery landed on, as the endpoint
204
+ # reported it — `nil` whenever that cannot be said honestly: a refusal,
205
+ # an exception, a 202 with an unreadable body, or one whose
206
+ # `test_run_id` is not a scalar.
207
+ #
208
+ # Returned as a String so two deliveries can be compared without
209
+ # caring whether the platform spells an id as a UUID or a number. Two
210
+ # accepted lines answering with the same value landed on the same row;
211
+ # that is the only claim about folding this class can support, and
212
+ # {IngestCLI} makes exactly that one.
213
+ #
214
+ # @return [String, nil]
215
+ def test_run_id
216
+ value = body.is_a?(Hash) ? body["test_run_id"] : nil
217
+ value.is_a?(String) || value.is_a?(Numeric) ? value.to_s : nil
218
+ end
219
+
220
+ # A single clause naming what went wrong, for the one stderr line a run
221
+ # is allowed. `nil` on success, because there is nothing to say.
222
+ #
223
+ # @return [String, nil]
224
+ def reason
225
+ case outcome
226
+ when :success then nil
227
+ when :rejected then [+"HTTP #{code}", ADVICE[code], rendered_reasons].compact.join(" — ")
228
+ else "#{error.class}: #{error.message}"
229
+ end
230
+ end
231
+
232
+ private
233
+
234
+ # The refusal, bounded and flattened to fit on the one line. `nil` when
235
+ # there is nothing readable to add, which is what keeps the bare
236
+ # `HTTP 400 — the endpoint rejected the payload` intact for every body
237
+ # this class could not make sense of.
238
+ def rendered_reasons
239
+ cleaned = Array(reasons).filter_map { |value| one_line(value) }
240
+ return nil if cleaned.empty?
241
+
242
+ rendered = cleaned.take(MAX_RENDERED_REASONS).join("; ")
243
+ rendered = "#{rendered[0, MAX_REASONS_LENGTH - 1]}…" if rendered.length > MAX_REASONS_LENGTH
244
+
245
+ omitted = cleaned.length - MAX_RENDERED_REASONS
246
+ omitted.positive? ? "#{rendered} and #{omitted} more" : rendered
247
+ end
248
+
249
+ # One line, guaranteed. Newlines, tabs, ANSI escapes and every other
250
+ # control character collapse to a single space, so nothing arriving
251
+ # from the network can smear itself across a CI log or forge lines
252
+ # that look like they came from something else. `scrub` first because
253
+ # a body from a proxy is bytes, not necessarily valid UTF-8, and
254
+ # `gsub` raises on an invalid sequence.
255
+ #
256
+ # @return [String, nil] nil for a non-String and for anything that was
257
+ # only whitespace to begin with.
258
+ def one_line(value)
259
+ return nil unless value.is_a?(String)
260
+
261
+ text = value.scrub("").gsub(/[[:space:][:cntrl:]]+/, " ").strip
262
+ text.empty? ? nil : text
263
+ end
264
+ end
265
+
266
+ # @param endpoint [String, nil] the installation's base URL. Any trailing
267
+ # slashes are dropped; a path prefix is preserved, so an installation
268
+ # behind `https://tools.example.com/specguard` works.
269
+ # @param api_key [String, nil] sent verbatim as a Bearer token.
270
+ # @param timeout [Numeric, String, nil] seconds. Anything that is not a
271
+ # positive finite number falls back to the default rather than raising:
272
+ # a typo in `SPECGUARD_TIMEOUT` must not be able to fail a suite.
273
+ def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS)
274
+ @endpoint = endpoint
275
+ @api_key = api_key
276
+ @timeout = sanitize_timeout(timeout)
277
+ end
278
+
279
+ attr_reader :timeout
280
+
281
+ # Where this transport would POST.
282
+ #
283
+ # @return [URI::HTTP]
284
+ # @raise [ArgumentError] when the endpoint is missing or is not an
285
+ # http(s) URL. Raised rather than returned because {#deliver} converts
286
+ # it into a {Result} like every other failure, and a caller asking for
287
+ # the URI directly wants to know.
288
+ def uri
289
+ @uri ||= build_uri
290
+ end
291
+
292
+ # @param payload [Hash] the run, as {SpecGuard::RSpecFormatter#payload}
293
+ # assembles it. Sent as-is: its key names are already the platform's
294
+ # ingest contract, and reshaping it here would put the wire format two
295
+ # files away from the code that decides it.
296
+ # @return [Result] never nil, never raised through.
297
+ def deliver(payload)
298
+ response = post(JSON.generate(payload))
299
+ code = response.code.to_i
300
+
301
+ return Result.new(outcome: :success, code: code, body: success_body(response)) if
302
+ response.is_a?(Net::HTTPSuccess)
303
+
304
+ Result.new(outcome: :rejected, code: code, reasons: refusal_reasons(response))
305
+ rescue ScriptError, StandardError => e
306
+ # Connection refused, DNS failure, TLS failure, open/read timeout, a
307
+ # malformed endpoint — one family, one shape. `ScriptError` is in the
308
+ # list for the same reason the formatter's guard names it: an autoload
309
+ # blowing up under `net/http` is not a `StandardError`, and a bare
310
+ # rescue would let it escape and take the suite's exit code with it.
311
+ Result.new(outcome: :failed, error: e)
312
+ end
313
+
314
+ private
315
+
316
+ # What the platform said about the run it accepted, or `nil` when the
317
+ # body did not say anything this class can use.
318
+ #
319
+ # The guard is {#refusal_reasons}'s, argued the other way round. There it
320
+ # keeps a refusal from being relabelled an exception; here it keeps an
321
+ # *acceptance* from being relabelled at all. The platform has stored the
322
+ # run by the time it writes this body, so a truncated read, an empty
323
+ # body, or a proxy that rewrote the 202 into HTML must cost the caller
324
+ # the decoration and nothing else. Every failure of reading degrades to
325
+ # `nil`, and `Result#success?` is what it was before this existed.
326
+ def success_body(response)
327
+ body = JSON.parse(response.body.to_s)
328
+ body if body.is_a?(Hash)
329
+ rescue ScriptError, StandardError
330
+ nil
331
+ end
332
+
333
+ # What the platform said about the refusal, or `nil` when the body did
334
+ # not say anything this class can use.
335
+ #
336
+ # `Api::BaseController` shapes both refusal bodies deliberately for a
337
+ # client to read: `render_bad_request` carries every validation failure
338
+ # in `details` and repeats the first in `message`, and
339
+ # `render_unauthorized` carries `message` alone. So `details` is
340
+ # preferred and `message` is the fallback, which is exactly the 400/401
341
+ # split rather than two special cases.
342
+ #
343
+ # == Why the guard is here and not the method-level one
344
+ #
345
+ # {#deliver}'s `rescue` turns anything raised into
346
+ # `Result(outcome: :failed)`, and a body that will not parse is not a
347
+ # delivery failure — the delivery plainly succeeded and was refused. To
348
+ # let a `JSON::ParserError` reach that rescue would relabel a 400 as an
349
+ # exception and tell the operator something untrue about what happened,
350
+ # for the sake of a decoration. So every failure of *reading* degrades to
351
+ # `nil` here and the run is told exactly what it was told before this
352
+ # existed. That covers the empty body, the HTML a proxy answers a 413
353
+ # with, a JSON scalar, and a read that dies part-way through a body whose
354
+ # status line had already arrived.
355
+ def refusal_reasons(response)
356
+ body = JSON.parse(response.body.to_s)
357
+ return nil unless body.is_a?(Hash)
358
+
359
+ details = body["details"]
360
+ return details if details.is_a?(Array) && details.any? && details.all?(String)
361
+
362
+ message = body["message"]
363
+ [message] if message.is_a?(String)
364
+ rescue ScriptError, StandardError
365
+ nil
366
+ end
367
+
368
+ def post(body)
369
+ target = uri
370
+
371
+ http = Net::HTTP.new(target.host, target.port)
372
+ http.use_ssl = target.scheme == "https"
373
+ # All three, not just the two the spec names. Compression takes a
374
+ # 20k-example run from 7.01 MiB to 0.33 MiB, which is what puts the
375
+ # write comfortably inside the budget rather than past it — but a peer
376
+ # that accepts the connection and then stops reading still hangs in the
377
+ # *write* whatever the body's size, so the timeout is the thing that
378
+ # bounds it. Same unbounded wait, reached by a different door.
379
+ http.open_timeout = @timeout
380
+ http.read_timeout = @timeout
381
+ http.write_timeout = @timeout
382
+
383
+ http.start { |connection| connection.request(build_request(target, body)) }
384
+ end
385
+
386
+ def build_request(target, body)
387
+ request = Net::HTTP::Post.new(target.request_uri)
388
+ # `Api::BaseController#bearer_token` matches /\ABearer\s+(?<token>.+)\z/i.
389
+ request["Authorization"] = "Bearer #{@api_key}"
390
+ # Describes the body *inside* any encoding, which is what
391
+ # `Content-Type` means — the platform's `GzipRequestBody` inflates and
392
+ # then hands an ordinary JSON request downstream.
393
+ request["Content-Type"] = CONTENT_TYPE
394
+ request["Accept"] = CONTENT_TYPE
395
+ request["User-Agent"] = USER_AGENT
396
+
397
+ compressed = compress(body)
398
+ request["Content-Encoding"] = CONTENT_ENCODING if compressed
399
+ # `Net::HTTP::Post#body=` sets `Content-Length` from what it is given,
400
+ # so the length always describes the bytes actually on the wire.
401
+ request.body = compressed || body
402
+ request
403
+ end
404
+
405
+ # The compressed body, or `nil` to mean "send it as it is" — for a run
406
+ # under the threshold and, deliberately, for a compression that failed.
407
+ #
408
+ # Returning `nil` on failure rather than letting it out is the whole
409
+ # point: an identity body is something the platform accepts, so a broken
410
+ # `Zlib` costs a large run some bandwidth and nothing else. Letting the
411
+ # error reach {#deliver} would turn it into `Result(outcome: :failed)`
412
+ # and lose the run to a *saving*.
413
+ #
414
+ # `ScriptError, StandardError` matches {#deliver}'s own guard rather than
415
+ # naming `Zlib::Error`: the failure worth catching here is as likely to be
416
+ # the `zlib` extension missing from a stripped-down Ruby, which is a
417
+ # `LoadError` and so a `ScriptError`, as it is a compression fault.
418
+ def compress(body)
419
+ return nil if body.bytesize < GZIP_THRESHOLD_BYTES
420
+
421
+ Zlib.gzip(body)
422
+ rescue ScriptError, StandardError
423
+ nil
424
+ end
425
+
426
+ def build_uri
427
+ base = @endpoint.to_s.strip
428
+ raise ArgumentError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if base.empty?
429
+
430
+ parsed = URI.parse(base.sub(%r{/+\z}, "") + PATH)
431
+ # `URI::HTTPS < URI::HTTP`, so this admits both and rejects the
432
+ # scheme-less `specguard.example.com` that `URI.parse` happily returns
433
+ # as a `URI::Generic` with a nil host — which `Net::HTTP` would then
434
+ # try to connect to.
435
+ raise ArgumentError, "endpoint must be an http:// or https:// URL, got #{base.inspect}" unless
436
+ parsed.is_a?(URI::HTTP) && !parsed.host.to_s.empty?
437
+
438
+ parsed
439
+ end
440
+
441
+ def sanitize_timeout(value)
442
+ parsed = Float(value, exception: false)
443
+ return Configuration::DEFAULT_TIMEOUT_SECONDS unless parsed&.finite? && parsed.positive?
444
+
445
+ parsed
446
+ end
447
+ end
448
+ end
449
+ end