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.
- checksums.yaml +7 -0
- data/kloudlite.rb +1122 -0
- 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: []
|