gitlab-labkit 5.2.1 → 5.3.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 +4 -4
- data/.gitlab-ci.yml +7 -0
- data/lib/labkit/rate_limit/README.md +50 -4
- data/lib/labkit/rate_limit/matcher.rb +138 -28
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e333c26dffb8c22bb8a131e820b01920887c2792dcffcee8f4ee012663b70831
|
|
4
|
+
data.tar.gz: 1cd8ac6e3b87b8bf1e214234e0d44dfc2b593798554fb1a1206d6e94cc8a2863
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4f602e792b4e8573f1ce8e29a9654221f274a3789e262af412f3b52a937970f5605748bbf9f4580698f6457436c561c905c7e1021e964f545bc511508892f8bc
|
|
7
|
+
data.tar.gz: b131bf8715cecbd30484b89299607b9781e6cda5c690da7dc24f851b1558fdd5eb78a417cbd9baaaf2d93393d3694de8c98aa180b0103e27f5e61d598aa110a8
|
data/.gitlab-ci.yml
CHANGED
|
@@ -79,6 +79,13 @@ conformance-spec:
|
|
|
79
79
|
variables:
|
|
80
80
|
LABKIT_RUBY_SHA: $CI_COMMIT_SHA
|
|
81
81
|
rules:
|
|
82
|
+
# Renovate's project-token bot cannot create pipelines in labkit-spec
|
|
83
|
+
# (GitLab forbids project bots from joining other projects), so its MRs
|
|
84
|
+
# would hard-fail the bridge. Attempt the trigger but do not block; a
|
|
85
|
+
# human re-running the pipeline gates the MR properly (it runs as them).
|
|
86
|
+
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $GITLAB_USER_LOGIN =~ /^project_\d+_bot/'
|
|
87
|
+
changes: !reference [.conformance-trigger-paths, changes]
|
|
88
|
+
allow_failure: true
|
|
82
89
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
|
83
90
|
changes: !reference [.conformance-trigger-paths, changes]
|
|
84
91
|
trigger:
|
|
@@ -207,17 +207,63 @@ A `match` hash gates whether a rule applies. Each value is normalised through
|
|
|
207
207
|
| plain value | `eq` | `match: { user: nil }`, `match: { method: "POST" }` |
|
|
208
208
|
| `Regexp` | `re` | `match: { endpoint: %r{\A/api/} }` |
|
|
209
209
|
| `{ eq: <value> }` | `eq` | `match: { method: { eq: "POST" } }` (YAML-friendly) |
|
|
210
|
+
| `{ ne: <value> }` | `ne` | `match: { method: { ne: "OPTIONS" } }` |
|
|
210
211
|
| `{ re: <source> }`| `re` | `match: { endpoint: { re: '\A/api/' } }` (YAML-friendly) |
|
|
212
|
+
| `{ nre: <source> }`| `nre` | `match: { endpoint: { nre: '\A/api/' } }` |
|
|
211
213
|
| `{ oneOf: <Array> }` | `oneOf` | `match: { user: { oneOf: ["7", "42"] } }` (YAML-friendly) |
|
|
214
|
+
| `{ noneOf: <Array> }` | `noneOf` | `match: { plan: { noneOf: ["premium"] } }` |
|
|
215
|
+
| `Array` of matchers | `all` | `match: { endpoint: [%r{\A/api/}, { nre: 'internal' }] }` |
|
|
212
216
|
|
|
213
|
-
`re`
|
|
214
|
-
used against non-String values (e.g. matching a 503 status against
|
|
217
|
+
`re` and `nre` coerce the identifier value via `#to_s` before matching, so they
|
|
218
|
+
can be used against non-String values (e.g. matching a 503 status against
|
|
219
|
+
`{ re: '^5' }`).
|
|
215
220
|
|
|
216
221
|
`oneOf` is set membership via `Set#include?` (`eql?`/`hash` equality, no
|
|
217
222
|
coercion): it agrees with `eq` for Strings, Symbols, booleans, nil, and
|
|
218
223
|
same-class numerics, but cross-type numerics differ (`1 == 1.0`, yet
|
|
219
|
-
`{ oneOf: [1] }` does not match `1.0`).
|
|
220
|
-
|
|
224
|
+
`{ oneOf: [1] }` does not match `1.0`). `noneOf` is its complement.
|
|
225
|
+
|
|
226
|
+
An Array means **AND**, so one identifier key can carry several predicates:
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
endpoint:
|
|
230
|
+
- nre: '/oauth/'
|
|
231
|
+
- nre: '\A/api/'
|
|
232
|
+
- nre: '\A/-/(?:health|liveness|readiness|metrics)'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Its entries must be matcher Hashes or bare `Regexp`s, the two shapes that
|
|
236
|
+
already mean "a matcher" on their own. An Array of plain values is rejected.
|
|
237
|
+
A value meant as one matcher, arriving as a list, therefore fails loudly rather
|
|
238
|
+
than becoming a membership test. Membership is spelled `{ oneOf: [...] }`.
|
|
239
|
+
|
|
240
|
+
An Array holds matchers, not other Arrays, so a match value nests three deep at
|
|
241
|
+
most: an Array, a matcher Hash inside it, and that Hash's `oneOf` list. A value
|
|
242
|
+
nesting deeper than `Matcher::MAX_NESTING_DEPTH` is rejected when the rule is
|
|
243
|
+
built.
|
|
244
|
+
|
|
245
|
+
#### Negation and absent keys
|
|
246
|
+
|
|
247
|
+
`ne`, `nre` and `noneOf` are plain complements of their positive forms, with no
|
|
248
|
+
special case for a missing key. The `Evaluator` looks an identifier key up
|
|
249
|
+
rather than requiring it, so an absent key reaches the matcher as `nil`, and a
|
|
250
|
+
complement is therefore **true** for one: a request with no `endpoint` is not a
|
|
251
|
+
request to an excluded endpoint.
|
|
252
|
+
|
|
253
|
+
That widens a rule rather than narrowing it. A rule that should apply only when
|
|
254
|
+
the key is present says so, with a presence matcher alongside the exclusions:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
match: { endpoint: [/./, { nre: '\A/api/' }] }
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Since this follows from inversion, a matcher that does accept `nil` inverts the
|
|
261
|
+
other way: `{ ne: nil }`, `{ noneOf: [nil] }` and `{ nre: '^$' }` are all false
|
|
262
|
+
for an absent key, the last because `nil.to_s` is `""`.
|
|
263
|
+
|
|
264
|
+
There is no cap on regex source length. Rules are written by operators, and the
|
|
265
|
+
only bound on a pattern is `Matcher::MATCH_TIMEOUT_SECONDS`, a 50ms wall-clock
|
|
266
|
+
budget applied per compiled `Regexp`.
|
|
221
267
|
|
|
222
268
|
Glob and prefix matchers are intentionally out of scope.
|
|
223
269
|
|
|
@@ -12,27 +12,49 @@ module Labkit
|
|
|
12
12
|
# - any plain value (String, Symbol, Integer, ...) -> :eq matcher
|
|
13
13
|
# - a Regexp instance -> :re matcher (Ruby convenience)
|
|
14
14
|
# - { eq: <value> } -> :eq matcher (canonical, YAML-compatible)
|
|
15
|
+
# - { ne: <value> } -> :ne matcher (complement of :eq)
|
|
15
16
|
# - { re: <String|Regexp> } -> :re matcher (canonical, YAML-compatible)
|
|
17
|
+
# - { nre: <String|Regexp> } -> :nre matcher (complement of :re)
|
|
16
18
|
# - { oneOf: <Array> } -> :oneOf matcher (set membership, YAML-compatible)
|
|
19
|
+
# - { noneOf: <Array> } -> :noneOf matcher (complement of :oneOf)
|
|
20
|
+
# - [ <matcher>, <matcher>, ... ] -> :all matcher (every entry must match)
|
|
17
21
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
22
|
+
# An Array means AND, so several predicates can apply to one identifier key.
|
|
23
|
+
# Its entries must be matcher Hashes or bare Regexps, the two shapes that
|
|
24
|
+
# already mean "a matcher" on their own. An Array of plain values is
|
|
25
|
+
# rejected, so a value that was meant as a single matcher but arrived as a
|
|
26
|
+
# list fails loudly instead of silently becoming a membership test. An Array
|
|
27
|
+
# holds matchers rather than other Arrays, so a match value nests at most
|
|
28
|
+
# MAX_NESTING_DEPTH deep; see too_deeply_nested?.
|
|
21
29
|
#
|
|
22
|
-
# :oneOf membership is Set#include? (eql?/hash equality), not
|
|
23
|
-
# cross-type numerics differ (1 == 1.0, but {oneOf: [1]} does
|
|
30
|
+
# :oneOf and :noneOf membership is Set#include? (eql?/hash equality), not
|
|
31
|
+
# :eq's ==, so cross-type numerics differ (1 == 1.0, but {oneOf: [1]} does
|
|
32
|
+
# not match 1.0).
|
|
24
33
|
#
|
|
25
34
|
# Hash-key naming follows the metrics-catalog selector pattern. Glob,
|
|
26
35
|
# prefix, and other matcher kinds are intentionally out of scope here; see
|
|
27
36
|
# gitlab-com/gl-infra/production-engineering#28853 for that follow-up.
|
|
28
37
|
#
|
|
29
|
-
#
|
|
30
|
-
# the regex, so callers can match non-String identifier values such
|
|
31
|
-
# Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
|
|
38
|
+
# The :re and :nre matchers coerce the identifier value via #to_s before
|
|
39
|
+
# applying the regex, so callers can match non-String identifier values such
|
|
40
|
+
# as Integer status codes (e.g. {status: { re: "^5" }} against status: 503).
|
|
41
|
+
#
|
|
42
|
+
# Each negation matcher is the plain complement of its positive form, with
|
|
43
|
+
# no special case for a missing key. Evaluator passes identifier[key], so an
|
|
44
|
+
# absent key arrives as nil, and the complement is therefore usually true
|
|
45
|
+
# for one: a request with no path is not a request to an excluded path. A
|
|
46
|
+
# rule that should apply only when the key is present says so, by adding a
|
|
47
|
+
# presence matcher alongside the exclusions:
|
|
48
|
+
#
|
|
49
|
+
# path: [/./, { nre: '\A/api/' }]
|
|
32
50
|
class Matcher < Data.define(:type, :value)
|
|
33
|
-
KNOWN_HASH_KEYS = %i[eq re oneOf].freeze
|
|
34
|
-
MAX_REGEX_SOURCE_LENGTH = 200
|
|
51
|
+
KNOWN_HASH_KEYS = %i[eq ne re nre oneOf noneOf].freeze
|
|
35
52
|
ERROR_INSPECT_LIMIT = 80
|
|
53
|
+
# No Hash or Array may sit this many levels below the match value, which
|
|
54
|
+
# is level 0. The deepest valid shape is an Array (0) holding a matcher
|
|
55
|
+
# Hash (1) whose :oneOf list (2) holds plain values; see
|
|
56
|
+
# too_deeply_nested?.
|
|
57
|
+
MAX_NESTING_DEPTH = 3
|
|
36
58
|
|
|
37
59
|
# Wall-clock budget for a single #match? call. Without it a match is
|
|
38
60
|
# bounded only by whatever global the host sets (40s in GitLab Rails,
|
|
@@ -45,16 +67,23 @@ module Labkit
|
|
|
45
67
|
# timeouts were wall-clock stalls, since Ruby counts real time and GC
|
|
46
68
|
# pauses on GitLab's git fleet have a p50 of ~72ms. Under 4x CPU
|
|
47
69
|
# oversubscription 50ms still fires on 0.16% of those matches, 5ms 1.33%.
|
|
70
|
+
#
|
|
71
|
+
# This timeout is the only bound on a pattern. There is no cap on regex
|
|
72
|
+
# source length: rules are written by operators rather than end users, and
|
|
73
|
+
# a cap that applied to {re: "..."} but not to a bare Regexp bounded
|
|
74
|
+
# nothing in practice, since callers pass Regexp constants.
|
|
48
75
|
MATCH_TIMEOUT_SECONDS = 0.05
|
|
49
76
|
|
|
50
77
|
def self.build(input)
|
|
78
|
+
raise ArgumentError, nesting_error(input) if too_deeply_nested?(input)
|
|
79
|
+
|
|
51
80
|
case input
|
|
52
81
|
when Regexp
|
|
53
|
-
new(type: :re, value: with_match_timeout(input))
|
|
82
|
+
new(type: :re, value: with_match_timeout(:re, input))
|
|
54
83
|
when Hash
|
|
55
84
|
from_hash(input)
|
|
56
85
|
when Array
|
|
57
|
-
|
|
86
|
+
compose(input)
|
|
58
87
|
else
|
|
59
88
|
new(type: :eq, value: input)
|
|
60
89
|
end
|
|
@@ -64,6 +93,15 @@ module Labkit
|
|
|
64
93
|
raise ArgumentError, invalid_shape_error(input) if input.size != 1
|
|
65
94
|
|
|
66
95
|
type, source = input.first
|
|
96
|
+
|
|
97
|
+
# Identifier rejects keys the same way. Without this, a key such as an
|
|
98
|
+
# Integer or nil leaves NoMethodError for the caller instead of the
|
|
99
|
+
# ArgumentError every other bad shape raises.
|
|
100
|
+
unless type.respond_to?(:to_sym)
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"rate-limit match value type key #{truncate_for_error(type)} must be a String or Symbol"
|
|
103
|
+
end
|
|
104
|
+
|
|
67
105
|
type_sym = type.to_sym
|
|
68
106
|
|
|
69
107
|
unless KNOWN_HASH_KEYS.include?(type_sym)
|
|
@@ -77,31 +115,45 @@ module Labkit
|
|
|
77
115
|
|
|
78
116
|
def self.compile(type_sym, source)
|
|
79
117
|
case type_sym
|
|
80
|
-
when :eq
|
|
81
|
-
new(type:
|
|
82
|
-
when :oneOf
|
|
118
|
+
when :eq, :ne
|
|
119
|
+
new(type: type_sym, value: source)
|
|
120
|
+
when :oneOf, :noneOf
|
|
83
121
|
unless source.is_a?(Array)
|
|
84
122
|
raise ArgumentError,
|
|
85
|
-
"rate-limit match value {
|
|
123
|
+
"rate-limit match value {#{type_sym}: ...} must be an Array, got #{truncate_for_error(source)}"
|
|
86
124
|
end
|
|
87
125
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
126
|
+
# {oneOf: []} matches nothing, so the rule never applies and no harm
|
|
127
|
+
# follows. Its complement matches everything, which is an exclusion
|
|
128
|
+
# that excludes nothing: rejected for the same reason an empty Array
|
|
129
|
+
# of matchers is.
|
|
130
|
+
if type_sym == :noneOf && source.empty?
|
|
91
131
|
raise ArgumentError,
|
|
92
|
-
"rate-limit match value {
|
|
132
|
+
"rate-limit match value {noneOf: []} must not be empty, it matches everything"
|
|
93
133
|
end
|
|
94
134
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
raise ArgumentError,
|
|
99
|
-
"rate-limit match value {re: #{truncate_for_error(source)}} failed to compile: #{e.message}"
|
|
100
|
-
end
|
|
135
|
+
new(type: type_sym, value: Set.new(source).freeze)
|
|
136
|
+
when :re, :nre
|
|
137
|
+
new(type: type_sym, value: with_match_timeout(type_sym, source))
|
|
101
138
|
end
|
|
102
139
|
end
|
|
103
140
|
private_class_method :compile
|
|
104
141
|
|
|
142
|
+
# Builds the :all matcher behind the Array form. Entries go back through
|
|
143
|
+
# .build, which is what keeps a nested regex bounded by the match timeout.
|
|
144
|
+
def self.compose(input)
|
|
145
|
+
raise ArgumentError, "rate-limit match value [] must not be empty" if input.empty?
|
|
146
|
+
|
|
147
|
+
unless input.all? { |entry| entry.is_a?(Hash) || entry.is_a?(Regexp) }
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"rate-limit match value Array entries must each be a matcher Hash or Regexp, " \
|
|
150
|
+
"got #{truncate_for_error(input)}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
new(type: :all, value: input.map { |entry| build(entry) }.freeze)
|
|
154
|
+
end
|
|
155
|
+
private_class_method :compose
|
|
156
|
+
|
|
105
157
|
# Compiles +source+ (a String pattern or an existing Regexp) into a
|
|
106
158
|
# Regexp bounded by MATCH_TIMEOUT_SECONDS.
|
|
107
159
|
#
|
|
@@ -116,7 +168,7 @@ module Labkit
|
|
|
116
168
|
# Recompiling an existing Regexp preserves its source and options
|
|
117
169
|
# (including the fixed-encoding flags) and therefore +#==+; only object
|
|
118
170
|
# identity changes.
|
|
119
|
-
def self.with_match_timeout(source)
|
|
171
|
+
def self.with_match_timeout(key, source)
|
|
120
172
|
return source if source.is_a?(Regexp) && source.timeout
|
|
121
173
|
|
|
122
174
|
if source.is_a?(Regexp)
|
|
@@ -124,12 +176,19 @@ module Labkit
|
|
|
124
176
|
else
|
|
125
177
|
Regexp.new(source, timeout: MATCH_TIMEOUT_SECONDS)
|
|
126
178
|
end
|
|
179
|
+
rescue RegexpError, TypeError => e
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"rate-limit match value {#{key}: #{truncate_for_error(source)}} failed to compile: #{e.message}"
|
|
127
182
|
end
|
|
128
183
|
|
|
129
184
|
private_class_method :with_match_timeout
|
|
130
185
|
|
|
131
186
|
def self.invalid_shape_error(input)
|
|
132
|
-
|
|
187
|
+
# No key list here: this fires on the shape, and from_hash names the
|
|
188
|
+
# accepted keys when the shape is right but the key is not. Keeping the
|
|
189
|
+
# fixed part short is also what lets truncate_for_error bound the whole
|
|
190
|
+
# message.
|
|
191
|
+
"rate-limit match value must be a single-key Hash, or an Array of matchers, " \
|
|
133
192
|
"got #{truncate_for_error(input)}"
|
|
134
193
|
end
|
|
135
194
|
private_class_method :invalid_shape_error
|
|
@@ -140,14 +199,65 @@ module Labkit
|
|
|
140
199
|
end
|
|
141
200
|
private_class_method :truncate_for_error
|
|
142
201
|
|
|
202
|
+
def self.nesting_error(input)
|
|
203
|
+
# Does not inspect the input: it is too deep to render, which is the
|
|
204
|
+
# reason this fires. The class is the only hint available cheaply, and
|
|
205
|
+
# the nesting may be in a key or a value.
|
|
206
|
+
"rate-limit match value nests deeper than #{MAX_NESTING_DEPTH} levels (#{input.class})"
|
|
207
|
+
end
|
|
208
|
+
private_class_method :nesting_error
|
|
209
|
+
|
|
210
|
+
# A valid match value nests three deep at most: an Array of matcher
|
|
211
|
+
# Hashes whose :oneOf value is an Array of plain values. Anything past
|
|
212
|
+
# MAX_NESTING_DEPTH is rejected rather than built.
|
|
213
|
+
#
|
|
214
|
+
# The check is iterative because the alternative is finding out through
|
|
215
|
+
# #inspect, which recurses once per level while rendering an error
|
|
216
|
+
# message. Rules are built at boot and SystemStackError is not a
|
|
217
|
+
# StandardError, so that would take the process down rather than reject
|
|
218
|
+
# one rule, and rescuing it is not an option either: the VM aborts on a
|
|
219
|
+
# second overflow in the same process.
|
|
220
|
+
def self.too_deeply_nested?(input)
|
|
221
|
+
# A flat stack of node, depth pairs, so a visit allocates no Array. The
|
|
222
|
+
# pops must mirror the pushes: depth comes off first, then its node.
|
|
223
|
+
stack = [input, 0]
|
|
224
|
+
|
|
225
|
+
until stack.empty?
|
|
226
|
+
depth = stack.pop
|
|
227
|
+
node = stack.pop
|
|
228
|
+
next unless node.is_a?(Hash) || node.is_a?(Array)
|
|
229
|
+
return true if depth >= MAX_NESTING_DEPTH
|
|
230
|
+
|
|
231
|
+
# Keys as well as values: #inspect renders both, so a deeply nested
|
|
232
|
+
# key overflows the same way a deeply nested value does.
|
|
233
|
+
if node.is_a?(Hash)
|
|
234
|
+
node.each { |key, value| stack.push(key, depth + 1, value, depth + 1) }
|
|
235
|
+
else
|
|
236
|
+
node.each { |child| stack.push(child, depth + 1) }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
false
|
|
241
|
+
end
|
|
242
|
+
private_class_method :too_deeply_nested?
|
|
243
|
+
|
|
143
244
|
def match?(identifier_value)
|
|
144
245
|
case type
|
|
145
246
|
when :eq
|
|
146
247
|
value == identifier_value
|
|
248
|
+
when :ne
|
|
249
|
+
value != identifier_value
|
|
147
250
|
when :oneOf
|
|
148
251
|
value.include?(identifier_value)
|
|
252
|
+
when :noneOf
|
|
253
|
+
# exclude? is ActiveSupport, and nothing in rate_limit requires it.
|
|
254
|
+
!value.include?(identifier_value) # rubocop:disable Rails/NegateInclude
|
|
149
255
|
when :re
|
|
150
256
|
value.match?(identifier_value.to_s)
|
|
257
|
+
when :nre
|
|
258
|
+
!value.match?(identifier_value.to_s)
|
|
259
|
+
when :all
|
|
260
|
+
value.all? { |matcher| matcher.match?(identifier_value) }
|
|
151
261
|
else
|
|
152
262
|
raise ArgumentError, "unknown matcher type: #{type.inspect}"
|
|
153
263
|
end
|