k9o 1.4.0 → 1.5.2
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/CHANGELOG.md +69 -1
- data/README.md +1273 -142
- data/k9o.gemspec +2 -2
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +102 -8
- data/lib/k9o/audit.rb +150 -0
- data/lib/k9o/client.rb +18 -3
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +65 -11
- data/lib/k9o/ed25519.rb +182 -0
- data/lib/k9o/end_users.rb +93 -0
- data/lib/k9o/envelope.rb +20 -5
- data/lib/k9o/functions.rb +82 -3
- data/lib/k9o/gate.rb +46 -7
- data/lib/k9o/http_transport.rb +71 -14
- data/lib/k9o/jcs.rb +117 -0
- data/lib/k9o/kerror.rb +57 -5
- data/lib/k9o/keys.rb +131 -0
- data/lib/k9o/kibo_client_core.rb +385 -0
- data/lib/k9o/kibo_ffi_core.rb +178 -0
- data/lib/k9o/locate.rb +118 -0
- data/lib/k9o/me.rb +1 -1
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +4 -5
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -10
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/vault.rb +3 -4
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +13 -0
- data/sig/k9o/approvals.rbs +25 -3
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +2 -0
- data/sig/k9o/kerror.rbs +24 -0
- data/sig/k9o/kibo_client_core.rbs +25 -0
- data/sig/k9o/metadata.rbs +54 -0
- data/sig/k9o/moat.rbs +29 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/workload.rbs +64 -0
- metadata +27 -4
data/lib/k9o/rampart.rb
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require_relative 'jcs'
|
|
6
|
+
require_relative 'ed25519'
|
|
7
|
+
|
|
8
|
+
module K9o
|
|
9
|
+
# Rampart: hosted classification of content. Advisory: it returns a verdict and a signed receipt and blocks
|
|
10
|
+
# nothing. Your code decides what to do with the verdict.
|
|
11
|
+
#
|
|
12
|
+
# classify(input, label: nil) POST /@<slug>/rampart/classify
|
|
13
|
+
# jwks GET /rampart/jwks (public, no project prefix, no credential)
|
|
14
|
+
# verify_receipt(receipt, input, **options) checks the receipt locally and returns the verified verdict
|
|
15
|
+
#
|
|
16
|
+
# +classify+ answers an Envelope whose +data+ is { 'classification' => "clear" | "suspicious" | "malicious",
|
|
17
|
+
# 'riskScore', 'confidence' (both whole basis points, 0 to 10000), 'categories', 'calibrationVersion',
|
|
18
|
+
# 'classificationReceipt', 'projectId' }; the receipt is the string exactly as received. When the classifier is down the
|
|
19
|
+
# gateway answers 503 K9O_RAMPART_CLASSIFIER_UNAVAILABLE, raised as K9o::KError: there is no verdict and never a
|
|
20
|
+
# default pass. A long input is scored in overlapping windows (the highest injection score decides); past 16
|
|
21
|
+
# windows (about 6,000 tokens) the gateway answers 413 K9O_RAMPART_INPUT_TOO_LONG (context reason
|
|
22
|
+
# "rampart_input_too_long"), also raised as K9o::KError, with no verdict.
|
|
23
|
+
class Rampart
|
|
24
|
+
RECEIPT_TYP = 'k9o.rampart.classify.v1'
|
|
25
|
+
CLASSIFICATIONS = %w[clear suspicious malicious].freeze
|
|
26
|
+
DEFAULT_CLOCK_SKEW = 60
|
|
27
|
+
# The closed set of reasons verify_receipt refuses with, in the order it checks them.
|
|
28
|
+
REASONS = %i[malformed key_unknown alg typ signature issuer project_unknown project expired not_yet_valid
|
|
29
|
+
input_hash].freeze
|
|
30
|
+
|
|
31
|
+
# Raised by verify_receipt for every failure. +reason+ is one of REASONS; no verdict is returned with it.
|
|
32
|
+
# It is a K9o::KError, so +rescue K9o::KError+ catches it too.
|
|
33
|
+
class ReceiptRefusal < KError
|
|
34
|
+
attr_reader :reason
|
|
35
|
+
|
|
36
|
+
def initialize(reason, detail)
|
|
37
|
+
@reason = reason
|
|
38
|
+
super(code: 'K9O_RAMPART_RECEIPT_REFUSED', message: "#{reason}: #{detail}", context: { reason: reason.to_s })
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The claims of a receipt that passed every check. +windows+ is the number of windows the classifier scored (1 when
|
|
43
|
+
# the input fits one window), or nil when the receipt carries no such claim. Claims this SDK does not know are
|
|
44
|
+
# ignored.
|
|
45
|
+
Verdict = Struct.new(:classification, :risk_bp, :confidence_bp, :categories, :project_id, :calibration_version,
|
|
46
|
+
:input_sha256, :issued_at, :expires_at, :kid, :windows, keyword_init: true)
|
|
47
|
+
|
|
48
|
+
def initialize(config)
|
|
49
|
+
@config = config
|
|
50
|
+
@lock = Mutex.new
|
|
51
|
+
@keys = nil
|
|
52
|
+
@feed_issuer = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Classify +input+ (a Hash). +label+ is optional context for the classifier, 1 to 128 characters.
|
|
56
|
+
def classify(input, label: nil)
|
|
57
|
+
raise invalid('input must be a Hash') unless input.is_a?(Hash)
|
|
58
|
+
unless label.nil? || (label.is_a?(String) && label.length >= 1 && label.length <= 128)
|
|
59
|
+
raise invalid('label must be a String of 1 to 128 characters')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
slug = Studio.require_project_slug(@config, 'rampart')
|
|
63
|
+
body = { 'input' => input }
|
|
64
|
+
body['label'] = label unless label.nil?
|
|
65
|
+
envelope = HttpTransport.request(config: @config, method: 'POST',
|
|
66
|
+
path: UrlGrammar.project_path(slug, 'rampart/classify'), body: body)
|
|
67
|
+
envelope
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The receipt key feed. +data+ is { 'keys' => [ { 'kid', 'kty', 'crv', 'x', ... } ], 'issuer' => "<issuer>/rampart" }.
|
|
71
|
+
# Public: sends no credential.
|
|
72
|
+
def jwks
|
|
73
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.platform_path('rampart/jwks'),
|
|
74
|
+
authenticated: false, bare: true)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Verify a receipt against +input+ (the same Hash passed to classify) and return a Verdict read from the signed
|
|
78
|
+
# claims. +receipt+ is either the Envelope classify returned (it carries the receipt and its +projectId+) or the
|
|
79
|
+
# bare receipt String. Any failure raises ReceiptRefusal; there is never a partial verdict.
|
|
80
|
+
#
|
|
81
|
+
# Options:
|
|
82
|
+
# key_set: a key set you already fetched (the Hash from +jwks.data+, or its Array of keys). Without it the keys
|
|
83
|
+
# are fetched from the feed, kept for the life of the client, and fetched once more on an unknown kid.
|
|
84
|
+
# issuer: the expected +iss+. Default: the +issuer+ the key feed advertises. With neither, the receipt is
|
|
85
|
+
# refused with reason +issuer+.
|
|
86
|
+
# project_id: the expected +project_id+. Default: the +projectId+ carried by the classify result passed in. A
|
|
87
|
+
# bare receipt String with neither is refused with reason +project_unknown+.
|
|
88
|
+
# clock_skew: seconds of tolerance on +iat+ and +exp+ (default 60).
|
|
89
|
+
# clock: a Time, a number of Unix seconds, or an object whose +call+ returns either. Default: the current time.
|
|
90
|
+
def verify_receipt(receipt, input, key_set: nil, issuer: nil, project_id: nil, clock_skew: DEFAULT_CLOCK_SKEW,
|
|
91
|
+
clock: nil)
|
|
92
|
+
receipt, carried_project = unpack_result(receipt)
|
|
93
|
+
parts = split_receipt(receipt)
|
|
94
|
+
header = decode_json(parts[0], 'header')
|
|
95
|
+
claims = decode_json(parts[1], 'claims')
|
|
96
|
+
signature = decode_b64(parts[2], 'signature')
|
|
97
|
+
|
|
98
|
+
kid = header['kid']
|
|
99
|
+
raise refuse(:malformed, 'header has no kid') unless kid.is_a?(String) && !kid.empty?
|
|
100
|
+
|
|
101
|
+
jwk, feed_issuer = find_key(kid, key_set)
|
|
102
|
+
raise refuse(:alg, "alg must be EdDSA, got #{header['alg'].inspect}") unless header['alg'] == 'EdDSA'
|
|
103
|
+
raise refuse(:typ, "typ must be #{RECEIPT_TYP}, got #{header['typ'].inspect}") unless header['typ'] == RECEIPT_TYP
|
|
104
|
+
|
|
105
|
+
public_key = decode_b64(jwk['x'].to_s, 'key')
|
|
106
|
+
unless Ed25519.verify(public_key, "#{parts[0]}.#{parts[1]}", signature)
|
|
107
|
+
raise refuse(:signature, 'the signature does not match the receipt')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
expected_issuer = issuer || feed_issuer
|
|
111
|
+
if expected_issuer.nil?
|
|
112
|
+
raise refuse(:issuer, 'no issuer is advertised by the key feed and none was given; pass issuer:')
|
|
113
|
+
end
|
|
114
|
+
unless claims['iss'] == expected_issuer
|
|
115
|
+
raise refuse(:issuer, "iss is #{claims['iss'].inspect}, expected #{expected_issuer}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
expected_project = project_id || carried_project
|
|
119
|
+
if expected_project.nil?
|
|
120
|
+
raise refuse(:project_unknown, 'no project id to compare with; pass the classify result or project_id:')
|
|
121
|
+
end
|
|
122
|
+
unless claims['project_id'].is_a?(String) && claims['project_id'] == expected_project
|
|
123
|
+
raise refuse(:project, 'the receipt was issued for a different project')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
check_times(claims, clock_skew, clock)
|
|
127
|
+
unless claims['input_sha256'] == input_digest(input)
|
|
128
|
+
raise refuse(:input_hash, 'the receipt does not attest this input')
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
build_verdict(claims, kid)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# SHA-256 of the canonical form (see Jcs) of +input+, as lowercase hex.
|
|
135
|
+
def input_digest(input)
|
|
136
|
+
raise ReceiptRefusal.new(:input_hash, 'input must be a Hash') unless input.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
Digest::SHA256.hexdigest(Jcs.canonical_bytes(input))
|
|
139
|
+
rescue Jcs::Error => e
|
|
140
|
+
raise ReceiptRefusal.new(:input_hash, "the input cannot be canonicalised: #{e.message}")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def invalid(message)
|
|
146
|
+
ApprovalChecks.invalid(message, {})
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def refuse(reason, detail)
|
|
150
|
+
ReceiptRefusal.new(reason, detail)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# [receipt String, projectId carried by a classify result or nil].
|
|
154
|
+
def unpack_result(value)
|
|
155
|
+
data = value.respond_to?(:data) ? value.data : value
|
|
156
|
+
return [value, nil] unless data.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
carried = data['projectId']
|
|
159
|
+
[data['classificationReceipt'], carried.is_a?(String) && !carried.empty? ? carried : nil]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def split_receipt(receipt)
|
|
163
|
+
raise refuse(:malformed, 'receipt must be a String') unless receipt.is_a?(String)
|
|
164
|
+
|
|
165
|
+
parts = receipt.split('.', -1)
|
|
166
|
+
raise refuse(:malformed, 'receipt must have three dot-separated parts') unless parts.size == 3 && parts.none?(&:empty?)
|
|
167
|
+
|
|
168
|
+
parts
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def decode_b64(text, what)
|
|
172
|
+
raise refuse(:malformed, "#{what} is not base64url") unless text.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
173
|
+
|
|
174
|
+
padded = text.tr('-_', '+/')
|
|
175
|
+
padded += '=' * ((4 - (padded.length % 4)) % 4)
|
|
176
|
+
padded.unpack1('m0')
|
|
177
|
+
rescue ArgumentError
|
|
178
|
+
raise refuse(:malformed, "#{what} is not base64url")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def decode_json(text, what)
|
|
182
|
+
parsed = JSON.parse(decode_b64(text, what).force_encoding(Encoding::UTF_8))
|
|
183
|
+
raise refuse(:malformed, "#{what} is not a JSON object") unless parsed.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
parsed
|
|
186
|
+
rescue JSON::ParserError, EncodingError
|
|
187
|
+
raise refuse(:malformed, "#{what} is not JSON")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def normalise_keys(key_set)
|
|
191
|
+
list = key_set.is_a?(Hash) ? (key_set['keys'] || key_set[:keys]) : key_set
|
|
192
|
+
raise refuse(:key_unknown, 'the key set has no keys') unless list.is_a?(Array)
|
|
193
|
+
|
|
194
|
+
list.each_with_object({}) do |jwk, out|
|
|
195
|
+
next unless jwk.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
jwk = jwk.transform_keys(&:to_s)
|
|
198
|
+
out[jwk['kid']] = jwk if jwk['kid'].is_a?(String)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# [the key for +kid+, the issuer the feed advertises or nil].
|
|
203
|
+
def find_key(kid, key_set)
|
|
204
|
+
keys, advertised = if key_set.nil?
|
|
205
|
+
cached_feed(kid)
|
|
206
|
+
else
|
|
207
|
+
[normalise_keys(key_set), feed_issuer_of(key_set)]
|
|
208
|
+
end
|
|
209
|
+
jwk = keys[kid]
|
|
210
|
+
raise refuse(:key_unknown, "no key with kid #{kid.inspect} in the key feed") if jwk.nil?
|
|
211
|
+
unless jwk['kty'] == 'OKP' && jwk['crv'] == 'Ed25519' && jwk['x'].is_a?(String)
|
|
212
|
+
raise refuse(:key_unknown, "key #{kid.inspect} is not an Ed25519 key")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
[jwk, advertised]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def feed_issuer_of(feed)
|
|
219
|
+
return nil unless feed.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
value = feed['issuer'] || feed[:issuer]
|
|
222
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Keys and issuer kept for the client's life; one refetch when the kid is not among the keys.
|
|
226
|
+
def cached_feed(kid)
|
|
227
|
+
@lock.synchronize do
|
|
228
|
+
if @keys.nil? || !@keys.key?(kid)
|
|
229
|
+
feed = jwks.data
|
|
230
|
+
@keys = normalise_keys(feed)
|
|
231
|
+
@feed_issuer = feed_issuer_of(feed)
|
|
232
|
+
end
|
|
233
|
+
[@keys, @feed_issuer]
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def now_seconds(clock)
|
|
238
|
+
value = clock.respond_to?(:call) ? clock.call : clock
|
|
239
|
+
value = Time.now if value.nil?
|
|
240
|
+
value.to_f
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def check_times(claims, skew, clock)
|
|
244
|
+
iat = claims['iat']
|
|
245
|
+
exp = claims['exp']
|
|
246
|
+
unless iat.is_a?(Numeric) && exp.is_a?(Numeric)
|
|
247
|
+
raise refuse(:malformed, 'iat and exp must be numbers')
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
now = now_seconds(clock)
|
|
251
|
+
raise refuse(:expired, 'the receipt has expired') if now > exp + skew
|
|
252
|
+
raise refuse(:not_yet_valid, 'the receipt is issued in the future') if iat > now + skew
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def build_verdict(claims, kid)
|
|
256
|
+
cls = claims['classification']
|
|
257
|
+
risk = claims['risk_bp']
|
|
258
|
+
conf = claims['confidence_bp']
|
|
259
|
+
cats = claims['categories']
|
|
260
|
+
ok = CLASSIFICATIONS.include?(cls) &&
|
|
261
|
+
[risk, conf].all? { |n| n.is_a?(Integer) && n >= 0 && n <= 10_000 } &&
|
|
262
|
+
cats.is_a?(Array) && cats.all?(String) &&
|
|
263
|
+
claims['calibration_version'].is_a?(String) && claims['input_sha256'].is_a?(String)
|
|
264
|
+
raise refuse(:malformed, 'the signed claims are not a verdict') unless ok
|
|
265
|
+
|
|
266
|
+
Verdict.new(classification: cls, risk_bp: risk, confidence_bp: conf, categories: cats.dup.freeze,
|
|
267
|
+
project_id: claims['project_id'], calibration_version: claims['calibration_version'],
|
|
268
|
+
input_sha256: claims['input_sha256'], issued_at: Time.at(claims['iat']).utc,
|
|
269
|
+
expires_at: Time.at(claims['exp']).utc, kid: kid,
|
|
270
|
+
windows: claims['windows'].is_a?(Integer) && claims['windows'] >= 1 ? claims['windows'] : nil)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
data/lib/k9o/realtime.rb
CHANGED
|
@@ -3,30 +3,281 @@
|
|
|
3
3
|
module K9o
|
|
4
4
|
# Realtime Subscriptions namespace.
|
|
5
5
|
#
|
|
6
|
-
# Transport: SSE over GET /@<slug>/realtime/subscribe. The gateway
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Transport: SSE over GET /@<slug>/realtime/subscribe. The gateway closes each stream after at most 300 seconds
|
|
7
|
+
# and sends a +:hb+ comment every 15 seconds on a quiet feed. #subscribe returns an Enumerator that reconnects
|
|
8
|
+
# with +Last-Event-ID+, drops events it has already delivered and, when the gateway reports that the position
|
|
9
|
+
# is lost (+event: resync+), calls +on_resync+ and continues from a fresh connection.
|
|
9
10
|
class Realtime
|
|
11
|
+
@idle_timeout = 45
|
|
12
|
+
@max_backoff = 30
|
|
13
|
+
@sleeper = ->(seconds) { sleep(seconds) }
|
|
14
|
+
DEFAULT_RETRY_MS = 1000
|
|
15
|
+
SEEN_LIMIT = 1024
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Seconds without any bytes (events, comments, heartbeats) after which a connection is abandoned.
|
|
19
|
+
attr_accessor :idle_timeout
|
|
20
|
+
# Longest reconnect delay in seconds.
|
|
21
|
+
attr_accessor :max_backoff
|
|
22
|
+
# Callable taking seconds; replaceable in tests.
|
|
23
|
+
attr_accessor :sleeper
|
|
24
|
+
end
|
|
25
|
+
|
|
10
26
|
def initialize(config)
|
|
11
27
|
@config = config
|
|
12
28
|
end
|
|
13
29
|
|
|
14
30
|
# Subscribe via SSE; returns an Enumerator yielding event hashes
|
|
15
|
-
# { 'topic', 'eventType', 'payload', 'occurredAt' } (the gateway's spelling).
|
|
16
|
-
#
|
|
17
|
-
|
|
31
|
+
# { 'topic', 'eventType', 'payload', 'occurredAt', 'eventId' } (the gateway's spelling).
|
|
32
|
+
#
|
|
33
|
+
# The enumerator reconnects when the stream ends or fails, sending +Last-Event-ID+, and drops events whose
|
|
34
|
+
# +eventId+ it has already delivered (delivery is at-least-once). A client error (4xx) is raised once as a
|
|
35
|
+
# K9o::KError; server errors and network errors are retried with a capped backoff (30 seconds at most).
|
|
36
|
+
# An +event: resync+ frame is not yielded: +on_resync+ (a callable, may be nil) receives its
|
|
37
|
+
# { 'reason', 'action' } hash, then a fresh connection starts without +Last-Event-ID+. Stop by leaving the
|
|
38
|
+
# +each+ block (break) or by taking a finite number (+first(n)+).
|
|
39
|
+
def subscribe(topic, filter: nil, on_resync: nil)
|
|
18
40
|
slug = Studio.require_project_slug(@config, 'realtime')
|
|
19
41
|
q = { 'topic' => topic }
|
|
20
42
|
q['filter'] = JSON.dump(filter) if filter
|
|
21
|
-
|
|
22
|
-
|
|
43
|
+
feed(UrlGrammar.project_path(slug, 'realtime/subscribe'), q, nil, on_resync)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST /@<slug>/realtime/replay: a batch of past events for +topic+ (required, the same topic strings as
|
|
47
|
+
# #subscribe, 1 to 200 characters). +from_sequence+ and +until_sequence+ (whole numbers, 0 or more) bound the
|
|
48
|
+
# range; +limit+ (1 to 10,000) caps the batch, and the gateway's own default applies when omitted.
|
|
49
|
+
#
|
|
50
|
+
# Answers an Envelope whose +data+ is { "events" => [event], "nextCursor", "hasMore" } and, when the gateway
|
|
51
|
+
# says the topic has no stored history, "resync" => "reread-source" (re-read the current state instead). Each
|
|
52
|
+
# event is a Hash as the gateway sent it, in the spelling #subscribe yields ("topic", "eventType", "payload",
|
|
53
|
+
# "occurredAt", "eventId"; an event from the audit log also carries "sequence"). A 4xx raises K9o::KError.
|
|
54
|
+
def replay(topic, from_sequence: nil, until_sequence: nil, limit: nil)
|
|
55
|
+
unless topic.is_a?(String) && !topic.empty? && topic.length <= 200
|
|
56
|
+
raise ApprovalChecks.invalid('topic must be a realtime topic string of 1 to 200 characters', topic: topic)
|
|
57
|
+
end
|
|
58
|
+
{ from_sequence: from_sequence, until_sequence: until_sequence }.each do |name, value|
|
|
59
|
+
next if value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
60
|
+
|
|
61
|
+
raise ApprovalChecks.invalid("#{name} must be a whole number, 0 or more", name => value)
|
|
62
|
+
end
|
|
63
|
+
unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 10_000)
|
|
64
|
+
raise ApprovalChecks.invalid('limit must be a whole number from 1 to 10000', limit: limit)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
body = { 'topic' => topic }
|
|
68
|
+
body['fromSequence'] = from_sequence unless from_sequence.nil?
|
|
69
|
+
body['untilSequence'] = until_sequence unless until_sequence.nil?
|
|
70
|
+
body['limit'] = limit unless limit.nil?
|
|
71
|
+
slug = Studio.require_project_slug(@config, 'realtime')
|
|
72
|
+
envelope = HttpTransport.request(config: @config, method: 'POST',
|
|
73
|
+
path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
|
|
74
|
+
Envelope.new(data: replay_page(envelope.data), meta: envelope.meta)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The reconnecting reader behind #subscribe, Metadata#subscribe, Rollups#subscribe and Audit#tail.
|
|
78
|
+
# +initial_query+ is added to the query of the first connection only, and only while the feed has no position of
|
|
79
|
+
# its own.
|
|
80
|
+
#
|
|
81
|
+
# Frame options. By default a frame's JSON data is the event: +type_key+ is the field it must carry as a String
|
|
82
|
+
# to count (nil accepts any JSON object) and +id_key+ names the event (the frame's SSE +id+ when absent).
|
|
83
|
+
# A feed whose frames wrap the event passes +decode+, a callable taking the parsed data and the frame
|
|
84
|
+
# ({ event:, id:, data:, retry: }) and answering the event Hash to deliver, or nil to skip the frame.
|
|
85
|
+
# +keys+ (callable taking the event and the frame, or nil for a caught-up event) answers the strings that identify
|
|
86
|
+
# the event for de-duplication; +cursor+ (same arguments) answers the value sent as +Last-Event-ID+ when the
|
|
87
|
+
# connection is reopened (default: the first key). +accept+ (a callable) drops events after they have been counted
|
|
88
|
+
# as delivered. +catch_up+ (a callable taking the resync hash and the last delivered event, or nil, and answering an
|
|
89
|
+
# Array of events) is called on a resync instead of clearing the memory of delivered events. The feed first opens a
|
|
90
|
+
# fresh connection (no +Last-Event-ID+) and buffers its frames, then calls +catch_up+ (only when an event had been
|
|
91
|
+
# delivered), yields its events, then the buffered and live frames, all de-duplicated through +keys+; an event
|
|
92
|
+
# published while +catch_up+ ran is on the fresh connection, so nothing is lost. A fresh connection that is itself
|
|
93
|
+
# answered with a resync backs off before the next attempt. A resync that is the first
|
|
94
|
+
# data of a connection opened with no position (neither +Last-Event-ID+ nor the +initial_query+ start position) is informational and ignored (the stream goes on); this is the
|
|
95
|
+
# default (+fresh_head_resync: true+). A resync after data, or on a connection that sent a position, is a lost position.
|
|
96
|
+
def feed(route, q, initial_query, on_resync, type_key: 'eventType', id_key: 'eventId', accept: nil, catch_up: nil,
|
|
97
|
+
decode: nil, keys: nil, cursor: nil, fresh_head_resync: true)
|
|
98
|
+
keys ||= lambda do |event, frame|
|
|
99
|
+
id = event[id_key].is_a?(String) ? event[id_key] : (frame && frame[:id])
|
|
100
|
+
id ? [id] : []
|
|
101
|
+
end
|
|
102
|
+
remember = lambda do |seen, ks|
|
|
103
|
+
ks.each do |k|
|
|
104
|
+
seen[k] = true
|
|
105
|
+
seen.shift while seen.size > SEEN_LIMIT
|
|
106
|
+
end
|
|
107
|
+
end
|
|
23
108
|
Enumerator.new do |yielder|
|
|
24
|
-
|
|
25
|
-
|
|
109
|
+
use_initial = true
|
|
110
|
+
last_id = nil
|
|
111
|
+
last_event = nil
|
|
112
|
+
seen = {}
|
|
113
|
+
retry_ms = DEFAULT_RETRY_MS
|
|
114
|
+
failures = 0
|
|
115
|
+
resyncs = 0
|
|
116
|
+
buffered = nil
|
|
117
|
+
loop do
|
|
118
|
+
headers = last_id ? { 'Last-Event-ID' => last_id } : nil
|
|
119
|
+
resync_info = nil
|
|
120
|
+
failed = false
|
|
121
|
+
got_event = false
|
|
122
|
+
seen_resync = false
|
|
123
|
+
sent_position = !last_id.nil?
|
|
124
|
+
begin
|
|
125
|
+
query = use_initial && last_id.nil? && initial_query ? q.merge(initial_query) : q
|
|
126
|
+
sent_position ||= !query.equal?(q)
|
|
127
|
+
use_initial = false
|
|
128
|
+
frames = buffered || HttpTransport.stream_sse(config: @config, method: 'GET', path: route, query: query,
|
|
129
|
+
headers: headers, idle_timeout: self.class.idle_timeout, raw: true)
|
|
130
|
+
buffered = nil
|
|
131
|
+
catch(:resync) do
|
|
132
|
+
frames.each do |frame|
|
|
133
|
+
retry_ms = frame[:retry] if frame[:retry]
|
|
134
|
+
next if frame[:data].nil? && frame[:event].nil?
|
|
135
|
+
|
|
136
|
+
if frame[:event] == 'resync'
|
|
137
|
+
# Informational: a fresh connection (no Last-Event-ID sent) opens with the live head's resync;
|
|
138
|
+
# events follow on the same stream, so keep reading it.
|
|
139
|
+
if fresh_head_resync && !sent_position && !got_event && !seen_resync
|
|
140
|
+
seen_resync = true
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
info = parse(frame[:data])
|
|
145
|
+
resync_info = info.is_a?(Hash) ? info : {}
|
|
146
|
+
throw :resync
|
|
147
|
+
end
|
|
148
|
+
data = parse(frame[:data])
|
|
149
|
+
if decode
|
|
150
|
+
event = decode.call(data, frame)
|
|
151
|
+
next unless event.is_a?(Hash)
|
|
152
|
+
else
|
|
153
|
+
event = data
|
|
154
|
+
next unless event.is_a?(Hash) && (type_key.nil? || event[type_key].is_a?(String))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
failures = 0
|
|
158
|
+
resyncs = 0
|
|
159
|
+
got_event = true
|
|
160
|
+
ks = keys.call(event, frame)
|
|
161
|
+
next if ks.any? { |k| seen.key?(k) }
|
|
162
|
+
|
|
163
|
+
remember.call(seen, ks)
|
|
164
|
+
pos = cursor ? cursor.call(event, frame) : ks.first
|
|
165
|
+
last_id = pos if pos
|
|
166
|
+
last_event = event
|
|
167
|
+
yielder << event if accept.nil? || accept.call(event)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
rescue KError => e
|
|
171
|
+
raise if e.status && e.status < 500
|
|
172
|
+
|
|
173
|
+
failed = true
|
|
174
|
+
ensure
|
|
175
|
+
frames.close if frames.respond_to?(:close)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if resync_info
|
|
179
|
+
failures = 0
|
|
180
|
+
use_initial = false
|
|
181
|
+
if catch_up
|
|
182
|
+
resyncs += 1
|
|
183
|
+
last_id = nil
|
|
184
|
+
delay = resyncs >= 2 ? [retry_ms / 1000.0 * (2**(resyncs - 2)), self.class.max_backoff].min : retry_ms / 1000.0
|
|
185
|
+
self.class.sleeper.call(delay) if delay.positive?
|
|
186
|
+
if last_event
|
|
187
|
+
buffered = BufferedStream.new(HttpTransport.stream_sse(config: @config, method: 'GET', path: route,
|
|
188
|
+
query: q, idle_timeout: self.class.idle_timeout,
|
|
189
|
+
raw: true))
|
|
190
|
+
begin
|
|
191
|
+
buffered.wait_open
|
|
192
|
+
Array(catch_up.call(resync_info, last_event)).each do |event|
|
|
193
|
+
next unless event.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
ks = keys.call(event, nil)
|
|
196
|
+
next if ks.any? { |k| seen.key?(k) }
|
|
197
|
+
|
|
198
|
+
remember.call(seen, ks)
|
|
199
|
+
last_event = event
|
|
200
|
+
yielder << event if accept.nil? || accept.call(event)
|
|
201
|
+
end
|
|
202
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
203
|
+
buffered.close
|
|
204
|
+
buffered = nil
|
|
205
|
+
raise
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
on_resync&.call(resync_info)
|
|
209
|
+
next
|
|
210
|
+
end
|
|
211
|
+
last_id = nil
|
|
212
|
+
last_event = nil
|
|
213
|
+
seen.clear
|
|
214
|
+
on_resync&.call(resync_info)
|
|
215
|
+
next
|
|
216
|
+
end
|
|
217
|
+
failures += 1 if failed
|
|
218
|
+
delay = failed ? [retry_ms / 1000.0 * (2**(failures - 1)), self.class.max_backoff].min : retry_ms / 1000.0
|
|
219
|
+
self.class.sleeper.call(delay) if delay.positive?
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Reads an SSE frame enumerator on a thread into a queue, so the connection stays open (and its frames are kept)
|
|
225
|
+
# while something else runs. +each+ replays the kept frames, then the live ones; a failure is raised from +each+.
|
|
226
|
+
class BufferedStream
|
|
227
|
+
def initialize(source)
|
|
228
|
+
@queue = Queue.new
|
|
229
|
+
@head = nil
|
|
230
|
+
@thread = Thread.new do
|
|
231
|
+
begin
|
|
232
|
+
source.each { |frame| @queue << [:frame, frame] }
|
|
233
|
+
@queue << [:end]
|
|
234
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
235
|
+
@queue << [:error, e]
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
@thread.report_on_exception = false
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Blocks until the connection answered (first frame), failed or ended.
|
|
242
|
+
def wait_open
|
|
243
|
+
@head ||= @queue.pop
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def each
|
|
247
|
+
loop do
|
|
248
|
+
msg = @head || @queue.pop
|
|
249
|
+
@head = nil
|
|
250
|
+
case msg.first
|
|
251
|
+
when :frame then yield msg[1]
|
|
252
|
+
when :error then raise msg[1]
|
|
253
|
+
else break
|
|
254
|
+
end
|
|
26
255
|
end
|
|
27
256
|
end
|
|
257
|
+
|
|
258
|
+
def close
|
|
259
|
+
@thread.kill
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
def replay_page(data)
|
|
266
|
+
data = {} unless data.is_a?(Hash)
|
|
267
|
+
page = { 'events' => Array(data['events']).select { |e| e.is_a?(Hash) },
|
|
268
|
+
'nextCursor' => data['nextCursor'], 'hasMore' => data['hasMore'] == true }
|
|
269
|
+
page['resync'] = data['resync'] if data['resync'].is_a?(String)
|
|
270
|
+
page
|
|
28
271
|
end
|
|
29
272
|
|
|
273
|
+
def parse(text)
|
|
274
|
+
JSON.parse(text.to_s)
|
|
275
|
+
rescue JSON::ParserError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
public
|
|
280
|
+
|
|
30
281
|
module Topic
|
|
31
282
|
module_function
|
|
32
283
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Schedules: k9o.schedules.* run a function on a timetable (cron). They need the project admin role to change.
|
|
5
|
+
#
|
|
6
|
+
# list(status:, function_name:, limit:, cursor:) GET /@<slug>/forge/schedules
|
|
7
|
+
# get(id) GET /@<slug>/forge/schedules/<id>
|
|
8
|
+
# create(function_name:, schedule_expression:, ...) POST /@<slug>/forge/schedules
|
|
9
|
+
# update(id, **fields) PATCH /@<slug>/forge/schedules/<id>
|
|
10
|
+
# delete(id) DELETE /@<slug>/forge/schedules/<id> (204, no body)
|
|
11
|
+
# runs(id, limit:, cursor:) GET /@<slug>/forge/schedules/<id>/runs
|
|
12
|
+
# run_now(id) POST /@<slug>/forge/schedules/<id>/run-now (202)
|
|
13
|
+
#
|
|
14
|
+
# Lists are cursor-paginated, newest first: pass +meta.cursor.next+ of one page as +cursor:+ of the next, until it
|
|
15
|
+
# is nil. A schedule interval under 5 minutes is rejected with 400 K9O_INVALID_FIELD_VALUE (field schedule_expression), on every plan.
|
|
16
|
+
class Schedules
|
|
17
|
+
CATCHUP_POLICIES = %w[skip run_once run_all].freeze
|
|
18
|
+
STATUSES = %w[active paused].freeze
|
|
19
|
+
UPDATE_FIELDS = %i[schedule_expression schedule_timezone catchup_policy status enabled log_ok_invocations].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Schedules, newest first. +status+ is active or paused; +function_name+ keeps one function's schedules;
|
|
26
|
+
# +limit+ is 1 to 200 (the gateway defaults to 50).
|
|
27
|
+
def list(status: nil, function_name: nil, limit: nil, cursor: nil)
|
|
28
|
+
ApprovalChecks.check_limit(limit)
|
|
29
|
+
query = {}
|
|
30
|
+
query['status'] = ApprovalChecks.enum!(status, STATUSES, 'status') unless status.nil?
|
|
31
|
+
query['function_name'] = function_name unless function_name.nil?
|
|
32
|
+
query['limit'] = limit unless limit.nil?
|
|
33
|
+
query['cursor'] = cursor unless cursor.nil?
|
|
34
|
+
call('GET', path, query: query)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get(id)
|
|
38
|
+
call('GET', path(one(id)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create a schedule; answers 201 with the schedule. +schedule_expression+ is a five-field cron or one of @yearly,
|
|
42
|
+
# @annually, @monthly, @weekly, @daily, @midnight, @hourly (stored and returned expanded). +schedule_timezone+
|
|
43
|
+
# defaults to UTC; +catchup_policy+ to skip.
|
|
44
|
+
def create(function_name:, schedule_expression:, schedule_timezone: nil, catchup_policy: nil, collection: nil,
|
|
45
|
+
enabled: nil, log_ok_invocations: nil)
|
|
46
|
+
body = { 'functionName' => function_name, 'scheduleExpression' => schedule_expression }
|
|
47
|
+
body['scheduleTimezone'] = schedule_timezone unless schedule_timezone.nil?
|
|
48
|
+
body['catchupPolicy'] = ApprovalChecks.enum!(catchup_policy, CATCHUP_POLICIES, 'catchup_policy') unless catchup_policy.nil?
|
|
49
|
+
body['collection'] = collection unless collection.nil?
|
|
50
|
+
body['enabled'] = enabled unless enabled.nil?
|
|
51
|
+
body['logOkInvocations'] = log_ok_invocations unless log_ok_invocations.nil?
|
|
52
|
+
call('POST', path, body: body)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Change a schedule; send at least one of +schedule_expression+, +schedule_timezone+, +catchup_policy+,
|
|
56
|
+
# +status+ ('paused' stops it, 'active' resumes it), +enabled+, +log_ok_invocations+. The function cannot change.
|
|
57
|
+
def update(id, **fields)
|
|
58
|
+
unknown = fields.keys - UPDATE_FIELDS
|
|
59
|
+
raise ApprovalChecks.invalid("unknown field: #{unknown.join(', ')}") unless unknown.empty?
|
|
60
|
+
raise ApprovalChecks.invalid("patch needs at least one of: #{UPDATE_FIELDS.join(', ')}") if fields.empty?
|
|
61
|
+
|
|
62
|
+
seg = one(id)
|
|
63
|
+
body = {}
|
|
64
|
+
body['scheduleExpression'] = fields[:schedule_expression] if fields.key?(:schedule_expression)
|
|
65
|
+
body['scheduleTimezone'] = fields[:schedule_timezone] if fields.key?(:schedule_timezone)
|
|
66
|
+
if fields.key?(:catchup_policy)
|
|
67
|
+
body['catchupPolicy'] = ApprovalChecks.enum!(fields[:catchup_policy], CATCHUP_POLICIES, 'catchup_policy')
|
|
68
|
+
end
|
|
69
|
+
body['status'] = ApprovalChecks.enum!(fields[:status], STATUSES, 'status') if fields.key?(:status)
|
|
70
|
+
body['enabled'] = fields[:enabled] if fields.key?(:enabled)
|
|
71
|
+
body['logOkInvocations'] = fields[:log_ok_invocations] if fields.key?(:log_ok_invocations)
|
|
72
|
+
call('PATCH', path(seg), body: body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Delete a schedule. Answers 204, so +data+ is nil.
|
|
76
|
+
def delete(id)
|
|
77
|
+
call('DELETE', path(one(id)))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The schedule's runs, newest first, one page at a time.
|
|
81
|
+
def runs(id, limit: nil, cursor: nil)
|
|
82
|
+
ApprovalChecks.check_limit(limit)
|
|
83
|
+
seg = one(id)
|
|
84
|
+
query = {}
|
|
85
|
+
query['limit'] = limit unless limit.nil?
|
|
86
|
+
query['cursor'] = cursor unless cursor.nil?
|
|
87
|
+
call('GET', path("#{seg}/runs"), query: query)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Start one run now, outside the timetable. Answers 202; +data+ is the body exactly as the gateway sent it.
|
|
91
|
+
def run_now(id)
|
|
92
|
+
call('POST', path("#{one(id)}/run-now"))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def one(id)
|
|
98
|
+
"/#{ApprovalChecks.id_segment(id, 'schedule')}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def call(method, route, body: nil, query: nil)
|
|
102
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body, query: query)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def path(tail = '')
|
|
106
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'schedules'), "forge/schedules#{tail}")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|