k9o 1.1.1-aarch64-linux → 1.5.0-aarch64-linux
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 +118 -0
- data/README.md +1573 -176
- data/k9o.gemspec +2 -2
- data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
- data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +253 -0
- data/lib/k9o/audit.rb +162 -14
- data/lib/k9o/client.rb +28 -9
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +125 -5
- 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 +127 -9
- data/lib/k9o/gate.rb +52 -22
- data/lib/k9o/http_transport.rb +78 -16
- 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.rb +3 -3
- 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 +27 -87
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -32
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +79 -84
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +17 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +71 -0
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +9 -6
- 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/query.rbs +26 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/region.rbs +14 -3
- data/sig/k9o/workload.rbs +64 -0
- metadata +33 -4
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Shared argument checks for the approvals, approval policy and schema namespaces. A bad value raises
|
|
5
|
+
# K9o::KError K9O_INVALID_FIELD_VALUE before any request is sent.
|
|
6
|
+
module ApprovalChecks
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def invalid(message, context = {})
|
|
10
|
+
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def check_limit(limit)
|
|
14
|
+
return if limit.nil?
|
|
15
|
+
return if limit.is_a?(Integer) && limit >= 1 && limit <= 200
|
|
16
|
+
|
|
17
|
+
raise invalid('limit must be a whole number from 1 to 200', limit: limit)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def id_segment(value, what)
|
|
21
|
+
raise invalid("#{what} id must be a non-empty string") unless value.is_a?(String) && !value.empty?
|
|
22
|
+
|
|
23
|
+
::URI.encode_www_form_component(value).gsub('+', '%20')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reason!(reason)
|
|
27
|
+
raise invalid('reason is required and must be a non-empty string') unless reason.is_a?(String) && !reason.strip.empty?
|
|
28
|
+
|
|
29
|
+
reason
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def predicate!(value, field)
|
|
33
|
+
raise invalid("#{field} must be a predicate object") unless value.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enum!(value, allowed, field)
|
|
39
|
+
text = value.is_a?(Symbol) ? value.to_s : value
|
|
40
|
+
return text if allowed.include?(text)
|
|
41
|
+
|
|
42
|
+
raise invalid("#{field} must be #{allowed.map { |a| "\"#{a}\"" }.join(', ')}", field.to_sym => value)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when a single-record write sent with +require_approval+ was queued for approval (HTTP 202) instead of
|
|
47
|
+
# applied. The queued answer carries +data+ = { 'id' => 'apr_...', 'status' => 'pending_approval', 'collection',
|
|
48
|
+
# 'operation', 'makerId', 'proposedPayload', 'targetRecordId' } and +meta.approval+ = { 'status' =>
|
|
49
|
+
# 'pending_approval' }; an +auto_approve+ policy applies the write and answers the plain row instead.
|
|
50
|
+
# Read the queued change in full with +approvals.get(id)+.
|
|
51
|
+
def self.queued_write?(result)
|
|
52
|
+
return false unless result.respond_to?(:data) && result.respond_to?(:meta)
|
|
53
|
+
|
|
54
|
+
approval = result.meta.respond_to?(:approval) ? result.meta.approval : nil
|
|
55
|
+
data = result.data
|
|
56
|
+
approval.is_a?(Hash) && approval['status'] == 'pending_approval' &&
|
|
57
|
+
data.is_a?(Hash) && data['status'] == 'pending_approval'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Pending-change queue: k9o.approvals.*
|
|
61
|
+
#
|
|
62
|
+
# list(status:, limit:, cursor:) GET /@<slug>/pending-approvals
|
|
63
|
+
# get(id) GET /@<slug>/pending-approvals/<id>
|
|
64
|
+
# approve(id, reason:) POST /@<slug>/pending-approvals/<id>/approve
|
|
65
|
+
# reject(id, reason:) POST /@<slug>/pending-approvals/<id>/reject
|
|
66
|
+
# bulk(action:, criteria:, reason:) POST /@<slug>/pending-approvals/bulk
|
|
67
|
+
#
|
|
68
|
+
# +list+ returns one page, newest first: +limit+ changes (1 to 200, default 50). While more remain,
|
|
69
|
+
# +meta.cursor.next+ is a string to pass back as +cursor:+; on the last page it is +nil+. +approve+ can answer 200
|
|
70
|
+
# with +status+ still +pending_approval+: the approval is recorded and more approvers are needed. +bulk+ answers
|
|
71
|
+
# HTTP 200 even when rows fail; read +failed+ and compare +affectedIds+ with what you intended.
|
|
72
|
+
class Approvals
|
|
73
|
+
# The comment thread on a pending change: +approvals.comments+.
|
|
74
|
+
attr_reader :comments
|
|
75
|
+
|
|
76
|
+
def initialize(config)
|
|
77
|
+
@config = config
|
|
78
|
+
@comments = ApprovalComments.new(config)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# One page of pending changes, newest first. +status+ is one of pending_approval, approved, rejected, expired or
|
|
82
|
+
# redeemed (omitted: all). Pass +meta.cursor.next+ from the previous page as +cursor+, with the same +status+
|
|
83
|
+
# and +limit+; an invalid cursor raises K9o::KError K9O_INVALID_CURSOR.
|
|
84
|
+
def list(status: nil, limit: nil, cursor: nil)
|
|
85
|
+
ApprovalChecks.check_limit(limit)
|
|
86
|
+
query = {}
|
|
87
|
+
query['status'] = status.to_s unless status.nil?
|
|
88
|
+
query['limit'] = limit unless limit.nil?
|
|
89
|
+
query['cursor'] = cursor.to_s unless cursor.nil?
|
|
90
|
+
call('GET', path, query: query)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# One pending change. Members who are not queue admins can read only their own.
|
|
94
|
+
def get(id)
|
|
95
|
+
call('GET', path("/#{ApprovalChecks.id_segment(id, 'approval')}"))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Approve a pending change. Read +status+ on the answer: +pending_approval+ means more approvers are needed.
|
|
99
|
+
def approve(id, reason: nil)
|
|
100
|
+
seg = ApprovalChecks.id_segment(id, 'approval')
|
|
101
|
+
call('POST', path("/#{seg}/approve"), body: reason.nil? ? {} : { 'reason' => reason })
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reject a pending change. +reason+ is required and must not be empty.
|
|
105
|
+
def reject(id, reason:)
|
|
106
|
+
seg = ApprovalChecks.id_segment(id, 'approval')
|
|
107
|
+
call('POST', path("/#{seg}/reject"), body: { 'reason' => ApprovalChecks.reason!(reason) })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Approve or reject every pending change matching +criteria+ (a predicate Hash). +reason+ is required to reject.
|
|
111
|
+
# The answer is HTTP 200 even when rows fail. +data+ holds +affectedIds+, +affectedCount+, +attestationId+,
|
|
112
|
+
# +action+, +reason+ and +failed+, a list of { 'id', 'code', 'reason' } (empty when none failed): compare
|
|
113
|
+
# +affectedIds+ with what you intended.
|
|
114
|
+
def bulk(action:, criteria:, reason: nil)
|
|
115
|
+
act = ApprovalChecks.enum!(action, %w[approve reject], 'action')
|
|
116
|
+
body = { 'action' => act, 'criteria' => ApprovalChecks.predicate!(criteria, 'criteria') }
|
|
117
|
+
if act == 'reject'
|
|
118
|
+
body['reason'] = ApprovalChecks.reason!(reason)
|
|
119
|
+
elsif !reason.nil?
|
|
120
|
+
body['reason'] = reason
|
|
121
|
+
end
|
|
122
|
+
res = call('POST', path('/bulk'), body: body)
|
|
123
|
+
res.data['failed'] = [] if res.data.is_a?(Hash) && !res.data['failed'].is_a?(Array)
|
|
124
|
+
res
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def call(method, route, body: nil, query: nil)
|
|
130
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body, query: query)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def path(tail = '')
|
|
134
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals#{tail}")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Comments on a pending change: k9o.approvals.comments.*
|
|
139
|
+
#
|
|
140
|
+
# add(pending_id, body:, visibility:) POST /@<slug>/pending-approvals/<pending_id>/comments
|
|
141
|
+
# list(pending_id, limit:, cursor:) GET /@<slug>/pending-approvals/<pending_id>/comments
|
|
142
|
+
#
|
|
143
|
+
# A comment is never a decision: it changes no status and counts toward no approval rule. It cannot be edited or
|
|
144
|
+
# deleted, so a correction is a new comment. Posting needs a session: a personal access token alone, or a service,
|
|
145
|
+
# workload or extension token, is refused with K9o::KError K9O_RBAC_DENY. Other errors: K9O_APPROVAL_COMMENT_NOT_ELIGIBLE
|
|
146
|
+
# (403, the caller may not post this comment), K9O_APPROVAL_COMMENT_CLOSED (409, the change is decided, expired or
|
|
147
|
+
# cancelled), K9O_INVALID_FIELD_VALUE (400) and K9O_NOT_FOUND (404, no such pending change).
|
|
148
|
+
class ApprovalComments
|
|
149
|
+
VISIBILITIES = %w[everyone approvers_only].freeze
|
|
150
|
+
BODY_MAX = 4000
|
|
151
|
+
|
|
152
|
+
# One page of comments: an Envelope whose +data+ is the Array of comments, oldest first, and whose +next_cursor+
|
|
153
|
+
# is the +cursor:+ of the next page, or +nil+ on the last page.
|
|
154
|
+
class Page < Envelope
|
|
155
|
+
attr_reader :next_cursor
|
|
156
|
+
|
|
157
|
+
def initialize(data:, meta:, next_cursor: nil)
|
|
158
|
+
super(data: data, meta: meta)
|
|
159
|
+
@next_cursor = next_cursor
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def initialize(config)
|
|
164
|
+
@config = config
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Post a comment. +body+ is plain text of 1 to 4000 characters. +visibility+ is required and fixed once posted:
|
|
168
|
+
# "everyone" reaches everyone who can read the change; "approvers_only" reaches only people eligible to decide it.
|
|
169
|
+
# Answers HTTP 201; +data+ is { 'id' (acm_...), 'caseId', 'authorId', 'visibility', 'body', 'createdAt' }.
|
|
170
|
+
def add(pending_id, body:, visibility:)
|
|
171
|
+
seg = ApprovalChecks.id_segment(pending_id, 'pending approval')
|
|
172
|
+
unless body.is_a?(String) && body.length >= 1 && body.length <= BODY_MAX
|
|
173
|
+
raise ApprovalChecks.invalid("body must be a string of 1 to #{BODY_MAX} characters")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
payload = { 'body' => body, 'visibility' => ApprovalChecks.enum!(visibility, VISIBILITIES, 'visibility') }
|
|
177
|
+
call('POST', path(seg), body: payload)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# One page of the comments the caller may see, oldest first (+limit+ 1 to 200, default 50). While more remain,
|
|
181
|
+
# +next_cursor+ is a string to pass back as +cursor:+; on the last page it is +nil+. An invalid cursor raises
|
|
182
|
+
# K9o::KError K9O_INVALID_CURSOR.
|
|
183
|
+
def list(pending_id, limit: nil, cursor: nil)
|
|
184
|
+
seg = ApprovalChecks.id_segment(pending_id, 'pending approval')
|
|
185
|
+
ApprovalChecks.check_limit(limit)
|
|
186
|
+
query = {}
|
|
187
|
+
query['limit'] = limit unless limit.nil?
|
|
188
|
+
query['cursor'] = cursor.to_s unless cursor.nil?
|
|
189
|
+
envelope = call('GET', path(seg), query: query, bare: true)
|
|
190
|
+
raw = envelope.data
|
|
191
|
+
data = raw.is_a?(Hash) && raw['data'].is_a?(Array) ? raw['data'] : []
|
|
192
|
+
Page.new(data: data, meta: envelope.meta, next_cursor: raw.is_a?(Hash) ? raw['nextCursor'] : nil)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def call(method, route, body: nil, query: nil, bare: false)
|
|
198
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body, query: query, bare: bare)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def path(seg)
|
|
202
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'approvals'), "pending-approvals/#{seg}/comments")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Approval policies: k9o.approval_policies.*
|
|
207
|
+
#
|
|
208
|
+
# list GET /@<slug>/approval-policies
|
|
209
|
+
# create(collection:, operation:, action:, predicate:) POST /@<slug>/approval-policies
|
|
210
|
+
# delete(id) DELETE /@<slug>/approval-policies/<id>
|
|
211
|
+
class ApprovalPolicies
|
|
212
|
+
COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
|
|
213
|
+
|
|
214
|
+
def initialize(config)
|
|
215
|
+
@config = config
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# All policies, oldest first. The first matching policy for a write decides.
|
|
219
|
+
def list
|
|
220
|
+
call('GET', path)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Create a policy. Identical policies are not de-duplicated.
|
|
224
|
+
def create(collection:, operation:, action:, predicate:)
|
|
225
|
+
unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
|
|
226
|
+
raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]{0,62}$', collection: collection)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
body = {
|
|
230
|
+
'collection' => collection,
|
|
231
|
+
'operation' => ApprovalChecks.enum!(operation, %w[insert update delete], 'operation'),
|
|
232
|
+
'action' => ApprovalChecks.enum!(action, %w[auto_approve queue], 'action'),
|
|
233
|
+
'predicate' => ApprovalChecks.predicate!(predicate, 'predicate')
|
|
234
|
+
}
|
|
235
|
+
call('POST', path, body: body)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Delete a policy; the answer is { 'id' => ... }.
|
|
239
|
+
def delete(id)
|
|
240
|
+
call('DELETE', path("/#{ApprovalChecks.id_segment(id, 'policy')}"))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def call(method, route, body: nil)
|
|
246
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def path(tail = '')
|
|
250
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'approval_policies'), "approval-policies#{tail}")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
data/lib/k9o/audit.rb
CHANGED
|
@@ -2,28 +2,176 @@
|
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
4
|
# Project-plane audit log namespace — k9o.audit.*.
|
|
5
|
-
# Distinct from k9o.org.audit_log
|
|
5
|
+
# Distinct from k9o.org.audit_log.
|
|
6
6
|
class Audit
|
|
7
|
+
TOPIC = 'audit.project'
|
|
8
|
+
TYPE_PATTERN = /\A[A-Za-z0-9_][A-Za-z0-9_.\-]*\z/.freeze
|
|
9
|
+
DEFAULT_REPLAY_LIMIT = 1000
|
|
10
|
+
EVENT_ID_PATTERN = /\Aevt_[0-9A-Za-z]{1,40}\z/.freeze
|
|
11
|
+
|
|
7
12
|
def initialize(config)
|
|
8
13
|
@config = config
|
|
9
14
|
end
|
|
10
15
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
# GET /@<slug>/audit-log — reverse-chronological; filters AND-compose. Requires the AUDIT_READER role.
|
|
17
|
+
# event_type: audit-event name prefix (e.g. 'data.write.access') actor_id: filter by actor
|
|
18
|
+
# after / before: ISO 8601 bounds (events after / before this instant)
|
|
19
|
+
# limit: page size (bounded server-side) cursor: from the prior response's meta.cursor.next
|
|
20
|
+
def query(event_type: nil, actor_id: nil, after: nil, before: nil, limit: nil, cursor: nil)
|
|
16
21
|
slug = Studio.require_project_slug(@config, 'audit')
|
|
17
22
|
q = {}
|
|
18
|
-
q['
|
|
19
|
-
q['
|
|
20
|
-
q['
|
|
21
|
-
q['
|
|
22
|
-
q['
|
|
23
|
-
q['
|
|
24
|
-
q['limit'] = limit if limit
|
|
25
|
-
q['cursor'] = cursor if cursor
|
|
23
|
+
q['eventType'] = event_type unless event_type.nil?
|
|
24
|
+
q['actorId'] = actor_id unless actor_id.nil?
|
|
25
|
+
q['after'] = after unless after.nil?
|
|
26
|
+
q['before'] = before unless before.nil?
|
|
27
|
+
q['limit'] = limit unless limit.nil?
|
|
28
|
+
q['cursor'] = cursor unless cursor.nil?
|
|
26
29
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'audit-log'), query: q)
|
|
27
30
|
end
|
|
31
|
+
|
|
32
|
+
# Live tail of the project's audit events: an Enumerator of { "auditEventId", "type", "actorType", "occurredAt",
|
|
33
|
+
# "outcome", "eventId" }. The first five are the audit event (read the full row with #query by event type and
|
|
34
|
+
# time); +eventId+ is the publisher's own id for the frame, which is not the +auditEventId+.
|
|
35
|
+
#
|
|
36
|
+
# +types+ is a list of audit event types: one type subscribes to +audit.project.<type>+, none to
|
|
37
|
+
# +audit.project.*+, several to +audit.project.*+ with the SDK keeping only those types. Needs ORG_OWNER,
|
|
38
|
+
# ORG_ADMIN, PROJECT_ADMIN or PROJECT_AUDITOR (re-checked every 60 seconds; others get 403 K9O_RBAC_DENY);
|
|
39
|
+
# extensions cannot subscribe. +warden.checkpoint+ events are not in the live tail: the audit log and #replay
|
|
40
|
+
# carry them.
|
|
41
|
+
#
|
|
42
|
+
# It is the same reconnecting reader as +realtime.subscribe+: it resumes with +Last-Event-ID+ (the frame's SSE
|
|
43
|
+
# +id+), drops events it already delivered (by the frame's +eventId+ and by +auditEventId+), ignores heartbeats
|
|
44
|
+
# and abandons a connection that has been silent for +Realtime.idle_timeout+ seconds. When the gateway says the
|
|
45
|
+
# position is lost (+event: resync+, action +replay-ledger+; audit streams cannot be resumed by sequence) the tail
|
|
46
|
+
# first reconnects fresh and buffers that connection's frames, then calls #replay after the last +auditEventId+ it
|
|
47
|
+
# delivered (page by page while there are more), yields the events it missed (they also carry +sequence+, and may
|
|
48
|
+
# include +warden.checkpoint+), calls +on_resync+ with the frame's hash, then yields the buffered and live frames,
|
|
49
|
+
# each event once. Nothing is lost: the connection is open before the replay runs. If it had delivered nothing yet
|
|
50
|
+
# it only reconnects (a new connection starts at the live head; a resync that opens a fresh connection is informational and ignored). Stop with +break+ or +first(n)+.
|
|
51
|
+
def tail(types: nil, on_resync: nil)
|
|
52
|
+
topic, keep = topic_for(types)
|
|
53
|
+
slug = Studio.require_project_slug(@config, 'audit')
|
|
54
|
+
catch_up = lambda do |_info, last_event|
|
|
55
|
+
last_event ? collect_replay(topic, last_event['auditEventId']) : []
|
|
56
|
+
end
|
|
57
|
+
decode = lambda do |data, frame|
|
|
58
|
+
payload = data.is_a?(Hash) ? data['payload'] : nil
|
|
59
|
+
next nil unless payload.is_a?(Hash) && payload['auditEventId'].is_a?(String)
|
|
60
|
+
|
|
61
|
+
event = {
|
|
62
|
+
'auditEventId' => payload['auditEventId'],
|
|
63
|
+
'type' => payload['type'].is_a?(String) ? payload['type'] : (data['eventType'] || frame[:event]),
|
|
64
|
+
'actorType' => payload['actorType'],
|
|
65
|
+
'occurredAt' => payload['occurredAt'],
|
|
66
|
+
'outcome' => payload['outcome']
|
|
67
|
+
}
|
|
68
|
+
event['eventId'] = data['eventId'] if data['eventId'].is_a?(String)
|
|
69
|
+
event
|
|
70
|
+
end
|
|
71
|
+
keys = lambda do |event, _frame|
|
|
72
|
+
[event['eventId'], event['auditEventId']].compact
|
|
73
|
+
end
|
|
74
|
+
cursor = lambda do |event, frame|
|
|
75
|
+
(frame && frame[:id]) || event['eventId']
|
|
76
|
+
end
|
|
77
|
+
Realtime.new(@config).feed(UrlGrammar.project_path(slug, 'realtime/subscribe'), { 'topic' => topic }, nil,
|
|
78
|
+
on_resync, decode: decode, keys: keys, cursor: cursor, accept: keep,
|
|
79
|
+
catch_up: catch_up, fresh_head_resync: true)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# POST /@<slug>/realtime/replay for the audit tail: a batch of past audit events in order, including
|
|
83
|
+
# +warden.checkpoint+. +topic+ is +audit.project.*+ (default) or +audit.project.<type>+. Give
|
|
84
|
+
# +after_audit_event_id+ (the last +auditEventId+ you received; unknown ids answer 400 K9O_INVALID_CURSOR) or
|
|
85
|
+
# +from_sequence+ (+after_audit_event_id+ wins when both are given); +until_sequence+ and +limit+ bound the batch (+limit+ is
|
|
86
|
+
# 1000 when omitted, up to 10,000). Needs the audit log's access.
|
|
87
|
+
#
|
|
88
|
+
# +data+ is the typed page { "events" => [event], "nextCursor", "hasMore" }; each event is { "auditEventId", "type",
|
|
89
|
+
# "actorType", "occurredAt", "outcome", "sequence" } (+sequence+ is a String, as the gateway sends it).
|
|
90
|
+
def replay(topic: "#{TOPIC}.*", after_audit_event_id: nil, from_sequence: nil, until_sequence: nil, limit: nil)
|
|
91
|
+
unless topic.is_a?(String) && (topic == TOPIC || topic.start_with?("#{TOPIC}."))
|
|
92
|
+
raise ApprovalChecks.invalid('topic must be audit.project.* or audit.project.<type>', topic: topic)
|
|
93
|
+
end
|
|
94
|
+
unless after_audit_event_id.nil? || (after_audit_event_id.is_a?(String) && EVENT_ID_PATTERN.match?(after_audit_event_id))
|
|
95
|
+
raise ApprovalChecks.invalid('after_audit_event_id must look like evt_<id>', after_audit_event_id: after_audit_event_id)
|
|
96
|
+
end
|
|
97
|
+
{from_sequence: from_sequence, until_sequence: until_sequence}.each do |name, value|
|
|
98
|
+
next if value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
99
|
+
|
|
100
|
+
raise ApprovalChecks.invalid("#{name} must be a whole number, 0 or more", name => value)
|
|
101
|
+
end
|
|
102
|
+
unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 10_000)
|
|
103
|
+
raise ApprovalChecks.invalid('limit must be a whole number from 1 to 10000', limit: limit)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
body = { 'topic' => topic }
|
|
107
|
+
body['afterAuditEventId'] = after_audit_event_id unless after_audit_event_id.nil?
|
|
108
|
+
body['fromSequence'] = from_sequence unless from_sequence.nil?
|
|
109
|
+
body['untilSequence'] = until_sequence unless until_sequence.nil?
|
|
110
|
+
body['limit'] = limit.nil? ? DEFAULT_REPLAY_LIMIT : limit
|
|
111
|
+
slug = Studio.require_project_slug(@config, 'audit')
|
|
112
|
+
envelope = HttpTransport.request(config: @config, method: 'POST',
|
|
113
|
+
path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
|
|
114
|
+
Envelope.new(data: replay_page(envelope.data), meta: envelope.meta)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# [topic, filter or nil]
|
|
120
|
+
def topic_for(types)
|
|
121
|
+
list = types.nil? ? [] : Array(types).map { |t| t.is_a?(Symbol) ? t.to_s : t }
|
|
122
|
+
list.each do |t|
|
|
123
|
+
unless t.is_a?(String) && TYPE_PATTERN.match?(t)
|
|
124
|
+
raise ApprovalChecks.invalid('types must be audit event type names such as "data.write.access"', type: t)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
list = list.uniq
|
|
128
|
+
return ["#{TOPIC}.*", nil] if list.empty?
|
|
129
|
+
return ["#{TOPIC}.#{list.first}", nil] if list.size == 1
|
|
130
|
+
|
|
131
|
+
["#{TOPIC}.*", ->(event) { list.include?(event['type']) }]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# The replay answer with its events flattened to the tail's shape (+sequence+ kept).
|
|
135
|
+
def replay_page(data)
|
|
136
|
+
data = {} unless data.is_a?(Hash)
|
|
137
|
+
events = Array(data['events']).map { |e| flatten_replayed(e) }.compact
|
|
138
|
+
{ 'events' => events, 'nextCursor' => data['nextCursor'], 'hasMore' => data['hasMore'] == true }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def flatten_replayed(raw)
|
|
142
|
+
return nil unless raw.is_a?(Hash)
|
|
143
|
+
|
|
144
|
+
payload = raw['payload'].is_a?(Hash) ? raw['payload'] : {}
|
|
145
|
+
return nil unless payload['auditEventId'].is_a?(String)
|
|
146
|
+
|
|
147
|
+
event = {
|
|
148
|
+
'auditEventId' => payload['auditEventId'],
|
|
149
|
+
'type' => payload['type'].is_a?(String) ? payload['type'] : raw['eventType'],
|
|
150
|
+
'actorType' => payload['actorType'],
|
|
151
|
+
'occurredAt' => payload['occurredAt'],
|
|
152
|
+
'outcome' => payload['outcome']
|
|
153
|
+
}
|
|
154
|
+
event['sequence'] = raw['sequence'] if raw.key?('sequence')
|
|
155
|
+
event
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Events after +last_id+ from the replay route, page after page while it says there are more. The route has no
|
|
159
|
+
# page cursor: each next page starts after the last event of the one before.
|
|
160
|
+
def collect_replay(topic, last_id)
|
|
161
|
+
out = []
|
|
162
|
+
cursor = last_id
|
|
163
|
+
loop do
|
|
164
|
+
data = replay(topic: topic, after_audit_event_id: cursor).data
|
|
165
|
+
events = data['events']
|
|
166
|
+
break if events.empty?
|
|
167
|
+
|
|
168
|
+
out.concat(events)
|
|
169
|
+
next_cursor = events.last['auditEventId']
|
|
170
|
+
break unless data['hasMore'] && next_cursor != cursor
|
|
171
|
+
|
|
172
|
+
cursor = next_cursor
|
|
173
|
+
end
|
|
174
|
+
out
|
|
175
|
+
end
|
|
28
176
|
end
|
|
29
177
|
end
|
data/lib/k9o/client.rb
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module K9o
|
|
4
4
|
# The customer-facing client returned by K9o.create_client.
|
|
5
5
|
class K9oClient
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include QueryMethods
|
|
7
|
+
|
|
8
|
+
attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :streams, :org,
|
|
9
|
+
:approvals, :approval_policies, :schedules, :vectors, :metadata, :rampart, :custody, :end_users, :schema, :orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage, :analytics, :rollups, :keys, :crypto
|
|
8
10
|
|
|
9
11
|
def initialize(config)
|
|
10
12
|
@config = config
|
|
@@ -13,6 +15,16 @@ module K9o
|
|
|
13
15
|
@studio = Studio.new(config)
|
|
14
16
|
@compute = Compute.new(config)
|
|
15
17
|
@realtime = Realtime.new(config)
|
|
18
|
+
@streams = Streams.new(config)
|
|
19
|
+
@approvals = Approvals.new(config)
|
|
20
|
+
@approval_policies = ApprovalPolicies.new(config)
|
|
21
|
+
@schedules = Schedules.new(config)
|
|
22
|
+
@vectors = Vectors.new(config)
|
|
23
|
+
@metadata = Metadata.new(config)
|
|
24
|
+
@rampart = Rampart.new(config)
|
|
25
|
+
@custody = Custody.new(config)
|
|
26
|
+
@end_users = EndUsers.new(config)
|
|
27
|
+
@schema = Schema.new(config)
|
|
16
28
|
@org = Org.new(config)
|
|
17
29
|
@orgs = Orgs.new(config)
|
|
18
30
|
@migrations = Migrations.new(config)
|
|
@@ -23,6 +35,10 @@ module K9o
|
|
|
23
35
|
@kibo = KiboNamespace.new(config)
|
|
24
36
|
@ai = Ai.new(config)
|
|
25
37
|
@storage = Storage.new(config)
|
|
38
|
+
@analytics = Analytics.new(config)
|
|
39
|
+
@rollups = Rollups.new(config)
|
|
40
|
+
@keys = Keys.new(config)
|
|
41
|
+
@crypto = Crypto.new(config)
|
|
26
42
|
end
|
|
27
43
|
|
|
28
44
|
def from(collection)
|
|
@@ -36,23 +52,26 @@ module K9o
|
|
|
36
52
|
|
|
37
53
|
# Factory — construct a K9oClient bound to the supplied options.
|
|
38
54
|
#
|
|
39
|
-
# Per spec sample:
|
|
40
55
|
# k9o = K9o.create_client(
|
|
41
56
|
# api_key: ENV["K9O_API_KEY"],
|
|
42
57
|
# org_slug: "acme",
|
|
43
58
|
# project_slug: "member-portal",
|
|
44
59
|
# )
|
|
45
60
|
#
|
|
46
|
-
# base_url is OPTIONAL. When omitted it
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
61
|
+
# base_url is OPTIONAL. When omitted it defaults to the public-cloud platform host (a convenience only, never used
|
|
62
|
+
# to parse a host). When the host is a platform host (+api.<apex>+ or +api-staging.<apex>+, any apex) and org_slug
|
|
63
|
+
# is set, the org host +<org_slug>.<host>+ is composed. Any other host passes through unchanged.
|
|
64
|
+
#
|
|
65
|
+
# expected_key_refs is optional: an Array of the key identifiers (from your own cloud configuration) your BYOK or HYOK
|
|
66
|
+
# key may be. It is checked here (see Crypto.check_expected_key_refs) and handed to the client core on every crypto call.
|
|
67
|
+
def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
|
|
68
|
+
expected_key_refs: nil)
|
|
69
|
+
refs = Crypto.check_expected_key_refs(expected_key_refs)
|
|
51
70
|
normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
|
|
52
71
|
config = HttpClientConfig.new(
|
|
53
72
|
base_url: normalised,
|
|
54
73
|
api_key: api_key, org_slug: org_slug, project_slug: project_slug,
|
|
55
|
-
default_headers: default_headers, http_client: http_client
|
|
74
|
+
default_headers: default_headers, http_client: http_client, expected_key_refs: refs
|
|
56
75
|
)
|
|
57
76
|
K9oClient.new(config)
|
|
58
77
|
end
|
data/lib/k9o/compute.rb
CHANGED
|
@@ -4,30 +4,37 @@ module K9o
|
|
|
4
4
|
# Compute namespace (Compute LLMs registry).
|
|
5
5
|
# k9o.compute.llms.*
|
|
6
6
|
class Compute
|
|
7
|
-
#
|
|
7
|
+
# The curated model ids (closed enum).
|
|
8
8
|
LLM_MODEL_IDS = %w[
|
|
9
9
|
mistral-7b mistral-8x7b llama-3-8b llama-3-70b
|
|
10
10
|
claude-3-5-sonnet gpt-4o gpt-4o-mini
|
|
11
11
|
text-embedding-3-small text-embedding-3-large
|
|
12
12
|
].freeze
|
|
13
13
|
|
|
14
|
+
# Inference parameters the gateway reads (top-level body fields next to +prompt+). Snake_case spellings map to
|
|
15
|
+
# the gateway's.
|
|
16
|
+
PARAM_FIELDS = {
|
|
17
|
+
'max_tokens' => 'maxTokens', 'maxTokens' => 'maxTokens',
|
|
18
|
+
'temperature' => 'temperature',
|
|
19
|
+
'usage_type' => 'usageType', 'usageType' => 'usageType'
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
14
22
|
attr_reader :llms
|
|
15
23
|
|
|
16
24
|
def initialize(config)
|
|
17
25
|
@llms = Llms.new(config)
|
|
18
26
|
end
|
|
19
27
|
|
|
20
|
-
# k9o.compute.llms
|
|
28
|
+
# k9o.compute.llms.*
|
|
21
29
|
class Llms
|
|
22
30
|
def initialize(config)
|
|
23
31
|
@config = config
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
# GET /@<slug>/compute/llms
|
|
35
|
+
def list
|
|
27
36
|
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
28
|
-
|
|
29
|
-
HttpTransport.request(config: @config, method: 'GET',
|
|
30
|
-
path: UrlGrammar.project_path(slug, 'compute/llms'), query: q)
|
|
37
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'compute/llms'))
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
def read(model_id)
|
|
@@ -36,29 +43,39 @@ module K9o
|
|
|
36
43
|
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}"))
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
# Synchronous inference
|
|
46
|
+
# Synchronous inference: POST /@<slug>/compute/llms/<id>/invoke { prompt, maxTokens?, temperature?,
|
|
47
|
+
# usageType? }. +params+ takes max_tokens / temperature / usage_type (the gateway's spellings also work).
|
|
48
|
+
# The envelope's data is { 'modelId', 'content', 'finishReason', 'usage' => { 'promptTokens',
|
|
49
|
+
# 'completionTokens', 'totalTokens' }, 'usageType' }.
|
|
40
50
|
def invoke(model_id, prompt:, params: nil)
|
|
41
51
|
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
42
|
-
body = { 'prompt' => prompt }
|
|
43
|
-
body['params'] = params if params
|
|
44
52
|
HttpTransport.request(config: @config, method: 'POST',
|
|
45
|
-
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"),
|
|
53
|
+
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"),
|
|
54
|
+
body: invoke_body(prompt, params))
|
|
46
55
|
end
|
|
47
56
|
|
|
48
|
-
# Streaming inference (SSE per-token). Returns an
|
|
49
|
-
# Enumerator of chunk hashes (Ruby idiom). Consume with
|
|
57
|
+
# Streaming inference (SSE per-token). Returns an Enumerator of chunk hashes. Consume with
|
|
50
58
|
# `.each { |chunk| ... }`.
|
|
51
59
|
def invoke_stream(model_id, prompt:, params: nil)
|
|
52
60
|
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
53
|
-
body = { 'prompt' => prompt }
|
|
54
|
-
body['params'] = params if params
|
|
55
61
|
HttpTransport.stream_sse(config: @config, method: 'POST',
|
|
56
62
|
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke-stream"),
|
|
57
|
-
body:
|
|
63
|
+
body: invoke_body(prompt, params))
|
|
58
64
|
end
|
|
59
65
|
|
|
60
66
|
private
|
|
61
67
|
|
|
68
|
+
def invoke_body(prompt, params)
|
|
69
|
+
body = { 'prompt' => prompt }
|
|
70
|
+
(params || {}).each do |key, value|
|
|
71
|
+
wire = PARAM_FIELDS[key.to_s]
|
|
72
|
+
raise ArgumentError, "unknown inference parameter: #{key}" if wire.nil?
|
|
73
|
+
|
|
74
|
+
body[wire] = value unless value.nil?
|
|
75
|
+
end
|
|
76
|
+
body
|
|
77
|
+
end
|
|
78
|
+
|
|
62
79
|
def enc(s)
|
|
63
80
|
::URI.encode_www_form_component(s)
|
|
64
81
|
end
|