kosa8 0.1.2 → 0.3.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 +4 -4
- data/README.md +86 -1
- data/lib/kosa8/client.rb +31 -4
- data/lib/kosa8/cloud.rb +288 -0
- data/lib/kosa8/errors.rb +12 -6
- data/lib/kosa8/toolbox.rb +332 -0
- data/lib/kosa8/transport.rb +71 -17
- data/lib/kosa8/version.rb +1 -1
- data/lib/kosa8.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 17b6bb99a0c01a23ce95dcb36f7c15c3aa5450a62ff8115277199fec1d2579ba
|
|
4
|
+
data.tar.gz: ba4f1921f132bfec9de4acbdac756724d542368d9b7a44e4e981197e92ae39a9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b04a51d55a99495a01c8bd9b627f046b1205b50cfee240d95acf91f60f36186dfb173579f6d43a370c2f998717a14fc7dcfcc99efeac567c6dbb819eeaf05d84
|
|
7
|
+
data.tar.gz: cbe1b43b35bb3c337df1e860a6f719274ee8329bf52cbe8f327066d40293f83f627e1a232bec3a67ec3e96be0d3b22e379343540f85e93a43e8185a3251e2311
|
data/README.md
CHANGED
|
@@ -27,12 +27,26 @@ sb.delete
|
|
|
27
27
|
| `token:` / `KOSA8_TOKEN` | sent as `Authorization: Bearer` to a remote worker |
|
|
28
28
|
| `actor:` | `"tool"` (`human`, `agent`, `tool`), sent as `X-Kosa8-Actor` |
|
|
29
29
|
| `agent_token:` / `KOSA8_AGENT_TOKEN` | sent as `X-Kosa8-Agent-Token` |
|
|
30
|
+
| `worker_file:` | `~/.kosa8/worker.json`; `false` or `""` reads none |
|
|
31
|
+
|
|
32
|
+
Where requests go, the same as the CLI:
|
|
33
|
+
|
|
34
|
+
1. `host:`, or a non-blank `KOSA8_HOST`. The token is `token:`, else `KOSA8_TOKEN` (only for a
|
|
35
|
+
host from `KOSA8_HOST`). The saved file is not read.
|
|
36
|
+
2. Else, unless `socket_path:` is given, the connection the CLI saved in `~/.kosa8/worker.json`
|
|
37
|
+
(`kosa8 wsl setup` on Windows, `kosa8 worker join` anywhere): its `host`, its `token` unless
|
|
38
|
+
`token:` is given, and plain http to a LAN address only with `"insecure": true`, `insecure: true`
|
|
39
|
+
or `KOSA8_INSECURE=1`. A missing or malformed file, or a blank `host`, is ignored.
|
|
40
|
+
3. Else the unix socket.
|
|
41
|
+
|
|
42
|
+
On Windows the engine runs in WSL2: run `kosa8 wsl setup` once and `Kosa8::Client.new` finds it.
|
|
43
|
+
`client.transport.endpoint.target` shows where requests go.
|
|
30
44
|
|
|
31
45
|
## Errors
|
|
32
46
|
|
|
33
47
|
All errors inherit `Kosa8::Error` and carry the daemon's message and remediation:
|
|
34
48
|
`ConnectionError`, `BadRequestError`, `AuthError`, `PolicyError` › `ApprovalRequiredError` (`#approval`),
|
|
35
|
-
`NotFoundError`, `ConflictError`, `UnsupportedError`, `StreamError` (`#stdout`/`#stderr` so far),
|
|
49
|
+
`NotFoundError`, `ConflictError`, `UnsupportedError`, `PaymentRequiredError` (402), `RateLimitError` (429), `StreamError` (`#stdout`/`#stderr` so far),
|
|
36
50
|
`CheckpointError`, `TimeoutError`. Methods that a policy may gate take `approval:`.
|
|
37
51
|
|
|
38
52
|
## API
|
|
@@ -47,10 +61,81 @@ Snapshots: `snapshot`, `list_snapshots`, `restore_snapshot`, `delete_snapshot`,
|
|
|
47
61
|
Checkpoints: `checkpoint`, `list_checkpoints`, `rewind` (store: `~/.kosa8/checkpoints.json`, flock-shared with the CLI).
|
|
48
62
|
`client.sandbox(id)` binds them to one sandbox.
|
|
49
63
|
|
|
64
|
+
## Toolbox
|
|
65
|
+
|
|
66
|
+
Run code, files, git, shell sessions, terminals, previews, lifecycle and templates, each one daemon
|
|
67
|
+
route (see the [native API](https://docs.kosa8.com/guides/native-api/)). All of them except the
|
|
68
|
+
template calls are also on `client.sandbox(id)`.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
k.create_sandbox(name: "work", egress_allow: ["github.com"], auto_stop_min: 30, ttl_min: 120)
|
|
72
|
+
sb = k.sandbox("work")
|
|
73
|
+
|
|
74
|
+
r = sb.run_code("print(6*7)") # Kosa8::CodeResult; also language: "javascript" / "typescript"
|
|
75
|
+
r.stdout # "42\n"; r.ok?, r.exit_code, r.timed_out
|
|
76
|
+
r.artifacts.each { |a| File.binwrite(a.name, a.data) } # matplotlib charts as PNG bytes
|
|
77
|
+
|
|
78
|
+
sb.write_file("/workspace/app.py", "print(1)") # make_dir, move_file, delete_file(recursive: true)
|
|
79
|
+
sb.read_text("/workspace/app.py") # read_file gives binary bytes; list_files, stat_file
|
|
80
|
+
|
|
81
|
+
sb.git_clone("https://github.com/octocat/Hello-World.git", "hw", depth: 1)
|
|
82
|
+
sb.git_commit("hw", "change", author_name: "me", author_email: "me@example.com") # stages everything first
|
|
83
|
+
sb.git("log", path: "hw", n: 5).log # any op: status, add, push, pull, checkout, branches, diff
|
|
84
|
+
|
|
85
|
+
sb.shell("cd /tmp && export X=1") # session "main", made on first use; cd/export persist
|
|
86
|
+
sb.shell("echo $X; pwd").cwd # "/tmp"; create_session / session_exec / delete_session
|
|
87
|
+
|
|
88
|
+
cid = sb.start_pty(cols: 80, rows: 24) # then WebSocket …/containers/{cid}/tty; resize_pty
|
|
89
|
+
sb.create_preview(8000, session: "main", expires_in_s: 600) # signed link; k.revoke_previews ends them all
|
|
90
|
+
sb.set_lifecycle(auto_stop_min: 0) # nil keeps a setting, 0 turns it off
|
|
91
|
+
|
|
92
|
+
k.build_template("py-data", "./ctx", build_args: { "V" => "1" }) { |out| print out } # tars ./ctx, honours .dockerignore
|
|
93
|
+
k.create_sandbox(name: "fast", template: "py-data")
|
|
94
|
+
k.list_templates; k.delete_template("py-data")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A non-zero exit or a failed git op is a result (`ok?` false), not an exception; a failed template build
|
|
98
|
+
raises `Kosa8::StreamError`.
|
|
99
|
+
|
|
100
|
+
## kosa8 Cloud
|
|
101
|
+
|
|
102
|
+
The same sandboxes on kosa8 Cloud instead of the local daemon. Make an API
|
|
103
|
+
key in the cloud dashboard and set `KOSA8_API_KEY`.
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
require "kosa8"
|
|
107
|
+
|
|
108
|
+
cloud = Kosa8::Cloud.new # or Kosa8::Cloud.new(api_key: "k8c_live_...")
|
|
109
|
+
|
|
110
|
+
box = cloud.sandboxes.create(template: "python", sleep_after_s: 600)
|
|
111
|
+
r = box.exec(["python3", "-c", "print(6 * 7)"])
|
|
112
|
+
puts r.exit_code, r.stdout # 0, 42
|
|
113
|
+
box.sleep # costs only snapshot storage while asleep
|
|
114
|
+
box.exec("ls /workspace") # wakes it, files and all
|
|
115
|
+
box.delete
|
|
116
|
+
|
|
117
|
+
cloud.functions.deploy("hello", {
|
|
118
|
+
"runtime" => "python3.12",
|
|
119
|
+
"code" => "def handler(event):\n return {\"hello\": event.get(\"name\", \"world\")}\n"
|
|
120
|
+
})
|
|
121
|
+
out = cloud.functions.invoke("hello", { "name" => "kosa8" })
|
|
122
|
+
p out["ok"], out["result"] # true, {"hello"=>"kosa8"}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A new organization runs nothing until a payment method is on file; until
|
|
126
|
+
then creating a sandbox raises `Kosa8::PaymentRequiredError` (`code`
|
|
127
|
+
`payment_required`), whose message says what to do. Every error from the
|
|
128
|
+
cloud carries its stable `code`. The address is `KOSA8_CLOUD_URL` (default
|
|
129
|
+
`https://api.kosa8.cloud`, https only except localhost); `cloud.me` and
|
|
130
|
+
`cloud.usage` show the org and this month's bill estimate.
|
|
131
|
+
|
|
50
132
|
## Development
|
|
51
133
|
|
|
52
134
|
```sh
|
|
53
135
|
ruby -w test/test_client.rb # mock daemon on a unix socket
|
|
136
|
+
ruby -w test/test_toolbox.rb
|
|
137
|
+
ruby -w test/test_cloud.rb # stub transport and a local socket
|
|
138
|
+
ruby -w -Ilib -Itest -e 'Dir["test/test_*.rb"].sort.each { |f| require File.expand_path(f) }' # all, as CI does
|
|
54
139
|
ruby examples/smoke.rb # live, against a running `kosa8 daemon`
|
|
55
140
|
```
|
|
56
141
|
|
data/lib/kosa8/client.rb
CHANGED
|
@@ -14,19 +14,23 @@ module Kosa8
|
|
|
14
14
|
TRANSIENT = /connection refused|EOF|connection reset|broken pipe|no such file or directory|stream ended|ECONNREFUSED|ECONNRESET|unreachable/i.freeze
|
|
15
15
|
|
|
16
16
|
# Typed client for the kosa8 native API (/kosa8/v1) over the daemon's unix
|
|
17
|
-
# socket, or a remote worker via KOSA8_HOST / KOSA8_TOKEN
|
|
17
|
+
# socket, or a remote worker via KOSA8_HOST / KOSA8_TOKEN, or the connection
|
|
18
|
+
# the CLI saved in ~/.kosa8/worker.json (`kosa8 wsl setup`, `kosa8 worker join`).
|
|
18
19
|
#
|
|
19
20
|
# k = Kosa8::Client.new
|
|
20
21
|
# k.create_sandbox(name: "demo", egress: "deny")
|
|
21
22
|
# k.pull_into_sandbox("demo", "alpine:latest")
|
|
22
23
|
# puts k.run("demo", "alpine:latest", ["echo", "hi"]).stdout
|
|
23
24
|
class Client
|
|
25
|
+
include Toolbox
|
|
26
|
+
|
|
24
27
|
attr_reader :transport, :checkpoints
|
|
25
28
|
|
|
26
29
|
def initialize(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil,
|
|
27
|
-
headers: {}, timeout: nil, checkpoint_store: nil, env: ENV)
|
|
30
|
+
headers: {}, timeout: nil, checkpoint_store: nil, env: ENV, worker_file: nil)
|
|
28
31
|
@transport = Transport.new(socket_path: socket_path, host: host, token: token, insecure: insecure, actor: actor,
|
|
29
|
-
agent_token: agent_token, headers: headers, timeout: timeout, env: env
|
|
32
|
+
agent_token: agent_token, headers: headers, timeout: timeout, env: env,
|
|
33
|
+
worker_file: worker_file)
|
|
30
34
|
@checkpoints = CheckpointBook.new(checkpoint_store || FileCheckpointStore.new)
|
|
31
35
|
@stdin_sent = {}
|
|
32
36
|
@stdin_mu = Mutex.new
|
|
@@ -62,8 +66,14 @@ module Kosa8
|
|
|
62
66
|
|
|
63
67
|
# POST /kosa8/v1/sandboxes: boot a microVM sandbox.
|
|
64
68
|
# egress: "deny" | "any" | "fakenet" | "allow:host,host" | { allow: [...] }
|
|
69
|
+
#
|
|
70
|
+
# auto_stop_min stops it after that many idle minutes, ttl_min that long
|
|
71
|
+
# after creation, auto_delete_min deletes it once stopped that long, and
|
|
72
|
+
# ephemeral deletes it whenever it stops. template starts it with a
|
|
73
|
+
# template's image (see build_template).
|
|
65
74
|
def create_sandbox(name: nil, cpus: nil, mem_mb: nil, disk_gb: nil, mounts: nil, egress: nil, egress_allow: nil,
|
|
66
|
-
capture: nil, labels: nil, broker: nil,
|
|
75
|
+
capture: nil, labels: nil, broker: nil, auto_stop_min: nil, auto_delete_min: nil, ttl_min: nil,
|
|
76
|
+
ephemeral: false, template: nil, approval: nil)
|
|
67
77
|
body = {}
|
|
68
78
|
body[:name] = name if name
|
|
69
79
|
body[:cpus] = cpus if cpus
|
|
@@ -75,6 +85,11 @@ module Kosa8
|
|
|
75
85
|
body[:capture] = capture unless capture.nil?
|
|
76
86
|
body[:labels] = labels if labels
|
|
77
87
|
body[:broker] = broker if broker
|
|
88
|
+
{ auto_stop_min: auto_stop_min, auto_delete_min: auto_delete_min, ttl_min: ttl_min }.each do |k, v|
|
|
89
|
+
body[k] = v if v && v.positive?
|
|
90
|
+
end
|
|
91
|
+
body[:ephemeral] = true if ephemeral
|
|
92
|
+
body[:template] = template if template
|
|
78
93
|
@transport.json("POST", "/kosa8/v1/sandboxes", body: body, approval: approval)
|
|
79
94
|
end
|
|
80
95
|
|
|
@@ -512,6 +527,18 @@ module Kosa8
|
|
|
512
527
|
@client.attach(@id, container, **kw)
|
|
513
528
|
end
|
|
514
529
|
|
|
530
|
+
# The toolbox, bound to this sandbox: sandbox.run_code("print(1)"),
|
|
531
|
+
# sandbox.write_file("a.txt", "hi"), sandbox.shell("ls"), …
|
|
532
|
+
%i[run_code list_files stat_file read_file read_text write_file delete_file make_dir move_file git git_clone
|
|
533
|
+
git_status git_commit create_session list_sessions session_exec shell delete_session start_pty resize_pty
|
|
534
|
+
create_preview].each do |name|
|
|
535
|
+
define_method(name) { |*args, **kw, &blk| @client.public_send(name, @id, *args, **kw, &blk) }
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def set_lifecycle(**kw)
|
|
539
|
+
@client.set_lifecycle(@id, **kw)
|
|
540
|
+
end
|
|
541
|
+
|
|
515
542
|
{ start: :start_sandbox, stop: :stop_sandbox, delete: :delete_sandbox, snapshot: :snapshot,
|
|
516
543
|
checkpoint: :checkpoint, rewind: :rewind }.each do |name, target|
|
|
517
544
|
define_method(name) { |**kw| @client.public_send(target, @id, **kw) }
|
data/lib/kosa8/cloud.rb
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Kosa8
|
|
10
|
+
# The outcome of a cloud exec. +woke_ms+ is set when the sandbox was asleep
|
|
11
|
+
# and woke for this command.
|
|
12
|
+
CloudExecResult = Struct.new(:exit_code, :stdout, :stderr, :woke_ms, keyword_init: true) do
|
|
13
|
+
def ok?
|
|
14
|
+
exit_code == 0 # exit_code is nil when the command never ran
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# kosa8 Cloud: the same sandboxes, run on kosa8's hosts (or your own, with
|
|
19
|
+
# bring-your-own-cloud) instead of the local daemon.
|
|
20
|
+
#
|
|
21
|
+
# cloud = Kosa8::Cloud.new # KOSA8_API_KEY from the environment
|
|
22
|
+
# box = cloud.sandboxes.create(template: "python")
|
|
23
|
+
# box.exec(["python3", "-c", "print(6 * 7)"]).stdout # => "42\n"
|
|
24
|
+
# box.delete
|
|
25
|
+
#
|
|
26
|
+
# Authentication is an API key (k8c_live_...) made in the cloud dashboard.
|
|
27
|
+
# Errors are the SDK's usual typed errors; each carries the cloud's stable
|
|
28
|
+
# +code+ (for example payment_required, quota_exceeded). Standard library only.
|
|
29
|
+
class Cloud
|
|
30
|
+
DEFAULT_URL = "https://api.kosa8.cloud"
|
|
31
|
+
KEY_PREFIX = "k8c_live_"
|
|
32
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :base_url, :timeout, :sandboxes, :functions
|
|
35
|
+
|
|
36
|
+
# transport: an optional callable (method, url, headers, body_or_nil, timeout) -> [status, body_string],
|
|
37
|
+
# used instead of Net::HTTP (tests, proxies).
|
|
38
|
+
def initialize(api_key: nil, base_url: nil, env: ENV, timeout: 120, transport: nil)
|
|
39
|
+
key = (api_key.nil? ? env["KOSA8_API_KEY"].to_s : api_key.to_s).strip
|
|
40
|
+
raise Error, "no kosa8 Cloud API key: pass api_key: or set KOSA8_API_KEY" if key.empty?
|
|
41
|
+
raise Error, "a kosa8 Cloud API key starts with #{KEY_PREFIX}" unless key.start_with?(KEY_PREFIX)
|
|
42
|
+
|
|
43
|
+
@key = key
|
|
44
|
+
base = [base_url, env["KOSA8_CLOUD_URL"]].find { |v| v && !v.to_s.empty? } || DEFAULT_URL
|
|
45
|
+
@base_url = Cloud.check_base(base.to_s)
|
|
46
|
+
@timeout = timeout
|
|
47
|
+
@transport = transport || method(:net_http)
|
|
48
|
+
@sandboxes = CloudSandboxes.new(self)
|
|
49
|
+
@functions = CloudFunctions.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Never the key.
|
|
53
|
+
def inspect
|
|
54
|
+
"#<Kosa8::Cloud base_url=#{@base_url.inspect}>"
|
|
55
|
+
end
|
|
56
|
+
alias to_s inspect
|
|
57
|
+
|
|
58
|
+
# The org, the key in use and, for a member's key, the member.
|
|
59
|
+
def me
|
|
60
|
+
request("GET", "/v1/me")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# This month's usage, bill estimate and whether work is paused for payment.
|
|
64
|
+
def usage
|
|
65
|
+
request("GET", "/v1/usage")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# One JSON request; raises the typed error for an error answer.
|
|
69
|
+
def request(method, path, body = nil)
|
|
70
|
+
headers = { "Authorization" => "Bearer #{@key}", "Accept" => "application/json" }
|
|
71
|
+
data = nil
|
|
72
|
+
unless body.nil?
|
|
73
|
+
headers["Content-Type"] = "application/json"
|
|
74
|
+
data = JSON.generate(body)
|
|
75
|
+
end
|
|
76
|
+
begin
|
|
77
|
+
status, raw = @transport.call(method, @base_url + path, headers, data, @timeout)
|
|
78
|
+
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
|
|
79
|
+
raise TimeoutError.new("kosa8 Cloud did not answer within #{@timeout} s (#{e.message})",
|
|
80
|
+
method: method, path: path)
|
|
81
|
+
rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
|
|
82
|
+
raise ConnectionError.new("could not reach kosa8 Cloud at #{@base_url}: #{e.message}",
|
|
83
|
+
method: method, path: path)
|
|
84
|
+
end
|
|
85
|
+
text = raw.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
86
|
+
text = text.scrub unless text.valid_encoding?
|
|
87
|
+
parsed = begin
|
|
88
|
+
text.strip.empty? ? nil : JSON.parse(text)
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
text
|
|
91
|
+
end
|
|
92
|
+
raise Cloud.error(status.to_i, parsed, method, path) if status.to_i >= 400
|
|
93
|
+
|
|
94
|
+
parsed
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The cloud answers {"error": code, "message": sentence}: the sentence is
|
|
98
|
+
# the message, and the code is kept.
|
|
99
|
+
def self.error(status, body, method, path)
|
|
100
|
+
code = nil
|
|
101
|
+
msg_body = body
|
|
102
|
+
if body.is_a?(Hash)
|
|
103
|
+
code = body["error"] if body["error"].is_a?(String)
|
|
104
|
+
m = body["message"]
|
|
105
|
+
msg_body = { "error" => m.is_a?(String) && !m.empty? ? m : body["error"] }
|
|
106
|
+
end
|
|
107
|
+
e = Kosa8.error_from_response(status, msg_body, method: method, path: path, code: code)
|
|
108
|
+
e.instance_variable_set(:@body, body)
|
|
109
|
+
e
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.check_base(url)
|
|
113
|
+
u = begin
|
|
114
|
+
URI.parse(url)
|
|
115
|
+
rescue URI::InvalidURIError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
raise Error, "kosa8 Cloud address is not a URL: #{url}" if u.nil? || u.scheme.nil? || u.host.nil? || u.host.empty?
|
|
119
|
+
|
|
120
|
+
local = LOCAL_HOSTS.include?(u.hostname)
|
|
121
|
+
# The API key travels in every request.
|
|
122
|
+
unless u.scheme == "https" || (u.scheme == "http" && local)
|
|
123
|
+
raise Error, "kosa8 Cloud address must use https: #{url}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
url.sub(%r{/+\z}, "")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def net_http(method, url, headers, body, timeout)
|
|
132
|
+
u = URI.parse(url)
|
|
133
|
+
http = Net::HTTP.new(u.hostname, u.port)
|
|
134
|
+
http.use_ssl = u.scheme == "https"
|
|
135
|
+
if timeout
|
|
136
|
+
http.open_timeout = [timeout, 30].min
|
|
137
|
+
http.read_timeout = timeout
|
|
138
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
139
|
+
end
|
|
140
|
+
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, u.request_uri)
|
|
141
|
+
headers.each { |k, v| req[k] = v }
|
|
142
|
+
req.body = body unless body.nil?
|
|
143
|
+
http.start do |h|
|
|
144
|
+
resp = h.request(req)
|
|
145
|
+
[resp.code.to_i, resp.body.to_s]
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# cloud.sandboxes
|
|
151
|
+
class CloudSandboxes
|
|
152
|
+
def initialize(cloud)
|
|
153
|
+
@cloud = cloud
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inspect
|
|
157
|
+
"#<Kosa8::CloudSandboxes>"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Start a sandbox; returns once it is running. Unset options take the org's settings.
|
|
161
|
+
def create(template: nil, region: nil, sleep_after_s: nil)
|
|
162
|
+
body = {}
|
|
163
|
+
body["template"] = template unless template.nil?
|
|
164
|
+
body["region"] = region unless region.nil?
|
|
165
|
+
body["sleep_after_s"] = sleep_after_s unless sleep_after_s.nil?
|
|
166
|
+
CloudSandbox.new(@cloud, @cloud.request("POST", "/v1/sandboxes", body)["sandbox"])
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The org's sandboxes; with all: true, deleted ones too.
|
|
170
|
+
def list(all: false)
|
|
171
|
+
r = @cloud.request("GET", "/v1/sandboxes" + (all ? "?all=1" : ""))
|
|
172
|
+
r["sandboxes"].map { |s| CloudSandbox.new(@cloud, s) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def get(id)
|
|
176
|
+
CloudSandbox.new(@cloud, @cloud.request("GET", "/v1/sandboxes/#{Kosa8.enc(id)}")["sandbox"])
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# One cloud sandbox. +info+ is its state as of the last call.
|
|
181
|
+
class CloudSandbox
|
|
182
|
+
attr_reader :info
|
|
183
|
+
|
|
184
|
+
def initialize(cloud, info)
|
|
185
|
+
@cloud = cloud
|
|
186
|
+
@info = info
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def id
|
|
190
|
+
@info["id"]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def state
|
|
194
|
+
@info["state"].to_s
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def inspect
|
|
198
|
+
"#<Kosa8::CloudSandbox id=#{id.inspect} state=#{state.inspect}>"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Run a command and wait for it. A String runs through sh -c, an Array is
|
|
202
|
+
# the argv. A sleeping sandbox wakes for it.
|
|
203
|
+
def exec(cmd, image: nil, env: nil)
|
|
204
|
+
argv = cmd.is_a?(String) ? ["sh", "-c", cmd] : cmd.to_a
|
|
205
|
+
body = { "cmd" => argv }
|
|
206
|
+
body["image"] = image unless image.nil?
|
|
207
|
+
body["env"] = env.map { |k, v| "#{k}=#{v}" } if env && !env.empty?
|
|
208
|
+
r = @cloud.request("POST", path("/exec"), body)
|
|
209
|
+
CloudExecResult.new(exit_code: r["exit"], stdout: r["stdout"] || "", stderr: r["stderr"] || "",
|
|
210
|
+
woke_ms: r["woke_ms"])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Snapshot it and free its VM; it then costs only snapshot storage.
|
|
214
|
+
def sleep
|
|
215
|
+
@info = @cloud.request("POST", path("/sleep"))["sandbox"]
|
|
216
|
+
self
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Restore a sleeping sandbox, files and all.
|
|
220
|
+
def wake
|
|
221
|
+
@info = @cloud.request("POST", path("/wake"))["sandbox"]
|
|
222
|
+
self
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def refresh
|
|
226
|
+
@info = @cloud.request("GET", path)["sandbox"]
|
|
227
|
+
self
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def delete
|
|
231
|
+
@cloud.request("DELETE", path)
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def path(suffix = "")
|
|
238
|
+
"/v1/sandboxes/#{Kosa8.enc(id)}#{suffix}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# cloud.functions
|
|
243
|
+
class CloudFunctions
|
|
244
|
+
def initialize(cloud)
|
|
245
|
+
@cloud = cloud
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def inspect
|
|
249
|
+
"#<Kosa8::CloudFunctions>"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Deploy (create or replace) a function; every deploy bumps its version.
|
|
253
|
+
def deploy(name, spec)
|
|
254
|
+
@cloud.request("PUT", path(name), spec.to_h)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def list
|
|
258
|
+
@cloud.request("GET", "/v1/functions")["functions"]
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def get(name)
|
|
262
|
+
@cloud.request("GET", path(name))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def delete(name)
|
|
266
|
+
@cloud.request("DELETE", path(name))
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Call it once. A handler that raises answers "ok" => false, not an exception.
|
|
271
|
+
def invoke(name, event = nil)
|
|
272
|
+
@cloud.request("POST", path(name, "/invoke"), { "event" => event })
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# One call per event, results in input order.
|
|
276
|
+
def map(name, events, concurrency: nil)
|
|
277
|
+
body = { "events" => events.to_a }
|
|
278
|
+
body["concurrency"] = concurrency unless concurrency.nil?
|
|
279
|
+
@cloud.request("POST", path(name, "/map"), body)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
def path(name, suffix = "")
|
|
285
|
+
"/v1/functions/#{Kosa8.enc(name)}#{suffix}"
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
data/lib/kosa8/errors.rb
CHANGED
|
@@ -4,9 +4,11 @@ module Kosa8
|
|
|
4
4
|
# Every error this gem raises. The daemon's own words are kept in
|
|
5
5
|
# +daemon_message+ and +remediation+, as @kosa8/sdk does.
|
|
6
6
|
class Error < StandardError
|
|
7
|
-
|
|
7
|
+
# +code+ is kosa8 Cloud's stable error code (payment_required, quota_exceeded, ...).
|
|
8
|
+
attr_reader :status, :daemon_message, :remediation, :http_method, :path, :body, :code
|
|
8
9
|
|
|
9
|
-
def initialize(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil
|
|
10
|
+
def initialize(message = nil, status: nil, daemon_message: nil, remediation: nil, method: nil, path: nil, body: nil,
|
|
11
|
+
code: nil)
|
|
10
12
|
super(message)
|
|
11
13
|
@status = status
|
|
12
14
|
@daemon_message = daemon_message
|
|
@@ -14,6 +16,7 @@ module Kosa8
|
|
|
14
16
|
@http_method = method
|
|
15
17
|
@path = path
|
|
16
18
|
@body = body
|
|
19
|
+
@code = code
|
|
17
20
|
end
|
|
18
21
|
end
|
|
19
22
|
|
|
@@ -25,6 +28,8 @@ module Kosa8
|
|
|
25
28
|
class NotFoundError < ApiError; end # 404
|
|
26
29
|
class ConflictError < ApiError; end # 409
|
|
27
30
|
class UnsupportedError < ApiError; end # 501
|
|
31
|
+
class PaymentRequiredError < ApiError; end # 402: kosa8 Cloud wants a payment method first
|
|
32
|
+
class RateLimitError < ApiError; end # 429: too many requests, or a quota reached
|
|
28
33
|
|
|
29
34
|
# 403 that a signed approval would lift; +approval+ is the daemon's refusal.
|
|
30
35
|
class ApprovalRequiredError < PolicyError
|
|
@@ -54,13 +59,13 @@ module Kosa8
|
|
|
54
59
|
class ProtocolError < Error; end
|
|
55
60
|
|
|
56
61
|
BY_STATUS = {
|
|
57
|
-
400 => BadRequestError, 401 => AuthError, 403 => PolicyError,
|
|
58
|
-
404 => NotFoundError, 409 => ConflictError, 501 => UnsupportedError
|
|
62
|
+
400 => BadRequestError, 401 => AuthError, 402 => PaymentRequiredError, 403 => PolicyError,
|
|
63
|
+
404 => NotFoundError, 409 => ConflictError, 429 => RateLimitError, 501 => UnsupportedError
|
|
59
64
|
}.freeze
|
|
60
65
|
|
|
61
66
|
# The typed error for an HTTP error answer: the daemon's message with its
|
|
62
67
|
# remediation in parentheses, as the CLI and MCP server print it.
|
|
63
|
-
def self.error_from_response(status, body, method:, path:)
|
|
68
|
+
def self.error_from_response(status, body, method:, path:, code: nil)
|
|
64
69
|
msg = nil
|
|
65
70
|
rem = nil
|
|
66
71
|
approval = nil
|
|
@@ -73,7 +78,8 @@ module Kosa8
|
|
|
73
78
|
end
|
|
74
79
|
base = msg.is_a?(String) && !msg.empty? ? msg : "request failed with HTTP #{status}"
|
|
75
80
|
text = rem ? "#{base} (#{rem})" : base
|
|
76
|
-
kw = { status: status, daemon_message: msg, remediation: rem, method: method, path: path, body: body
|
|
81
|
+
kw = { status: status, daemon_message: msg, remediation: rem, method: method, path: path, body: body,
|
|
82
|
+
code: code }
|
|
77
83
|
return ApprovalRequiredError.new(text, approval: approval, **kw) if approval
|
|
78
84
|
|
|
79
85
|
(BY_STATUS[status] || ApiError).new(text, **kw)
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "rubygems/package"
|
|
6
|
+
require "stringio"
|
|
7
|
+
|
|
8
|
+
module Kosa8
|
|
9
|
+
# A file a code run produced: today, a chart as a PNG. +data+ is a binary String.
|
|
10
|
+
Artifact = Struct.new(:type, :name, :data, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# The outcome of run_code. A non-zero exit_code is not an error.
|
|
13
|
+
CodeResult = Struct.new(:exit_code, :stdout, :stderr, :language, :image, :artifacts, :duration_ms, :timed_out,
|
|
14
|
+
:truncated, keyword_init: true) do
|
|
15
|
+
def ok?
|
|
16
|
+
exit_code.zero? && !timed_out
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The outcome of one command in a session. +cwd+ is where the shell is now.
|
|
21
|
+
ShellResult = Struct.new(:exit_code, :stdout, :stderr, :cwd, :duration_ms, :timed_out, :truncated,
|
|
22
|
+
keyword_init: true) do
|
|
23
|
+
def ok?
|
|
24
|
+
exit_code.zero? && !timed_out
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The outcome of a git operation; +status+, +log+ or +branches+ are filled for those ops.
|
|
29
|
+
# A git failure is ok == false, not an exception.
|
|
30
|
+
GitResult = Struct.new(:op, :ok, :exit_code, :stdout, :stderr, :path, :status, :log, :branches,
|
|
31
|
+
keyword_init: true) do
|
|
32
|
+
def ok?
|
|
33
|
+
ok
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# How long the daemon lets a snippet or session command run by default
|
|
38
|
+
# (codeTimeoutDefault), and a git operation (gitTimeout).
|
|
39
|
+
CODE_TIMEOUT_DEFAULT_S = 300
|
|
40
|
+
GIT_TIMEOUT_S = 600
|
|
41
|
+
|
|
42
|
+
# The sandbox toolbox: run code, files and git, shell sessions, terminals,
|
|
43
|
+
# previews, lifecycle and templates. Included in Kosa8::Client; every method
|
|
44
|
+
# maps to one daemon route (see https://docs.kosa8.com/guides/native-api/).
|
|
45
|
+
module Toolbox
|
|
46
|
+
# --- code -----------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# POST …/code: run a Python, JavaScript or TypeScript snippet. It starts in
|
|
49
|
+
# /workspace. Charts a Python snippet draws with matplotlib come back in
|
|
50
|
+
# +artifacts+ as PNG bytes.
|
|
51
|
+
def run_code(sandbox, code, language: "python", image: nil, env: nil, timeout_s: nil, approval: nil)
|
|
52
|
+
body = { language: language, code: code }
|
|
53
|
+
body[:image] = image if image
|
|
54
|
+
body[:env] = env.to_h if env && !env.empty?
|
|
55
|
+
body[:timeout_s] = timeout_s if timeout_s && timeout_s.positive?
|
|
56
|
+
d = @transport.json("POST", sb(sandbox, "code"), body: body, approval: approval,
|
|
57
|
+
timeout: (timeout_s || CODE_TIMEOUT_DEFAULT_S) + 120) || {}
|
|
58
|
+
CodeResult.new(
|
|
59
|
+
exit_code: d["exit"].to_i, stdout: d["stdout"].to_s, stderr: d["stderr"].to_s,
|
|
60
|
+
language: d["language"].to_s, image: d["image"].to_s,
|
|
61
|
+
artifacts: (d["artifacts"] || []).map do |a|
|
|
62
|
+
Artifact.new(type: a["type"].to_s, name: a["name"].to_s, data: Base64.decode64(a["data"].to_s).b)
|
|
63
|
+
end,
|
|
64
|
+
duration_ms: d["duration_ms"].to_i, timed_out: d["timed_out"] == true, truncated: d["truncated"] == true
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- files ----------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
# GET …/files: a folder in the workspace (or one entry).
|
|
71
|
+
def list_files(sandbox, path = "/workspace")
|
|
72
|
+
d = @transport.json("GET", sb(sandbox, "files"), query: { path: path })
|
|
73
|
+
(d.is_a?(Hash) ? d["files"] : nil) || []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# GET …/files/stat: one entry, not following a symlink.
|
|
77
|
+
def stat_file(sandbox, path)
|
|
78
|
+
@transport.json("GET", sb(sandbox, "files", "stat"), query: { path: path })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# GET …/files/content: a file's bytes, as a binary String.
|
|
82
|
+
def read_file(sandbox, path)
|
|
83
|
+
route = sb(sandbox, "files", "content")
|
|
84
|
+
@transport.raw("GET", route, query: { path: path }) do |resp|
|
|
85
|
+
data = resp.read_body.to_s.b
|
|
86
|
+
code = resp.code.to_i
|
|
87
|
+
if code >= 400
|
|
88
|
+
raise Kosa8.error_from_response(code, decode_body(data), method: "GET", path: route)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
want = resp["Content-Length"]
|
|
92
|
+
if want && want.to_i != data.bytesize
|
|
93
|
+
raise StreamError.new("#{path}: got #{data.bytesize} of #{want} bytes", method: "GET", path: route)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
data
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def read_text(sandbox, path, encoding: Encoding::UTF_8)
|
|
101
|
+
read_file(sandbox, path).force_encoding(encoding)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# PUT …/files/content: replace a file (a String, binary or text), making its folders.
|
|
105
|
+
def write_file(sandbox, path, data, mode: nil, approval: nil)
|
|
106
|
+
q = { path: path }
|
|
107
|
+
q[:mode] = mode if mode
|
|
108
|
+
@transport.json("PUT", sb(sandbox, "files", "content"), query: q, raw_body: data.to_s.b, approval: approval)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# DELETE …/files: a file, or with recursive: a folder.
|
|
112
|
+
def delete_file(sandbox, path, recursive: false, approval: nil)
|
|
113
|
+
@transport.json("DELETE", sb(sandbox, "files"), query: { path: path, recursive: recursive }, approval: approval)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def make_dir(sandbox, path, approval: nil)
|
|
117
|
+
@transport.json("POST", sb(sandbox, "files", "mkdir"), body: { path: path }, approval: approval)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def move_file(sandbox, from, to, approval: nil)
|
|
121
|
+
@transport.json("POST", sb(sandbox, "files", "move"), body: { from: from, to: to }, approval: approval)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# --- git ------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
# POST …/git: +op+ is clone, status, add, commit, push, pull, checkout,
|
|
127
|
+
# branches, log or diff; +fields+ are that op's (url:, path:, branch:,
|
|
128
|
+
# message:, token:…). A git failure returns ok == false.
|
|
129
|
+
def git(sandbox, op, approval: nil, **fields)
|
|
130
|
+
body = { op: op }
|
|
131
|
+
fields.each { |k, v| body[k] = v unless v.nil? }
|
|
132
|
+
d = @transport.json("POST", sb(sandbox, "git"), body: body, approval: approval, timeout: GIT_TIMEOUT_S + 120) || {}
|
|
133
|
+
GitResult.new(op: d["op"] || op.to_s, ok: d["ok"] == true, exit_code: d["exit"].to_i, stdout: d["stdout"].to_s,
|
|
134
|
+
stderr: d["stderr"].to_s, path: d["path"].to_s, status: d["status"], log: d["log"],
|
|
135
|
+
branches: d["branches"])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def git_clone(sandbox, url, path = nil, **kw)
|
|
139
|
+
git(sandbox, "clone", url: url, path: path, **kw)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def git_status(sandbox, path, **kw)
|
|
143
|
+
git(sandbox, "status", path: path, **kw)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Stage everything (unless add_all: false) and commit.
|
|
147
|
+
def git_commit(sandbox, path, message, add_all: true, **kw)
|
|
148
|
+
if add_all
|
|
149
|
+
r = git(sandbox, "add", path: path, approval: kw[:approval])
|
|
150
|
+
return r unless r.ok
|
|
151
|
+
end
|
|
152
|
+
git(sandbox, "commit", path: path, message: message, **kw)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- sessions -------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
# POST …/sessions: a shell that keeps cd and export between commands.
|
|
158
|
+
def create_session(sandbox, name = nil, image: nil, env: nil, approval: nil)
|
|
159
|
+
body = {}
|
|
160
|
+
body[:name] = name if name
|
|
161
|
+
body[:image] = image if image
|
|
162
|
+
body[:env] = env.to_h if env && !env.empty?
|
|
163
|
+
@transport.json("POST", sb(sandbox, "sessions"), body: body, approval: approval)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def list_sessions(sandbox)
|
|
167
|
+
@transport.json("GET", sb(sandbox, "sessions")) || []
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# POST …/sessions/{name}/exec: run one command line in the session.
|
|
171
|
+
def session_exec(sandbox, session, command, timeout_s: nil, approval: nil)
|
|
172
|
+
body = { command: command }
|
|
173
|
+
body[:timeout_s] = timeout_s if timeout_s && timeout_s.positive?
|
|
174
|
+
d = @transport.json("POST", sb(sandbox, "sessions", Kosa8.enc(session), "exec"), body: body, approval: approval,
|
|
175
|
+
timeout: (timeout_s || CODE_TIMEOUT_DEFAULT_S) + 120) || {}
|
|
176
|
+
ShellResult.new(exit_code: d["exit"].to_i, stdout: d["stdout"].to_s, stderr: d["stderr"].to_s, cwd: d["cwd"].to_s,
|
|
177
|
+
duration_ms: d["duration_ms"].to_i, timed_out: d["timed_out"] == true,
|
|
178
|
+
truncated: d["truncated"] == true)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Run a command in a session, making the session on first use.
|
|
182
|
+
def shell(sandbox, command, session: "main", **kw)
|
|
183
|
+
begin
|
|
184
|
+
return session_exec(sandbox, session, command, **kw)
|
|
185
|
+
rescue NotFoundError => e
|
|
186
|
+
raise unless "#{e.daemon_message} #{e.message}".include?("no such session")
|
|
187
|
+
end
|
|
188
|
+
create_session(sandbox, session, approval: kw[:approval])
|
|
189
|
+
session_exec(sandbox, session, command, **kw)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def delete_session(sandbox, session, approval: nil)
|
|
193
|
+
@transport.json("DELETE", sb(sandbox, "sessions", Kosa8.enc(session)), approval: approval)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- terminals ------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
# POST …/pty: a container with a terminal; returns its id. Connect to
|
|
199
|
+
# /kosa8/v1/sandboxes/{id}/containers/{cid}/tty with any WebSocket client:
|
|
200
|
+
# binary messages are the terminal, text messages JSON control.
|
|
201
|
+
def start_pty(sandbox, image: nil, command: nil, cols: 0, rows: 0, env: nil, approval: nil)
|
|
202
|
+
body = {}
|
|
203
|
+
body[:image] = image if image
|
|
204
|
+
body[:command] = command.to_a if command && !command.empty?
|
|
205
|
+
if cols.to_i.positive? && rows.to_i.positive?
|
|
206
|
+
body[:cols] = cols
|
|
207
|
+
body[:rows] = rows
|
|
208
|
+
end
|
|
209
|
+
body[:env] = env.to_h if env && !env.empty?
|
|
210
|
+
@transport.json("POST", sb(sandbox, "pty"), body: body, approval: approval)["id"]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def resize_pty(sandbox, container, cols, rows)
|
|
214
|
+
@transport.json("POST", sb(sandbox, "containers", Kosa8.enc(container), "resize"), body: { cols: cols, rows: rows })
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# --- previews -------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
# POST …/previews: a signed link to a web server in the sandbox that expires.
|
|
220
|
+
def create_preview(sandbox, port, session: nil, container: nil, expires_in_s: nil, approval: nil)
|
|
221
|
+
body = { port: port }
|
|
222
|
+
body[:session] = session if session
|
|
223
|
+
body[:container] = container if container
|
|
224
|
+
body[:expires_in_s] = expires_in_s if expires_in_s && expires_in_s.positive?
|
|
225
|
+
@transport.json("POST", sb(sandbox, "previews"), body: body, approval: approval)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# POST /kosa8/v1/previews/rotate: end every preview link at once.
|
|
229
|
+
def revoke_previews(approval: nil)
|
|
230
|
+
@transport.json("POST", "/kosa8/v1/previews/rotate", approval: approval)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# --- lifecycle ------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
# POST …/lifecycle: change auto-stop, auto-delete, TTL or ephemeral.
|
|
236
|
+
# Arguments left nil keep their value; 0 turns one off.
|
|
237
|
+
def set_lifecycle(sandbox, auto_stop_min: nil, auto_delete_min: nil, ttl_min: nil, ephemeral: nil, approval: nil)
|
|
238
|
+
body = { auto_stop_min: auto_stop_min, auto_delete_min: auto_delete_min, ttl_min: ttl_min, ephemeral: ephemeral }
|
|
239
|
+
.reject { |_, v| v.nil? }
|
|
240
|
+
@transport.json("POST", sb(sandbox, "lifecycle"), body: body, approval: approval)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# --- templates ------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def list_templates
|
|
246
|
+
@transport.json("GET", "/kosa8/v1/templates") || []
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def delete_template(name, approval: nil)
|
|
250
|
+
@transport.json("DELETE", "/kosa8/v1/templates/#{Kosa8.enc(name)}", approval: approval)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# POST /kosa8/v1/templates: build context_dir's Dockerfile in a throwaway
|
|
254
|
+
# sandbox and keep the image as a template. Build output goes to on_output
|
|
255
|
+
# (or the block) as it arrives; returns the final frame. A failed build
|
|
256
|
+
# raises Kosa8::StreamError.
|
|
257
|
+
def build_template(name, context_dir = ".", dockerfile: "Dockerfile", build_args: nil, egress: nil, replace: false,
|
|
258
|
+
on_output: nil, approval: nil, &blk)
|
|
259
|
+
on_output ||= blk
|
|
260
|
+
q = [["name", name], ["dockerfile", dockerfile]]
|
|
261
|
+
(build_args || {}).each { |k, v| q << ["buildarg", "#{k}=#{v}"] }
|
|
262
|
+
q << ["egress", egress] if egress
|
|
263
|
+
q << %w[replace 1] if replace
|
|
264
|
+
ctx = Toolbox.tar_dir(context_dir, dockerfile)
|
|
265
|
+
route = "/kosa8/v1/templates?" + URI.encode_www_form(q)
|
|
266
|
+
last = nil
|
|
267
|
+
@transport.frames("POST", route, raw_body: ctx, approval: approval) do |f|
|
|
268
|
+
next if last
|
|
269
|
+
|
|
270
|
+
if f["done"]
|
|
271
|
+
if f["error"].is_a?(String) && !f["error"].empty?
|
|
272
|
+
raise StreamError.new(f["error"], daemon_message: f["error"], method: "POST", path: "/kosa8/v1/templates")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
last = f
|
|
276
|
+
next
|
|
277
|
+
end
|
|
278
|
+
on_output&.call(f["data"]) if f["data"].is_a?(String) && !f["data"].empty?
|
|
279
|
+
end
|
|
280
|
+
last || raise(StreamError.new("template build stream ended unexpectedly (no final frame)",
|
|
281
|
+
method: "POST", path: "/kosa8/v1/templates"))
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Pack a build context the way `kosa8 build` does, honouring .dockerignore
|
|
285
|
+
# prefixes. Returns the tar as a binary String.
|
|
286
|
+
def self.tar_dir(path, dockerfile = "Dockerfile")
|
|
287
|
+
raise Error, "no #{dockerfile} in #{path}" unless File.exist?(File.join(path, dockerfile))
|
|
288
|
+
|
|
289
|
+
ignore = []
|
|
290
|
+
di = File.join(path, ".dockerignore")
|
|
291
|
+
if File.exist?(di)
|
|
292
|
+
ignore = File.readlines(di).reject { |l| l.strip.empty? || l.start_with?("#") }
|
|
293
|
+
.map { |l| Pathname.new(l.strip).cleanpath.to_s }
|
|
294
|
+
end
|
|
295
|
+
skip = ->(rel) { ignore.any? { |p| rel == p || rel.start_with?(p + "/") } }
|
|
296
|
+
io = StringIO.new(+"".b)
|
|
297
|
+
Gem::Package::TarWriter.new(io) do |tar|
|
|
298
|
+
walk = lambda do |dir, rel_dir|
|
|
299
|
+
Dir.children(dir).sort.each do |name|
|
|
300
|
+
full = File.join(dir, name)
|
|
301
|
+
rel = rel_dir.empty? ? name : "#{rel_dir}/#{name}"
|
|
302
|
+
next if skip.call(rel)
|
|
303
|
+
|
|
304
|
+
st = File.lstat(full)
|
|
305
|
+
mode = st.mode & 0o7777
|
|
306
|
+
if st.symlink?
|
|
307
|
+
tar.add_symlink(rel, File.readlink(full), mode)
|
|
308
|
+
elsif st.directory?
|
|
309
|
+
tar.mkdir(rel, mode)
|
|
310
|
+
walk.call(full, rel)
|
|
311
|
+
elsif st.file?
|
|
312
|
+
tar.add_file_simple(rel, mode, st.size) { |w| File.open(full, "rb") { |f| IO.copy_stream(f, w) } }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
walk.call(path, "")
|
|
317
|
+
end
|
|
318
|
+
io.string
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private
|
|
322
|
+
|
|
323
|
+
def decode_body(data)
|
|
324
|
+
text = data.dup.force_encoding(Encoding::UTF_8).scrub
|
|
325
|
+
return nil if text.strip.empty?
|
|
326
|
+
|
|
327
|
+
JSON.parse(text)
|
|
328
|
+
rescue JSON::ParserError
|
|
329
|
+
text
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
data/lib/kosa8/transport.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "base64"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "net/http"
|
|
6
6
|
require "openssl"
|
|
7
|
+
require "rbconfig"
|
|
7
8
|
require "socket"
|
|
8
9
|
require "uri"
|
|
9
10
|
|
|
@@ -17,8 +18,16 @@ module Kosa8
|
|
|
17
18
|
File.join(Dir.home, ".kosa8", "run", "kosa8.sock")
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
#
|
|
21
|
-
|
|
21
|
+
# $HOME/.kosa8/worker.json: the connection `kosa8 wsl setup` (Windows) and
|
|
22
|
+
# `kosa8 worker join` save, which the CLI uses whenever KOSA8_HOST is unset.
|
|
23
|
+
def self.default_worker_file
|
|
24
|
+
File.join(Dir.home, ".kosa8", "worker.json")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Where requests go: a unix socket or a remote worker. default_socket is true
|
|
28
|
+
# when nothing chose the socket: no host, no saved worker, no socket_path.
|
|
29
|
+
Endpoint = Struct.new(:kind, :socket_path, :scheme, :host, :port, :base_path, :token, :default_socket,
|
|
30
|
+
keyword_init: true) do
|
|
22
31
|
def target
|
|
23
32
|
kind == :unix ? "unix://#{socket_path}" : "#{scheme}://#{host}:#{port}"
|
|
24
33
|
end
|
|
@@ -29,22 +38,54 @@ module Kosa8
|
|
|
29
38
|
h == "localhost" || h == "::1" || h.start_with?("127.")
|
|
30
39
|
end
|
|
31
40
|
|
|
32
|
-
# The
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
# The saved worker connection as {"host", "token", "insecure"}, or nil when
|
|
42
|
+
# the file is missing, unreadable, not a JSON object or names no host.
|
|
43
|
+
def self.load_saved_worker(path)
|
|
44
|
+
return nil if path.nil? || path == false || path.to_s.empty?
|
|
45
|
+
|
|
46
|
+
w = JSON.parse(File.read(path.to_s))
|
|
47
|
+
return nil unless w.is_a?(Hash) && w["host"].is_a?(String) && !w["host"].strip.empty?
|
|
48
|
+
|
|
49
|
+
{ "host" => w["host"], "token" => w["token"].is_a?(String) ? w["token"] : nil, "insecure" => w["insecure"] == true }
|
|
50
|
+
rescue SystemCallError, IOError, JSON::ParserError, EncodingError
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The CLI's rules (internal/cli/worker.go targetHost/targetToken/targetInsecure):
|
|
55
|
+
# 1. an explicit host:, or a non-blank KOSA8_HOST: https://, http://, tcp://,
|
|
56
|
+
# unix:// or a bare host:port; KOSA8_TOKEN goes only to a KOSA8_HOST host;
|
|
57
|
+
# 2. else, unless socket_path: was given, the saved worker in worker_file
|
|
58
|
+
# (nil = ~/.kosa8/worker.json, false or "" = none), with its token unless
|
|
59
|
+
# token: is given, and its "insecure": true;
|
|
60
|
+
# 3. else the unix socket.
|
|
61
|
+
# Plain http to a non-loopback host is refused unless insecure (or KOSA8_INSECURE=1).
|
|
62
|
+
def self.resolve_endpoint(host: nil, socket_path: nil, token: nil, insecure: false, env: ENV, worker_file: nil)
|
|
37
63
|
explicit = !host.nil? && !host.empty?
|
|
38
|
-
raw = (explicit ? host : env["KOSA8_HOST"].to_s).strip
|
|
39
|
-
|
|
64
|
+
raw = (explicit ? host : env["KOSA8_HOST"].to_s).strip
|
|
65
|
+
if raw.empty?
|
|
66
|
+
saved = socket_path.nil? ? load_saved_worker(worker_file.nil? ? default_worker_file : worker_file) : nil
|
|
67
|
+
if saved.nil?
|
|
68
|
+
return Endpoint.new(kind: :unix, socket_path: socket_path || default_socket_path,
|
|
69
|
+
default_socket: socket_path.nil?)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
tok = token.nil? || token.empty? ? saved["token"] : token
|
|
73
|
+
return parse_host(saved["host"].strip, token: tok, insecure: insecure || saved["insecure"], env: env,
|
|
74
|
+
source: "the saved worker host (#{worker_file || default_worker_file})")
|
|
75
|
+
end
|
|
76
|
+
token = env["KOSA8_TOKEN"] if (token.nil? || token.empty?) && !explicit
|
|
77
|
+
parse_host(raw, token: token, insecure: insecure, env: env, source: explicit ? "host" : "KOSA8_HOST")
|
|
78
|
+
end
|
|
40
79
|
|
|
80
|
+
def self.parse_host(raw, token:, insecure:, env:, source:)
|
|
81
|
+
raw = raw.sub(%r{/+\z}, "")
|
|
41
82
|
h = raw.sub(%r{\A([A-Za-z][A-Za-z0-9+.-]*)://}) { "#{Regexp.last_match(1).downcase}://" }
|
|
42
83
|
if h.start_with?("unix://")
|
|
43
|
-
return Endpoint.new(kind: :unix, socket_path: h.sub("unix://", ""))
|
|
84
|
+
return Endpoint.new(kind: :unix, socket_path: h.sub("unix://", ""), default_socket: false)
|
|
44
85
|
elsif h.start_with?("tcp://")
|
|
45
86
|
h = "http://" + h.sub("tcp://", "")
|
|
46
87
|
elsif h =~ %r{\A([a-z][a-z0-9+.-]*)://} && !%w[http https].include?(Regexp.last_match(1))
|
|
47
|
-
raise Error, "
|
|
88
|
+
raise Error, "#{source} scheme \"#{Regexp.last_match(1)}\" is not supported; use http://, https://, tcp:// or unix://"
|
|
48
89
|
elsif h !~ %r{\Ahttps?://}
|
|
49
90
|
h = "http://" + h
|
|
50
91
|
end
|
|
@@ -53,16 +94,20 @@ module Kosa8
|
|
|
53
94
|
rescue URI::InvalidURIError
|
|
54
95
|
nil
|
|
55
96
|
end
|
|
56
|
-
raise Error, "
|
|
97
|
+
raise Error, "#{source} #{raw.inspect} names no host" if u.nil? || u.host.nil? || u.host.empty?
|
|
57
98
|
|
|
58
99
|
if u.scheme == "http" && !insecure && env["KOSA8_INSECURE"] != "1" && !loopback?(u.host)
|
|
59
100
|
raise Error, "refusing to talk to #{u.host}:#{u.port} over plain http: the token and every request would cross " \
|
|
60
101
|
"the network unencrypted. Use https://#{u.host}:#{u.port} (a worker behind a TLS-terminating tunnel " \
|
|
61
102
|
"or proxy), or pass insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted"
|
|
62
103
|
end
|
|
63
|
-
token = env["KOSA8_TOKEN"] if (token.nil? || token.empty?) && !explicit
|
|
64
104
|
Endpoint.new(kind: :tcp, scheme: u.scheme, host: u.host, port: u.port, base_path: u.path.to_s.sub(%r{/+\z}, ""),
|
|
65
|
-
token: token && !token.empty? ? token : nil)
|
|
105
|
+
token: token && !token.empty? ? token : nil, default_socket: false)
|
|
106
|
+
end
|
|
107
|
+
private_class_method :parse_host
|
|
108
|
+
|
|
109
|
+
def self.windows?
|
|
110
|
+
RbConfig::CONFIG["host_os"] =~ /mswin|mingw|cygwin/ ? true : false
|
|
66
111
|
end
|
|
67
112
|
|
|
68
113
|
def self.enc(s)
|
|
@@ -74,6 +119,8 @@ module Kosa8
|
|
|
74
119
|
attr_accessor :socket_path
|
|
75
120
|
|
|
76
121
|
def connect
|
|
122
|
+
raise Errno::EAFNOSUPPORT, "unix sockets are not available in this Ruby" unless defined?(UNIXSocket)
|
|
123
|
+
|
|
77
124
|
io = UNIXSocket.new(@socket_path)
|
|
78
125
|
@socket = Net::BufferedIO.new(io, read_timeout: @read_timeout, continue_timeout: @continue_timeout,
|
|
79
126
|
debug_output: @debug_output)
|
|
@@ -121,8 +168,9 @@ module Kosa8
|
|
|
121
168
|
attr_reader :endpoint
|
|
122
169
|
|
|
123
170
|
def initialize(socket_path: nil, host: nil, token: nil, insecure: false, actor: "tool", agent_token: nil,
|
|
124
|
-
headers: {}, timeout: nil, env: ENV)
|
|
125
|
-
@endpoint = Kosa8.resolve_endpoint(host: host, socket_path: socket_path, token: token, insecure: insecure, env: env
|
|
171
|
+
headers: {}, timeout: nil, env: ENV, worker_file: nil)
|
|
172
|
+
@endpoint = Kosa8.resolve_endpoint(host: host, socket_path: socket_path, token: token, insecure: insecure, env: env,
|
|
173
|
+
worker_file: worker_file)
|
|
126
174
|
raise Error, "actor must be human, agent or tool, not #{actor.inspect}" unless %w[human agent tool].include?(actor)
|
|
127
175
|
|
|
128
176
|
@actor = actor
|
|
@@ -177,7 +225,13 @@ module Kosa8
|
|
|
177
225
|
raise TimeoutError.new("#{method} #{path} timed out talking to #{@endpoint.target}: #{e.message}",
|
|
178
226
|
method: method, path: path)
|
|
179
227
|
rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
|
|
180
|
-
|
|
228
|
+
# Windows reports a missing unix socket path as ENETDOWN.
|
|
229
|
+
absent = e.is_a?(Errno::ENOENT) || e.is_a?(Errno::ECONNREFUSED) ||
|
|
230
|
+
(Kosa8.windows? && @endpoint.kind == :unix && e.is_a?(Errno::ENETDOWN))
|
|
231
|
+
hint = absent ? " (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`)" : ""
|
|
232
|
+
if @endpoint.kind == :unix && @endpoint.default_socket && Kosa8.windows?
|
|
233
|
+
hint += "; on Windows the engine runs in WSL2: run `kosa8 wsl setup`, or set KOSA8_HOST"
|
|
234
|
+
end
|
|
181
235
|
raise ConnectionError.new("kosa8 daemon unreachable at #{@endpoint.target}: #{e.message}#{hint}",
|
|
182
236
|
method: method, path: path)
|
|
183
237
|
end
|
data/lib/kosa8/version.rb
CHANGED
data/lib/kosa8.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kosa8
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- kosa8
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Typed client for the kosa8 daemon: sandboxes, run/exec streams, snapshots,
|
|
14
14
|
fork, best-of-N explore and checkpoints shared with the kosa8 CLI. No dependencies.'
|
|
@@ -22,7 +22,9 @@ files:
|
|
|
22
22
|
- lib/kosa8.rb
|
|
23
23
|
- lib/kosa8/checkpoints.rb
|
|
24
24
|
- lib/kosa8/client.rb
|
|
25
|
+
- lib/kosa8/cloud.rb
|
|
25
26
|
- lib/kosa8/errors.rb
|
|
27
|
+
- lib/kosa8/toolbox.rb
|
|
26
28
|
- lib/kosa8/transport.rb
|
|
27
29
|
- lib/kosa8/version.rb
|
|
28
30
|
homepage: https://kosa8.com
|