kloudlite-sdk 0.2.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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/kloudlite.rb +1122 -0
  3. metadata +45 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fc50d6908b51dec0fbd96bcc62169ed1371cbf1530779f9a098dddd72f089bed
4
+ data.tar.gz: 65a92b28e7abc0443351b51d60bca6b35d31c19c41d4262ae08775e358ef238c
5
+ SHA512:
6
+ metadata.gz: 0aaba7a0ec7286682904258bc5045492182face99511a8b1121e62f6175b6178607eee26131eea54f7bf443314372a1c55e9166cf4d2baab36133622e98b6347
7
+ data.tar.gz: fb06db1c6ea5a6aea30d4f9d47b6528eee814fc2f049e0d1ee99c81ef8da9862c269e783e1c2530de2c7386e7a7f1fb2ecd5c54b1bc4ce7b40f1f28bdb949b0e
data/kloudlite.rb ADDED
@@ -0,0 +1,1122 @@
1
+ # Kloudlite SDK for Ruby — stdlib only (net/http + json). Needs Ruby 3.0 or later (endless
2
+ # methods); an older Ruby stops at the first such method with a SyntaxError.
3
+ #
4
+ # Auth is a Bearer token (a CLI token). Every module hangs off the one client:
5
+ #
6
+ # require_relative "kloudlite"
7
+ # kl = Kloudlite::Client.new # the login of `kloudlite`; see "Discovery" below
8
+ # workspaces = kl.workspaces.list
9
+ #
10
+ # Discovery: the API origin and the token each come from the first source that has one:
11
+ # 1. the keywords base_url: / token:; 2. the environment KLOUDLITE_URL / KLOUDLITE_TOKEN;
12
+ # 3. "api" / "token" in config.json in KLOUDLITE_CONFIG_DIR, else ~/.config/kloudlite. "/v1" is
13
+ # added to the origin. With no token the constructor raises RuntimeError carrying the sentence
14
+ # "no Kloudlite login: pass token=, set KLOUDLITE_TOKEN, or run kloudlite login".
15
+ #
16
+ # Every public method takes KEYWORD arguments, so no caller has to remember an order; a
17
+ # resource is always the keyword named after it (workspace:, environment:, session:,
18
+ # volume:, snapshot:, sandbox:, run:), and a new object made from an existing one is to:.
19
+
20
+ raise LoadError, "kloudlite.rb needs Ruby 3.0 or later, found #{RUBY_VERSION}" if RUBY_VERSION < "3.0"
21
+
22
+ require "json"
23
+ require "net/http"
24
+ require "timeout"
25
+ require "uri"
26
+
27
+ module Kloudlite
28
+ VERSION = "0.2.0"
29
+ USER_AGENT = "kloudlite-sdk-ruby/#{VERSION}".freeze
30
+ NO_LOGIN = "no Kloudlite login: pass token=, set KLOUDLITE_TOKEN, or run kloudlite login"
31
+
32
+ # A non-2xx answer. +status+ is the HTTP code, +message+ the server's text.
33
+ class ApiError < StandardError
34
+ attr_reader :status, :message
35
+
36
+ def initialize(status, message)
37
+ @status = status
38
+ @message = message
39
+ super("#{status}: #{message}")
40
+ end
41
+ end
42
+
43
+ # A sandbox that ended while the caller waited for it to run; +reason+ is the server's
44
+ # (Timeout, Idle, Unplaceable, …). An ApiError so one rescue covers both.
45
+ class SandboxTerminated < ApiError
46
+ attr_reader :reason
47
+
48
+ def initialize(sandbox, reason, detail)
49
+ @reason = reason
50
+ text = "sandbox #{sandbox} is terminated (#{reason || 'unknown'})"
51
+ text += ": #{detail}" unless detail.nil? || detail.to_s.empty?
52
+ super(409, text)
53
+ end
54
+ end
55
+
56
+ # A client-side wait ran out. A ::Timeout::Error, so a generic rescue still catches it.
57
+ class TimeoutError < ::Timeout::Error; end
58
+
59
+ # The naming rules every module shares. Mixed in privately; never part of the public surface.
60
+ module Names
61
+ private
62
+
63
+ # "name" → [nil, name]; "team/name" → [team, name]. Workspaces, environments and sessions
64
+ # send the pair as path segment +name+ plus ?owner=team, which the server folds back.
65
+ def owned(s)
66
+ s = s.to_s
67
+ i = s.rindex("/")
68
+ return [nil, s] if i.nil?
69
+ owner, name = s[0...i], s[(i + 1)..]
70
+ raise ArgumentError, "expected name or team/name, got #{s.inspect}" if owner.empty? || name.empty? || owner.include?("/")
71
+ [owner, name]
72
+ end
73
+
74
+ # Ruby 3.0 has no URI.encode_uri_component (3.2); this is the same escape.
75
+ def seg(s)
76
+ URI.encode_www_form_component(s.to_s).gsub("+", "%20")
77
+ end
78
+ end
79
+
80
+ class Client
81
+ attr_reader :workspaces, :environments, :sessions, :volumes,
82
+ :secrets, :connectors, :sandboxes, :pipelines
83
+
84
+ def initialize(base_url: nil, token: nil, timeout: 30)
85
+ base_url = presence(base_url) || presence(ENV["KLOUDLITE_URL"])
86
+ token = presence(token) || presence(ENV["KLOUDLITE_TOKEN"])
87
+ if base_url.nil? || token.nil?
88
+ cfg = login
89
+ base_url ||= presence(cfg["api"])
90
+ token ||= presence(cfg["token"])
91
+ end
92
+ raise RuntimeError, NO_LOGIN if token.nil?
93
+ raise RuntimeError, "no Kloudlite API URL: pass base_url=, set KLOUDLITE_URL, or run kloudlite login" if base_url.nil?
94
+
95
+ @base_url = base_url.sub(%r{/+\z}, "").sub(%r{/v1\z}, "") # paths carry /v1
96
+ @token = token
97
+ @timeout = timeout
98
+ @workspaces = Workspaces.new(self)
99
+ @environments = Environments.new(self)
100
+ @sessions = Sessions.new(self)
101
+ @volumes = Volumes.new(self)
102
+ @secrets = Secrets.new(self)
103
+ @connectors = Connectors.new(self)
104
+ @sandboxes = Sandboxes.new(self)
105
+ @pipelines = Pipelines.new(self)
106
+ end
107
+
108
+ # The ceiling and what is against it; +owner+ defaults to the caller's own.
109
+ def quota(owner: nil)
110
+ request("GET", "/v1/quota", query: { owner: owner })
111
+ end
112
+
113
+ # One HTTP round trip. Returns parsed JSON (or nil on an empty body);
114
+ # raises ApiError on any non-2xx status.
115
+ def request(method, path, body: nil, query: nil)
116
+ text = perform(method, path, body, query, "application/json", @timeout).body
117
+ text.nil? || text.empty? ? nil : JSON.parse(text)
118
+ end
119
+
120
+ # The server answers errors as {"error": …} or plain text; take either. Shared with Ide.
121
+ def self.error_text(raw)
122
+ text = (raw || "").strip
123
+ begin
124
+ parsed = JSON.parse(text)
125
+ return parsed["error"].to_s if parsed.is_a?(Hash) && parsed.key?("error")
126
+ rescue JSON::ParserError
127
+ # plain text
128
+ end
129
+ text.empty? ? "request failed" : text
130
+ end
131
+
132
+ private
133
+
134
+ def presence(v)
135
+ v.nil? || v.to_s.empty? ? nil : v.to_s
136
+ end
137
+
138
+ # config.json of the `kloudlite` login; {} when it is absent or unreadable.
139
+ def login
140
+ dir = presence(ENV["KLOUDLITE_CONFIG_DIR"]) || File.join(Dir.home, ".config", "kloudlite")
141
+ doc = JSON.parse(File.read(File.join(dir, "config.json")))
142
+ doc.is_a?(Hash) ? doc : {}
143
+ rescue SystemCallError, IOError, JSON::ParserError, ArgumentError
144
+ {}
145
+ end
146
+
147
+ # The raw Net::HTTP response of a 2xx; ApiError otherwise. The text, header-reading and
148
+ # no-timeout (+read_timeout+ nil) callers reach it through __send__ — not public surface.
149
+ def perform(method, path, body, query, accept, read_timeout)
150
+ uri = URI.parse(@base_url + path)
151
+ q = (query || {}).compact
152
+ uri.query = URI.encode_www_form(q) unless q.empty?
153
+ req = Net::HTTP.const_get(method.capitalize).new(uri)
154
+ req["User-Agent"] = USER_AGENT
155
+ req["Accept"] = accept
156
+ req["Authorization"] = "Bearer #{@token}"
157
+ unless body.nil?
158
+ req["Content-Type"] = "application/json"
159
+ req.body = JSON.generate(body)
160
+ end
161
+ resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
162
+ open_timeout: @timeout, read_timeout: read_timeout) { |http| http.request(req) }
163
+ raise ApiError.new(resp.code.to_i, self.class.error_text(resp.body)) unless resp.is_a?(Net::HTTPSuccess)
164
+ resp
165
+ end
166
+ end
167
+
168
+ # Btrfs-backed dev workspaces, over /v1/workspaces. A workspace is named like an environment —
169
+ # "name" for the caller's own, "team/name" for a team's — or by its id; pushes cut snapshots,
170
+ # kept on the workspace's VOLUME — see Volumes for history, restore targets and deletion.
171
+ class Workspaces
172
+ include Names
173
+
174
+ def initialize(client)
175
+ @c = client
176
+ end
177
+
178
+ # +workspace+ ("name" or "team/name") is split into the body's name and team. +packages+ are
179
+ # nixpkgs attributes, optionally pinned ("attr@version"). +ttl_secs+ (600..=2592000) deletes
180
+ # it after that long unused; absent, never. +env+ maps variables to literals or
181
+ # "${{ secrets.NAME }}" references. Unset keywords are omitted (202).
182
+ def create(workspace:, quota_gb:, image: nil, packages: nil, ttl_secs: nil, env: nil)
183
+ team, name = owned(workspace)
184
+ @c.request("POST", "/v1/workspaces",
185
+ body: { name: name, quota_gb: quota_gb, team: team, image: image,
186
+ packages: packages, ttl_secs: ttl_secs, env: env }.compact)
187
+ end
188
+
189
+ # The caller's workspaces; +team+ scopes to their workspaces in that team.
190
+ def list(team: nil)
191
+ @c.request("GET", "/v1/workspaces", query: { team: team })
192
+ end
193
+
194
+ def get(workspace:)
195
+ at("GET", workspace)
196
+ end
197
+
198
+ # Runs the worktree finalizer: the volume survives while a pushed snapshot references it.
199
+ def delete(workspace:)
200
+ at("DELETE", workspace)
201
+ end
202
+
203
+ def start(workspace:)
204
+ at("POST", workspace, "/start")
205
+ end
206
+
207
+ # A stop cuts a sync point and tears the workspace down — seconds, never waiting on a replica.
208
+ def stop(workspace:)
209
+ at("POST", workspace, "/stop")
210
+ end
211
+
212
+ # Restart a running workspace so it gets the current env (202); 409 when not running.
213
+ def restart(workspace:)
214
+ at("POST", workspace, "/restart")
215
+ end
216
+
217
+ # Push a SNAPSHOT of the working copy — the one durable, restorable state (202). +volume+
218
+ # names the volume and is required on its first push; +ttl_secs+ (3600..=31536000) or
219
+ # never: true set its expiry. +push+ is the old name.
220
+ def take_snapshot(workspace:, message: nil, volume: nil, ttl_secs: nil, never: nil)
221
+ at("POST", workspace, "/snapshots",
222
+ body: { message: message, volume: volume, ttl_secs: ttl_secs, never: never }.compact)
223
+ end
224
+ alias_method :push, :take_snapshot
225
+
226
+ # A new workspace +to+ from this one's state, cut at the moment of the request; the
227
+ # answer's +based_on+ names exactly what it grafted onto.
228
+ def clone(workspace:, to:, task: nil)
229
+ at("POST", workspace, "/clone", body: { name: to, task: task })
230
+ end
231
+
232
+ # A new workspace +to+ grafted onto a PUSHED +snapshot+ (a volume name, "name@N" or a snapshot
233
+ # id) — also one whose workspace is long gone. +team+ (or to: "team/name") is required for a
234
+ # team's volume. Absent overrides mean "whatever the snapshot froze".
235
+ def restore(snapshot:, to:, team: nil, image: nil, packages: nil, quota_gb: nil)
236
+ owner, name = owned(to)
237
+ @c.request("POST", "/v1/workspaces/restore",
238
+ body: { name: name, snapshot_id: snapshot, team: team || owner,
239
+ image: image, packages: packages, quota_gb: quota_gb }.compact)
240
+ end
241
+
242
+ # Replace the package list; pinned entries are locked before anything is written.
243
+ def set_packages(workspace:, packages:)
244
+ at("PATCH", workspace, body: { packages: packages })
245
+ end
246
+
247
+ # Seconds unused before the workspace is deleted (600..=2592000); nil clears it. Not for a
248
+ # sandbox or a bench.
249
+ def set_ttl(workspace:, ttl_secs:)
250
+ at("PUT", workspace, "/ttl", body: { ttl_secs: ttl_secs })
251
+ end
252
+
253
+ # Replace the whole env (literals or "${{ secrets.NAME }}" references); takes effect on the
254
+ # next start. A running workspace is only marked restart_required unless
255
+ # restart_on_env_change: true.
256
+ def set_env(workspace:, env:, restart_on_env_change: nil)
257
+ at("PUT", workspace, "/env", body: { env: env, restartOnEnvChange: restart_on_env_change }.compact)
258
+ end
259
+
260
+ # Re-resolve every pinned package — the only call that moves existing locks.
261
+ def update_packages(workspace:)
262
+ at("POST", workspace, "/packages/update")
263
+ end
264
+
265
+ # The workspace's tool-server address and token — answered to the owner alone.
266
+ def tools(workspace:)
267
+ at("GET", workspace, "/tools")
268
+ end
269
+
270
+ # An Ide bound to this workspace's tool server. The address is a private IP: reachable from
271
+ # inside Kloudlite (a bench); from a laptop, tunnel with `kl-connect ws ide` and use
272
+ # Ide.new(url: "http://127.0.0.1:7788", token: token) instead.
273
+ def ide(workspace:)
274
+ doc = tools(workspace: workspace)
275
+ Ide.new(url: "http://#{doc['address']}", token: doc["token"])
276
+ end
277
+
278
+ private
279
+
280
+ def at(method, workspace, tail = "", body: nil)
281
+ owner, name = owned(workspace)
282
+ @c.request(method, "/v1/workspaces/#{seg(name)}#{tail}", body: body, query: { owner: owner })
283
+ end
284
+ end
285
+
286
+ # A workspace's tool server (`kl ide serve`): the full tool set over plain HTTP — GET /tools
287
+ # lists every tool with its schema, POST /tools/{name} runs one. +call+ takes anything the
288
+ # listing names; nil arguments are dropped, so every optional means "the tool's own default".
289
+ class Ide
290
+ def initialize(url:, token: nil, timeout: 630)
291
+ @url = url.sub(%r{/+\z}, "")
292
+ @token = token
293
+ @timeout = timeout
294
+ end
295
+
296
+ # Every tool, with its JSON schema — the authority on arguments.
297
+ def list
298
+ http("GET", "/tools")
299
+ end
300
+
301
+ # +tool+ names it; every other keyword is the tool's own argument, sent as given.
302
+ def call(tool:, **args)
303
+ body = args.compact.transform_keys(&:to_s)
304
+ http("POST", "/tools/#{URI.encode_www_form_component(tool.to_s).gsub("+", "%20")}", body)
305
+ end
306
+
307
+ # files
308
+ def read(path: nil, paths: nil, offset: nil, limit: nil)
309
+ call(tool: "read", path: path, paths: paths, offset: offset, limit: limit)
310
+ end
311
+
312
+ def write(path:, content:)
313
+ call(tool: "write", path: path, content: content)
314
+ end
315
+
316
+ # One file as (path:, edits:), several as files: [{path:, edits:}]; each edit is
317
+ # {old:, new:, replace_all:?} and the whole call is atomic.
318
+ def edit(path: nil, edits: nil, files: nil)
319
+ call(tool: "edit", path: path, edits: edits, files: files)
320
+ end
321
+
322
+ def patch(diff:, cwd: nil)
323
+ call(tool: "patch", diff: diff, cwd: cwd)
324
+ end
325
+
326
+ def glob(pattern:, cwd: nil)
327
+ call(tool: "glob", pattern: pattern, cwd: cwd)
328
+ end
329
+
330
+ def grep(pattern:, cwd: nil, glob: nil, mode: nil, context: nil, ignore_case: nil, max: nil)
331
+ call(tool: "grep", pattern: pattern, cwd: cwd, glob: glob, mode: mode,
332
+ context: context, ignore_case: ignore_case, max: max)
333
+ end
334
+
335
+ # commands and processes — detach: true answers {id}, passed back as +process+
336
+ def exec(cmd:, cwd: nil, env: nil, timeout_ms: nil, head: nil, tail: nil, quiet: nil, detach: nil)
337
+ call(tool: "exec", cmd: cmd, cwd: cwd, env: env, timeout_ms: timeout_ms,
338
+ head: head, tail: tail, quiet: quiet, detach: detach)
339
+ end
340
+
341
+ def process_list
342
+ call(tool: "process_list")
343
+ end
344
+
345
+ def process_output(process:, since: nil, since_err: nil)
346
+ call(tool: "process_output", id: process, since: since, since_err: since_err)
347
+ end
348
+
349
+ def process_write(process:, data:)
350
+ call(tool: "process_write", id: process, data: data)
351
+ end
352
+
353
+ def process_kill(process:, signal: nil)
354
+ call(tool: "process_kill", id: process, signal: signal)
355
+ end
356
+
357
+ # watches — +watch+ answers {id}, passed back as +watch+
358
+ def watch(paths: nil, cmd: nil, pattern: nil, once: nil)
359
+ call(tool: "watch", paths: paths, cmd: cmd, pattern: pattern, once: once)
360
+ end
361
+
362
+ def watch_poll(watch:, since: nil)
363
+ call(tool: "watch_poll", id: watch, since: since)
364
+ end
365
+
366
+ def watch_stop(watch:)
367
+ call(tool: "watch_stop", id: watch)
368
+ end
369
+
370
+ # containers (the hidden per-owner builder)
371
+ def container_build(tags:, context: nil, file: nil, build_args: nil, platform: nil,
372
+ no_cache: nil, timeout_ms: nil, head: nil, tail: nil)
373
+ call(tool: "container_build", tags: tags, context: context, file: file, build_args: build_args,
374
+ platform: platform, no_cache: no_cache, timeout_ms: timeout_ms,
375
+ head: head, tail: tail)
376
+ end
377
+
378
+ def container_push(src:, dst:)
379
+ call(tool: "container_push", src: src, dst: dst)
380
+ end
381
+
382
+ private
383
+
384
+ def http(method, path, body = nil)
385
+ uri = URI.parse(@url + path)
386
+ req = Net::HTTP.const_get(method.capitalize).new(uri)
387
+ req["User-Agent"] = USER_AGENT
388
+ req["Accept"] = "application/json"
389
+ req["Authorization"] = "Bearer #{@token}" if @token
390
+ if body
391
+ req["Content-Type"] = "application/json"
392
+ req.body = JSON.generate(body)
393
+ end
394
+ resp = Net::HTTP.start(uri.hostname, uri.port,
395
+ use_ssl: uri.scheme == "https",
396
+ open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
397
+ raise ApiError.new(resp.code.to_i, Client.error_text(resp.body)) unless resp.is_a?(Net::HTTPSuccess)
398
+ resp.body.nil? || resp.body.empty? ? nil : JSON.parse(resp.body)
399
+ end
400
+ end
401
+
402
+ # Multi-service environments, over /v1/environments: a set of containers sharing one disk. An
403
+ # environment is named by ONE string — "name" for the caller's own, "team/name" for a team's —
404
+ # or by its id ("env-…"). Each service is a hash: +name+ and +image+, plus optional +ports+,
405
+ # +env+, +command+ and +mounts+ ([{ folder:, path: }], a folder of the environment's disk).
406
+ class Environments
407
+ include Names
408
+
409
+ def initialize(client)
410
+ @c = client
411
+ end
412
+
413
+ # +snapshot+ grows it from a pushed snapshot — also one of a deleted environment — and
414
+ # then +services+ default to the ones that snapshot froze. +ttl_secs+ (600..=2592000) deletes
415
+ # it after that long unused; absent, never.
416
+ def create(environment:, services: nil, quota_gb: nil, snapshot: nil, ttl_secs: nil)
417
+ owner, name = owned(environment)
418
+ @c.request("POST", "/v1/environments",
419
+ body: { name: name, owner: owner,
420
+ services: services.nil? || services.empty? ? nil : fill(services),
421
+ quota_gb: quota_gb, snapshot_id: snapshot, ttl_secs: ttl_secs }.compact)
422
+ end
423
+
424
+ # The caller's environments and their teams'; +owner+ narrows to one handle or team.
425
+ def list(owner: nil)
426
+ @c.request("GET", "/v1/environments", query: { owner: owner })
427
+ end
428
+
429
+ def get(environment:)
430
+ at("GET", environment)
431
+ end
432
+
433
+ # Replace the WHOLE service list. A removed service keeps its folders, so re-adding finds its
434
+ # data. A changed env only marks a running service restartRequired unless
435
+ # restart_on_env_change: true.
436
+ def set_services(environment:, services:, restart_on_env_change: nil)
437
+ at("PATCH", environment,
438
+ body: { services: fill(services), restartOnEnvChange: restart_on_env_change }.compact)
439
+ end
440
+
441
+ def start(environment:)
442
+ at("POST", environment, "/start")
443
+ end
444
+
445
+ def stop(environment:)
446
+ at("POST", environment, "/stop")
447
+ end
448
+
449
+ # Restart one service so it gets the current env (202); 409 when the environment is not running.
450
+ def restart(environment:, service:)
451
+ at("POST", environment, "/services/#{seg(service)}/restart")
452
+ end
453
+
454
+ # Seconds the environment may sit unused (600..=2592000) before it is deleted; nil clears.
455
+ def set_ttl(environment:, ttl_secs:)
456
+ at("PUT", environment, "/ttl", body: { ttl_secs: ttl_secs })
457
+ end
458
+
459
+ # The volume survives while a pushed snapshot references it.
460
+ def delete(environment:)
461
+ at("DELETE", environment)
462
+ end
463
+
464
+ # Push a SNAPSHOT of every service's data and the service list (202). +volume+ names the
465
+ # volume and is required on its first push. +push+ is the old name.
466
+ def take_snapshot(environment:, message: nil, volume: nil, ttl_secs: nil, never: nil)
467
+ at("POST", environment, "/snapshots",
468
+ body: { message: message, volume: volume, ttl_secs: ttl_secs, never: never }.compact)
469
+ end
470
+ alias_method :push, :take_snapshot
471
+
472
+ # A new environment +to+ ("name"; "team/name" only with +snapshot+): a copy of the live disk,
473
+ # or with +snapshot+ of one of this environment's snapshots. +services+ and +quota_gb+ apply
474
+ # only with +snapshot+.
475
+ def clone(environment:, to:, snapshot: nil, services: nil, quota_gb: nil, ttl_secs: nil)
476
+ owner, name = owned(to)
477
+ at("POST", environment, "/clone",
478
+ body: { name: name, owner: owner, snapshot_id: snapshot,
479
+ services: services.nil? || services.empty? ? nil : fill(services),
480
+ quota_gb: quota_gb, ttl_secs: ttl_secs }.compact)
481
+ end
482
+
483
+ # Put THIS environment back to one of its own snapshots, services and data (202). A
484
+ # persistent service the snapshot lacks is a 409 naming it; list it in +discard+ to accept
485
+ # losing its data.
486
+ def restore(environment:, snapshot:, discard: nil)
487
+ at("POST", environment, "/restore", body: { snapshot_id: snapshot, discard: discard }.compact)
488
+ end
489
+
490
+ private
491
+
492
+ def at(method, environment, tail = "", body: nil)
493
+ owner, name = owned(environment)
494
+ @c.request(method, "/v1/environments/#{seg(name)}#{tail}", body: body, query: { owner: owner })
495
+ end
496
+
497
+ # The server requires every field; fill the ones a caller leaves out.
498
+ def fill(services)
499
+ services.map { |sv| { command: [], env: {}, mounts: [], ports: [] }.merge(sv.transform_keys(&:to_sym)) }
500
+ end
501
+ end
502
+
503
+ # Working sessions, over /v1/sessions: some of YOUR workspaces in one team, connected to one
504
+ # environment. Many people's sessions connect to the same environment and collaborate through
505
+ # it; an intercept a session takes is environment-wide, and the session owns it — only it can
506
+ # release it, and removing the workspace or deleting the session releases it.
507
+ #
508
+ # A session is named like an environment — "name" (personal) or "team/name" — or by its id
509
+ # ("ses-…"); a workspace by its id, name or "team/name", sent verbatim.
510
+ class Sessions
511
+ include Names
512
+
513
+ def initialize(client)
514
+ @c = client
515
+ end
516
+
517
+ # +environment+, when given, must be the session's team's: an id, a name or "team/name";
518
+ # without one the session connects to nothing until +connect+. +ttl_secs+ (600..=2592000)
519
+ # deletes the session after that long unused.
520
+ def create(session:, environment: nil, ttl_secs: nil)
521
+ team, name = owned(session)
522
+ @c.request("POST", "/v1/sessions", body: { name: name, team: team, environment: environment, ttl_secs: ttl_secs })
523
+ end
524
+
525
+ # Seconds unused before the session is deleted (600..=2592000); nil clears it.
526
+ def set_ttl(session:, ttl_secs:)
527
+ at("PUT", session, "/ttl", body: { ttl_secs: ttl_secs })
528
+ end
529
+
530
+ def list(team: nil)
531
+ @c.request("GET", "/v1/sessions", query: { team: team })
532
+ end
533
+
534
+ # The session with its +workspaces+ and the +intercepts+ it holds.
535
+ def get(session:)
536
+ at("GET", session)
537
+ end
538
+
539
+ # Releases its intercepts and lets its workspaces go — never deletes a workspace.
540
+ def delete(session:)
541
+ at("DELETE", session)
542
+ end
543
+
544
+ # Sets, switches or (nil) clears the session's environment. Its intercepts in the old
545
+ # environment are released first; deleting an environment disconnects its sessions the same way.
546
+ def connect(session:, environment:)
547
+ at("PUT", session, "/environment", body: { environment: environment })
548
+ end
549
+
550
+ # Stop the session and every workspace in it (202, {"stopped" => [ids]}); sandboxes and the
551
+ # environment are untouched.
552
+ def stop(session:)
553
+ at("POST", session, "/stop")
554
+ end
555
+
556
+ # Create a workspace +workspace+ (a bare name) managed by the session, in its team (201). The
557
+ # session controls it from then on; it takes the inputs of Workspaces#create.
558
+ def create_workspace(session:, workspace:, quota_gb:, image: nil, packages: nil, ttl_secs: nil, env: nil)
559
+ at("POST", session, "/workspaces",
560
+ body: { name: workspace, quota_gb: quota_gb, image: image, packages: packages,
561
+ ttl_secs: ttl_secs, env: env }.compact)
562
+ end
563
+
564
+ # Delete a workspace the session manages, releasing its intercepts first (204); snapshots stay.
565
+ def delete_workspace(session:, workspace:)
566
+ at("DELETE", session, "/workspaces/#{seg(workspace)}")
567
+ end
568
+
569
+ # Create a sandbox managed by the session (202), in its team and reaching its environment.
570
+ # Same inputs as Sandboxes#create (+team+ must be the session's).
571
+ def create_sandbox(session:, cmd:, **options)
572
+ Sandbox.new(@c, at("POST", session, "/sandboxes", body: Sandboxes.body(cmd: cmd, **options)))
573
+ end
574
+
575
+ # Send everything that calls +service+ in the environment to +workspace+ (a member), +ports+
576
+ # mapping each service port to the workspace port that answers it ({ 8080 => 3000 }); an
577
+ # unmapped port answers on the same number.
578
+ def intercept(session:, service:, workspace:, ports: nil)
579
+ at("POST", session, "/intercepts",
580
+ body: { service: service, workspace: workspace,
581
+ ports: (ports || {}).map { |s, w| { service: Integer(s), workspace: Integer(w) } } })
582
+ end
583
+
584
+ def release(session:, service:)
585
+ at("DELETE", session, "/intercepts/#{seg(service)}")
586
+ end
587
+
588
+ private
589
+
590
+ def at(method, session, tail = "", body: nil)
591
+ owner, name = owned(session)
592
+ @c.request(method, "/v1/sessions/#{seg(name)}#{tail}", body: body, query: { owner: owner })
593
+ end
594
+ end
595
+
596
+ # An owner's named secrets, over /v1/secrets. A value goes in and never comes back: +list+
597
+ # answers names with who and when. Reference one from a workspace env as "${{ secrets.NAME }}".
598
+ # +scope+ "mine" (default) is your own secret in the team +owner+ (for your own handle, your
599
+ # personal one); "team" is the team's shared secret and needs an admin.
600
+ class Secrets
601
+ include Names
602
+
603
+ def initialize(client)
604
+ @c = client
605
+ end
606
+
607
+ def set(owner:, name:, value:, scope: "mine")
608
+ @c.request("PUT", path(owner, scope, name), body: { value: value })
609
+ nil
610
+ end
611
+
612
+ def list(owner:, scope: "mine")
613
+ @c.request("GET", path(owner, scope))
614
+ end
615
+
616
+ # 409 while a workspace or environment still references it; the body names them.
617
+ def delete(owner:, name:, scope: "mine")
618
+ @c.request("DELETE", path(owner, scope, name))
619
+ nil
620
+ end
621
+
622
+ private
623
+
624
+ def path(owner, scope, name = nil)
625
+ raise ArgumentError, "scope is 'mine' or 'team', got #{scope.inspect}" unless %w[mine team].include?(scope.to_s)
626
+
627
+ "/v1/secrets/#{seg(owner)}#{'/mine' if scope.to_s == 'mine'}#{"/#{seg(name)}" if name}"
628
+ end
629
+ end
630
+
631
+ # The provider accounts linked to an owner, over /v1/connectors.
632
+ class Connectors
633
+ include Names
634
+
635
+ def initialize(client)
636
+ @c = client
637
+ end
638
+
639
+ # Connectors by name; +owner+ is a handle or team slug, default your own.
640
+ def list(owner: nil)
641
+ @c.request("GET", "/v1/connectors", query: { owner: owner })
642
+ end
643
+
644
+ # Remove a connector (204); a team's needs an admin.
645
+ def delete(name:)
646
+ @c.request("DELETE", "/v1/connectors/#{seg(name)}")
647
+ nil
648
+ end
649
+ end
650
+
651
+ # The btrfs volumes behind workspaces and environments: snapshot history, restore targets, and
652
+ # the deletes — over /v1/volumes.
653
+ class Volumes
654
+ include Names
655
+
656
+ def initialize(client)
657
+ @c = client
658
+ end
659
+
660
+ # +kind+ is "workspace" or "environment"; +owner+ a handle or team slug.
661
+ def list(kind: nil, owner: nil)
662
+ @c.request("GET", "/v1/volumes", query: { kind: kind, owner: owner })
663
+ end
664
+
665
+ # The chain of PUSHED snapshots, newest first — each row a restore target.
666
+ def history(volume:)
667
+ @c.request("GET", "/v1/volumes/#{seg(volume)}/history")
668
+ end
669
+
670
+ def refs(volume:)
671
+ @c.request("GET", "/v1/volumes/#{seg(volume)}/refs")
672
+ end
673
+
674
+ # Refused (409) for a sync point or a running worktree's base; deleting a detached volume's
675
+ # last snapshot deletes the volume.
676
+ def delete_snapshot(volume:, snapshot:)
677
+ @c.request("DELETE", "/v1/volumes/#{seg(volume)}/snapshots/#{seg(snapshot)}")
678
+ end
679
+
680
+ # Takes a DETACHED volume with all its snapshots; refused while a working copy remains.
681
+ def delete_by_id(volume:)
682
+ @c.request("DELETE", "/v1/volumes/#{seg(volume)}")
683
+ end
684
+
685
+ # One named volume; +owner+ is your handle or a team slug.
686
+ def get(owner:, name:)
687
+ @c.request("GET", named(owner, name))
688
+ end
689
+
690
+ # The versions of a named volume, newest first.
691
+ def versions(owner:, name:)
692
+ @c.request("GET", named(owner, name, "/versions"))
693
+ end
694
+
695
+ # Delete a named volume and every snapshot on it (204); the name is free again.
696
+ def delete(owner:, name:)
697
+ @c.request("DELETE", named(owner, name))
698
+ end
699
+
700
+ # Delete version +n+ (204); deleting the last one deletes the volume.
701
+ def delete_version(owner:, name:, n:)
702
+ @c.request("DELETE", named(owner, name, "/versions/#{Integer(n)}"))
703
+ end
704
+
705
+ private
706
+
707
+ def named(owner, name, tail = "")
708
+ "/v1/volumes/#{seg(owner)}/#{seg(name)}#{tail}"
709
+ end
710
+ end
711
+
712
+ # Throwaway, isolated machines that run ONE command, over /v1/sandboxes. A sandbox starts from
713
+ # an image, from one of your workspaces (+from_workspace+) or from a pushed snapshot
714
+ # (+from_snapshot+), runs +cmd+, and ends when it exits or after +timeout_s+ (server default
715
+ # 300). Every call answers a Sandbox handle.
716
+ #
717
+ # sb = kl.sandboxes.create(cmd: ["python3", "-c", "print(6 * 7)"], packages: ["python3"])
718
+ # code = sb.wait
719
+ # puts code, sb.logs
720
+ class Sandboxes
721
+ include Names
722
+
723
+ # The POST /v1/sandboxes body; refuses a missing +cmd+ before anything is sent. Shared with
724
+ # Sessions#create_sandbox — not public surface.
725
+ def self.body(cmd:, image: nil, packages: nil, from_workspace: nil, from_snapshot: nil, team: nil,
726
+ tty: nil, stdin: nil, timeout_s: nil, network: nil, env: nil, cpu: nil,
727
+ memory_gb: nil, disk_gb: nil)
728
+ unless cmd.is_a?(Array) && !cmd.empty? && cmd.all?(String)
729
+ raise ArgumentError, "cmd is required: the program and its arguments as a non-empty list of strings"
730
+ end
731
+
732
+ { cmd: cmd, image: image, packages: packages, from_workspace: from_workspace,
733
+ from_snapshot: from_snapshot, team: team, tty: tty, stdin: stdin, timeout_secs: timeout_s,
734
+ network: network, env: env, cpu: cpu, memory_gb: memory_gb, disk_gb: disk_gb }.compact
735
+ end
736
+
737
+ def initialize(client)
738
+ @c = client
739
+ end
740
+
741
+ # Ask for a sandbox and return at once (202); it is usually still "pending". +cmd+ (required)
742
+ # is the program then its arguments. +tty+ runs it on a pty; +stdin+ keeps input open for
743
+ # attach. +timeout_s+ is 10..=86400. +from_workspace+ is an id, "name" or "team/name";
744
+ # +from_snapshot+ a volume name, "name@N" or snapshot id (exclusive with it). +network+ is
745
+ # "internet" or "none"; +cpu+ 1..=8, +memory_gb+ 1..=32. A session's sandbox is made with
746
+ # Sessions#create_sandbox. Unset keywords are omitted, so the server's defaults apply.
747
+ def create(cmd:, **options)
748
+ Sandbox.new(@c, @c.request("POST", "/v1/sandboxes", body: Sandboxes.body(cmd: cmd, **options)))
749
+ end
750
+
751
+ # One sandbox by id (a name does not resolve).
752
+ def get(sandbox:)
753
+ Sandbox.new(@c, @c.request("GET", "/v1/sandboxes/#{seg(sandbox)}"))
754
+ end
755
+
756
+ # ONE page of the caller's sandboxes, newest first (+limit+ 1..=200, default 50); +team+
757
+ # scopes to that team. +list_page+ also answers the next cursor.
758
+ def list(team: nil, limit: nil, cursor: nil)
759
+ list_page(team: team, limit: limit, cursor: cursor).first
760
+ end
761
+
762
+ # [sandboxes, next_cursor]: a page, and the X-Next-Cursor to pass as +cursor+ for the next
763
+ # one (nil on the last page).
764
+ def list_page(team: nil, limit: nil, cursor: nil)
765
+ resp = @c.__send__(:perform, "GET", "/v1/sandboxes", nil,
766
+ { team: team, limit: limit, cursor: cursor }, "application/json", 30)
767
+ docs = resp.body.to_s.empty? ? [] : JSON.parse(resp.body)
768
+ [(docs || []).map { |d| Sandbox.new(@c, d) }, resp["X-Next-Cursor"]]
769
+ end
770
+
771
+ # One command in a fresh sandbox: create, wait for it to exit, read the output, and
772
+ # terminate — ALWAYS, also when anything before it raised. Answers
773
+ # {"exit_code", "stdout", "stderr", "sandbox"}: the sandbox keeps one stream, so "stdout"
774
+ # holds the output and error output together and "stderr" is "". +timeout_s+ bounds the wait
775
+ # (TimeoutError; nil waits as long as it takes) and, when given, sizes the sandbox's own
776
+ # lifetime to cover it plus a minute. +options+ are the other keywords of +create+.
777
+ def run(cmd:, timeout_s: nil, **options)
778
+ options[:timeout_s] = (timeout_s.ceil + 60).clamp(10, 86_400) if timeout_s && !options.key?(:timeout_s)
779
+ sb = create(cmd: cmd, **options)
780
+ begin
781
+ code = sb.wait(timeout_s: timeout_s)
782
+ out = sb.logs
783
+ rescue Exception # rubocop:disable Lint/RescueException -- terminate on any failure, then re-raise it
784
+ begin
785
+ sb.terminate
786
+ rescue StandardError
787
+ # never mask the failure that got us here
788
+ end
789
+ raise
790
+ end
791
+ sb.terminate
792
+ { "exit_code" => code, "stdout" => out, "stderr" => "", "sandbox" => sb.id }
793
+ end
794
+ end
795
+
796
+ # A handle on one sandbox: id, phase ("pending", "starting", "running", "terminated"), reason
797
+ # once terminated, exit_code, and the whole server doc as of the last answer — +refresh+
798
+ # re-reads it. Fields read as sb["phase"] or through the named readers.
799
+ class Sandbox
800
+ include Names
801
+
802
+ attr_reader :doc
803
+
804
+ def initialize(client, doc)
805
+ @c = client
806
+ @doc = doc || {}
807
+ end
808
+
809
+ def id = @doc["id"]
810
+ def phase = @doc["phase"]
811
+ def reason = @doc["reason"]
812
+ def exit_code = @doc["exit_code"]
813
+ def message = @doc["message"]
814
+
815
+ def [](field)
816
+ @doc[field.to_s]
817
+ end
818
+
819
+ # Re-read the doc; returns self.
820
+ def refresh
821
+ @doc = @c.request("GET", base)
822
+ self
823
+ end
824
+
825
+ # Poll (0.5 s backing off to 2 s) until running. A sandbox that terminates first raises
826
+ # SandboxTerminated naming the reason; +timeout_s+ passing raises TimeoutError.
827
+ def wait_running(timeout_s: 120)
828
+ poll_until(timeout_s, "running") do
829
+ raise SandboxTerminated.new(id, reason, message) if phase == "terminated"
830
+ phase == "running"
831
+ end
832
+ end
833
+
834
+ # The main process's output (stdout and stderr together) as text: the live output while it
835
+ # runs, the last 256 KiB after. +follow+ holds until the process ends. 409 while the sandbox
836
+ # has no output yet (still starting).
837
+ def logs(follow: false)
838
+ @c.__send__(:perform, "GET", "#{base}/logs", nil, { follow: follow ? "true" : nil },
839
+ "text/plain", follow ? nil : 30).body.to_s.dup.force_encoding(Encoding::UTF_8).scrub
840
+ end
841
+
842
+ # Not in this SDK: GET /v1/sandboxes/{id}/attach is a WebSocket and this client uses only
843
+ # the standard library.
844
+ def attach(cols: nil, rows: nil)
845
+ raise NotImplementedError, "sandbox attach is a WebSocket and is not in the Ruby SDK"
846
+ end
847
+
848
+ # Stop it for good (202, or 200 if already ended); the record stays until +delete+. Returns
849
+ # self with the answered doc.
850
+ def terminate
851
+ @doc = @c.request("POST", "#{base}/terminate") || @doc
852
+ self
853
+ end
854
+
855
+ # Block until it has ended and answer its exit code (nil when it ended some other way than
856
+ # the command exiting; +reason+ says how). Waits in steps of at most 20 s, the most the api
857
+ # holds one open; TimeoutError past +timeout_s+.
858
+ def wait(timeout_s: nil)
859
+ deadline = timeout_s && (Sandbox.now + timeout_s)
860
+ loop do
861
+ left_ms = deadline ? ((deadline - Sandbox.now) * 1000).to_i : 20_000
862
+ raise TimeoutError, "sandbox #{id} still #{phase} after #{timeout_s} s" if left_ms <= 0
863
+
864
+ out = @c.request("POST", "#{base}/wait", body: { timeout_ms: [left_ms, 20_000].min }) || {}
865
+ @doc = @doc.merge(out.slice("phase", "reason", "exit_code", "message"))
866
+ return exit_code if phase == "terminated"
867
+ end
868
+ end
869
+
870
+ # Delete the record (204); a sandbox still running dies with it.
871
+ def delete
872
+ @c.request("DELETE", base)
873
+ nil
874
+ end
875
+
876
+ def inspect
877
+ "#<Kloudlite::Sandbox #{id} #{phase}#{reason ? " #{reason}" : ''}>"
878
+ end
879
+
880
+ private
881
+
882
+ def base
883
+ "/v1/sandboxes/#{seg(id)}"
884
+ end
885
+
886
+ def poll_until(timeout_s, what)
887
+ deadline = timeout_s && (Sandbox.now + timeout_s)
888
+ delay = 0.5
889
+ loop do
890
+ refresh
891
+ return self if yield
892
+ left = deadline && (deadline - Sandbox.now)
893
+ raise TimeoutError, "sandbox #{id} was not #{what} within #{timeout_s}s (phase #{phase})" if left && left <= 0
894
+ sleep(left ? [delay, left].min : delay)
895
+ delay = [delay * 2, 2.0].min
896
+ end
897
+ end
898
+
899
+ # ::Process — a bare name would be ambiguous with nothing here, but keep it explicit.
900
+ def self.now
901
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
902
+ end
903
+ end
904
+
905
+ # Pipelines, over /v1/pipelines: a JSON definition stored as numbered versions; a run executes
906
+ # one version, each job in its own sandbox. +run+, +runs+ and +get_run+ answer Run handles.
907
+ #
908
+ # kl.pipelines.create(owner: "acme", definition: definition)
909
+ # run = kl.pipelines.run(owner: "acme", pipeline: "test", inputs: { "target" => "staging" })
910
+ # run.follow(job: "test") { |chunk| print chunk }
911
+ # run.wait # => "success"
912
+ class Pipelines
913
+ include Names
914
+
915
+ def initialize(client)
916
+ @c = client
917
+ end
918
+
919
+ # Save +definition+ (its "name" is the pipeline's) as version 1. A webhook trigger's URL is
920
+ # in the answer's "webhookUrl", this once. 409 if the name exists.
921
+ def create(owner:, definition:)
922
+ @c.request("POST", "/v1/pipelines/#{seg(owner)}", body: definition)
923
+ end
924
+
925
+ # Replace the definition: a changed one is the next version, an equal one saves nothing. To
926
+ # roll back, send an old definition. A concurrent save is an ApiError 409 ("changed; retry").
927
+ def update(owner:, name:, definition:)
928
+ @c.request("PUT", "/v1/pipelines/#{seg(owner)}/#{seg(name)}", body: definition)
929
+ end
930
+
931
+ # +owner+'s pipelines by name: "name", "latestVersion", "updatedAtMs", "triggers".
932
+ def list(owner:)
933
+ @c.request("GET", "/v1/pipelines/#{seg(owner)}")
934
+ end
935
+
936
+ # The pipeline document of +version+ (default the latest), "definition" included.
937
+ def get(owner:, name:, version: nil)
938
+ @c.request("GET", "/v1/pipelines/#{seg(owner)}/#{seg(name)}", query: { version: version })
939
+ end
940
+
941
+ # The versions, oldest first: "version", "createdBy", "createdAtMs", "definitionSha256".
942
+ def versions(owner:, name:)
943
+ @c.request("GET", "/v1/pipelines/#{seg(owner)}/#{seg(name)}/versions")
944
+ end
945
+
946
+ # Delete the pipeline and its versions; finished runs stay. 409 while a run is queued or running.
947
+ def delete(owner:, name:)
948
+ @c.request("DELETE", "/v1/pipelines/#{seg(owner)}/#{seg(name)}")
949
+ nil
950
+ end
951
+
952
+ # Delete one version; 409 for the latest or one a queued or running run uses.
953
+ def delete_version(owner:, name:, version:)
954
+ @c.request("DELETE", "/v1/pipelines/#{seg(owner)}/#{seg(name)}/versions/#{Integer(version)}")
955
+ nil
956
+ end
957
+
958
+ # A new webhook secret; the old URL stops at once. {"webhookUrl"}, shown only here.
959
+ def rotate_webhook(owner:, name:)
960
+ @c.request("POST", "/v1/pipelines/#{seg(owner)}/#{seg(name)}/webhook/rotate")
961
+ end
962
+
963
+ # Start +pipeline+ by hand and answer the Run at once (202), queued. +version+ defaults to the
964
+ # newest; +inputs+ are string values of the inputs it declares. +environment+ runs every job
965
+ # that names none in that environment; +clone+ instead in a per-run copy of it — one or the
966
+ # other. A version that cannot run is an ApiError 422 whose message lists every reason, and
967
+ # nothing is recorded.
968
+ def run(owner:, pipeline:, version: nil, inputs: nil, environment: nil, clone: nil)
969
+ raise ArgumentError, "pass environment: or clone:, not both" if environment && clone
970
+
971
+ body = { version: version, inputs: inputs,
972
+ environment: clone ? { clone: clone } : environment }.compact
973
+ Run.new(@c, @c.request("POST", "/v1/pipelines/#{seg(owner)}/#{seg(pipeline)}/runs", body: body))
974
+ end
975
+
976
+ # The pipeline's runs, newest first (+limit+ default 50, at most 200).
977
+ def runs(owner:, pipeline:, limit: nil)
978
+ docs = @c.request("GET", "/v1/pipelines/#{seg(owner)}/#{seg(pipeline)}/runs", query: { limit: limit }) || []
979
+ docs.map { |doc| Run.new(@c, doc) }
980
+ end
981
+
982
+ # One run by its id ("1760400000000-0a1b2c").
983
+ def get_run(owner:, pipeline:, run:)
984
+ Run.new(@c, @c.request("GET", "/v1/pipelines/#{seg(owner)}/#{seg(pipeline)}/runs/#{seg(run)}"))
985
+ end
986
+ end
987
+
988
+ # One pipeline run: the doc the server last answered (camelCase, as the wire spells it), plus
989
+ # the verbs. Fields read as run["state"] or through the named readers; +state+ is queued,
990
+ # running, success, failure or cancelled. Each of +jobs+ is a Hash with "key", "job", "matrix",
991
+ # "needs", "state" (pending, running, success, failure, skipped, cancelled), "sandbox",
992
+ # "startedAtMs", "finishedAtMs", "exitCode", "failedStep" and "detail"; +logs+ and +follow+
993
+ # name one by its index or its "key".
994
+ class Run
995
+ include Names
996
+
997
+ TERMINAL = %w[success failure cancelled].freeze
998
+
999
+ attr_reader :doc
1000
+
1001
+ def initialize(client, doc)
1002
+ @c = client
1003
+ @doc = doc || {}
1004
+ end
1005
+
1006
+ def id = @doc["id"]
1007
+ def number = @doc["number"]
1008
+ def pipeline = @doc["pipeline"]
1009
+ def owner = @doc["owner"]
1010
+ def pipeline_version = @doc["pipelineVersion"]
1011
+ def definition_sha256 = @doc["definitionSha256"]
1012
+ def event = @doc["event"]
1013
+ def inputs = @doc["inputs"] || {}
1014
+ def environment = @doc["environment"]
1015
+ def requested_by = @doc["requestedBy"]
1016
+ def requested_at_ms = @doc["requestedAtMs"]
1017
+ def state = @doc["state"]
1018
+ def cancel_requested? = @doc["cancelRequested"] == true
1019
+ def started_at_ms = @doc["startedAtMs"]
1020
+ def finished_at_ms = @doc["finishedAtMs"]
1021
+ def detail = @doc["detail"]
1022
+ def jobs = @doc["jobs"] || []
1023
+
1024
+ def [](field)
1025
+ @doc[field.to_s]
1026
+ end
1027
+
1028
+ # Re-read the doc; returns self.
1029
+ def refresh
1030
+ @doc = @c.request("GET", base)
1031
+ self
1032
+ end
1033
+
1034
+ # Ask it to stop: a queued run is cancelled at once, a running one once its worker has ended
1035
+ # its sandboxes — +wait+ sees it reach cancelled. Returns self with the answered doc.
1036
+ def cancel
1037
+ @doc = @c.request("POST", "#{base}/cancel") || @doc
1038
+ self
1039
+ end
1040
+
1041
+ # Poll every +poll_s+ until the run has ended and answer its final state; +timeout_s+ nil
1042
+ # waits as long as it takes, else TimeoutError once it passes.
1043
+ def wait(timeout_s: nil, poll_s: 5)
1044
+ deadline = timeout_s && (Run.now + timeout_s)
1045
+ until TERMINAL.include?(state)
1046
+ refresh
1047
+ break if TERMINAL.include?(state)
1048
+
1049
+ left = deadline && (deadline - Run.now)
1050
+ raise TimeoutError, "run #{id} still #{state} after #{timeout_s}s" if left && left <= 0
1051
+
1052
+ sleep(left ? [poll_s, left].min : poll_s)
1053
+ end
1054
+ state
1055
+ end
1056
+
1057
+ # The job's whole log so far (combined stdout and stderr), every page of it. +job+ is its
1058
+ # index in +jobs+ or its "key" ("test (postgres16)").
1059
+ def logs(job:)
1060
+ index = job_index(job)
1061
+ offset = 0
1062
+ text = +""
1063
+ loop do
1064
+ page = log_page(index, offset)
1065
+ text << page["data"].to_s
1066
+ nxt = page["next"].to_i
1067
+ return text if page["complete"] || nxt <= offset
1068
+
1069
+ offset = nxt
1070
+ end
1071
+ end
1072
+
1073
+ # Yield the job's log in chunks as it is written — from the first byte, polling every
1074
+ # +poll_s+ while nothing new has landed — until the job has ended and every byte was
1075
+ # yielded. No block answers an Enumerator.
1076
+ def follow(job:, poll_s: 5)
1077
+ return enum_for(:follow, job: job, poll_s: poll_s) unless block_given?
1078
+
1079
+ index = job_index(job)
1080
+ offset = 0
1081
+ loop do
1082
+ page = log_page(index, offset)
1083
+ data = page["data"].to_s
1084
+ yield data unless data.empty?
1085
+ return self if page["complete"]
1086
+
1087
+ nxt = page["next"].to_i
1088
+ sleep(poll_s) if nxt <= offset
1089
+ offset = [offset, nxt].max
1090
+ end
1091
+ end
1092
+
1093
+ def inspect
1094
+ "#<Kloudlite::Run #{owner}/#{pipeline}##{number} #{state}>"
1095
+ end
1096
+
1097
+ def self.now
1098
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
1099
+ end
1100
+
1101
+ private
1102
+
1103
+ def base
1104
+ "/v1/pipelines/#{seg(owner)}/#{seg(pipeline)}/runs/#{seg(id)}"
1105
+ end
1106
+
1107
+ # {"data", "next", "complete", "state"}: at most a page of the log from byte +offset+.
1108
+ def log_page(index, offset)
1109
+ @c.request("GET", "#{base}/jobs/#{index}/logs", query: { offset: offset })
1110
+ end
1111
+
1112
+ def job_index(job)
1113
+ keys = jobs.map { |j| j["key"] }
1114
+ if job.is_a?(Integer)
1115
+ raise ArgumentError, "run #{id} has #{keys.size} jobs, no job #{job}" unless job >= 0 && job < keys.size
1116
+
1117
+ return job
1118
+ end
1119
+ keys.index(job.to_s) or raise ArgumentError, "run #{id} has no job #{job.inspect}; its jobs are #{keys.inspect}"
1120
+ end
1121
+ end
1122
+ end
metadata ADDED
@@ -0,0 +1,45 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kloudlite-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Kloudlite
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-25 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - kloudlite.rb
20
+ homepage: https://github.com/kloudlite/sdk
21
+ licenses:
22
+ - Apache-2.0
23
+ metadata:
24
+ source_code_uri: https://github.com/kloudlite/sdk
25
+ rubygems_mfa_required: 'true'
26
+ post_install_message:
27
+ rdoc_options: []
28
+ require_paths:
29
+ - "."
30
+ required_ruby_version: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: '3.0'
35
+ required_rubygems_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ requirements: []
41
+ rubygems_version: 3.5.22
42
+ signing_key:
43
+ specification_version: 4
44
+ summary: Kloudlite SDK for Ruby
45
+ test_files: []