k9o 1.4.0 → 1.5.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.
@@ -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 serves no WebSocket upgrade and no durable
7
- # replay, so neither is offered; after an `event: resync` frame the caller re-reads current state from the data
8
- # plane and resubscribes. #subscribe returns an Enumerator iterated via `.each`.
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). Control frames such as `resync`
16
- # carry no +eventType+ and are skipped.
17
- def subscribe(topic, filter: nil)
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
- frames = HttpTransport.stream_sse(config: @config, method: 'GET',
22
- path: UrlGrammar.project_path(slug, 'realtime/subscribe'), query: q)
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
- frames.each do |event|
25
- yielder << event if event.is_a?(Hash) && event['eventType'].is_a?(String)
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. An interval under 5 minutes, or +catchup_policy: 'run_all'+, can be refused with 403 K9O_TIER_FEATURE_GATED.
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