withruntime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # Every failure the SDK raises. +code+ is stable and machine-readable, +hint+
5
+ # says what to do next, and +request_id+ is what to quote in a report. The
6
+ # subclasses name the class of failure, as in the JavaScript, Python, Go and
7
+ # Java SDKs.
8
+ class Error < StandardError
9
+ # 503s that are a deliberate state, not a passing one: retrying cannot change them.
10
+ DELIBERATE = %w[fork_unavailable previews_unavailable unavailable].freeze
11
+ # Refusals that pass on their own: a host frees room, a trial slot frees.
12
+ PASSING = %w[no_capacity trial_busy].freeze
13
+ # Refusals of a create that clear when a sandbox stops or a host frees room.
14
+ WAITS_FOR_ROOM = %w[trial_busy trial_domain_limit trial_capacity quota_exceeded no_capacity].freeze
15
+
16
+ attr_reader :code, :status, :hint, :request_id, :details, :idempotency_key, :retry_after
17
+
18
+ def initialize(message, code:, status: 0, hint: nil, request_id: nil, details: nil, idempotency_key: nil, retry_after: nil)
19
+ super(message)
20
+ @code = code
21
+ @status = status
22
+ @hint = hint
23
+ @request_id = request_id
24
+ @details = details || {}
25
+ @idempotency_key = idempotency_key
26
+ @retry_after = retry_after
27
+ end
28
+
29
+ # Whether retrying this exact call, with the same idempotency key, is safe and may work.
30
+ def retryable?
31
+ return false if DELIBERATE.include?(code)
32
+ return details["field"] != "count" if PASSING.include?(code)
33
+
34
+ [0, 429, 502, 503, 504].include?(status)
35
+ end
36
+
37
+ def to_s
38
+ text = +"#{super}"
39
+ text << " Hint: #{hint}" if hint
40
+ text << " Request: #{request_id}" if request_id
41
+ text
42
+ end
43
+
44
+ # The API's one error shape, as the right subclass.
45
+ def self.from(status, body, idempotency_key)
46
+ error = body.is_a?(Hash) && body["error"].is_a?(Hash) ? body["error"] : {}
47
+ retry_ms = error["retryAfterMs"]
48
+ klass = case status
49
+ when 401 then AuthenticationError
50
+ when 403 then PermissionDeniedError
51
+ when 404 then NotFoundError
52
+ when 409 then ConflictError
53
+ when 400, 413, 422 then InvalidRequestError
54
+ when 429 then RateLimitError
55
+ else status >= 500 ? ServiceUnavailableError : Error
56
+ end
57
+ klass.new(
58
+ error["message"].is_a?(String) ? error["message"] : "Runtime request failed (#{status}).",
59
+ code: error["code"].is_a?(String) ? error["code"] : "request_failed",
60
+ status: status,
61
+ hint: error["hint"],
62
+ request_id: error["requestId"],
63
+ details: error["details"].is_a?(Hash) ? error["details"] : nil,
64
+ idempotency_key: idempotency_key,
65
+ retry_after: retry_ms.is_a?(Numeric) ? retry_ms / 1000.0 : nil
66
+ )
67
+ end
68
+
69
+ def self.missing_key
70
+ AuthenticationError.new(
71
+ "No Runtime key found: RUNTIME_API_KEY is not set and this machine is not connected.",
72
+ code: "missing_api_key",
73
+ hint: "Run `npx -y withruntime login` (a browser approval; nothing to copy), set RUNTIME_API_KEY to a key " \
74
+ "from https://withruntime.com/account/keys, or pass api_key:."
75
+ )
76
+ end
77
+ end
78
+
79
+ class AuthenticationError < Error; end
80
+ class PermissionDeniedError < Error; end
81
+ class NotFoundError < Error; end
82
+ class ConflictError < Error; end
83
+ class InvalidRequestError < Error; end
84
+ class RateLimitError < Error; end
85
+ class ServiceUnavailableError < Error; end
86
+
87
+ # No answer arrived (+connection_error+) or the call ran past its deadline
88
+ # (+timeout+). A write may or may not have happened; retrying with the same
89
+ # idempotency key settles it safely.
90
+ class ConnectionError < Error; end
91
+
92
+ # Raised by exec with +check: true+ when a command exits non-zero or times out.
93
+ class CommandError < Error
94
+ attr_reader :result
95
+
96
+ def initialize(result)
97
+ @result = result
98
+ if result.timed_out
99
+ super("Command timed out.", code: "command_timeout")
100
+ else
101
+ tail = result.stderr.strip
102
+ tail = tail[-500..] if tail.length > 500
103
+ super("Command exited with #{result.exit_code.inspect}.#{tail.empty? ? "" : " #{tail}"}", code: "command_failed")
104
+ end
105
+ end
106
+
107
+ def exit_code = result.exit_code
108
+ def stdout = result.stdout
109
+ def stderr = result.stderr
110
+ end
111
+
112
+ # A webhook delivery that is not authentic or is too old.
113
+ class WebhookVerificationError < StandardError; end
114
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ # Ruby's snake_case keywords as the API's camelCase fields: +timeout_seconds:+
5
+ # is timeoutSeconds, +memory_mib:+ is memoryMiB. User data (labels, env,
6
+ # build_args, headers and the like) is sent as it is.
7
+ module Fields
8
+ OPAQUE = %w[labels env buildArgs headers data context detail].freeze
9
+
10
+ module_function
11
+
12
+ def camel(key)
13
+ parts = key.to_s.split("_")
14
+ parts.drop(1).each_with_index { |part, index| parts[index + 1] = part == "mib" ? "MiB" : part.capitalize }
15
+ parts.join
16
+ end
17
+
18
+ # A request body from keyword arguments; nil values are left out.
19
+ def body(fields)
20
+ fields.each_with_object({}) do |(key, value), out|
21
+ next if value.nil?
22
+
23
+ name = key.is_a?(Symbol) ? camel(key) : key.to_s
24
+ out[name] = OPAQUE.include?(name) ? stringify(value) : convert(value)
25
+ end
26
+ end
27
+
28
+ def convert(value)
29
+ case value
30
+ when Hash then body(value)
31
+ when Array then value.map { |item| convert(item) }
32
+ else value
33
+ end
34
+ end
35
+
36
+ def stringify(value)
37
+ value.is_a?(Hash) ? value.to_h { |key, item| [key.to_s, item] } : value
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ class WatchService < SandboxProduct
5
+ def path(id = nil) = @s.path("/files/watches") + (id ? "/#{Transport.segment(id)}" : "")
6
+ def start(path, idempotency_key: nil, **options)
7
+ answer = @t.json("POST", self.path, body: Fields.body(options.merge(path: path)), key: idempotency_key)
8
+ FileWatch.new(self, answer)
9
+ end
10
+ def list = @t.json("GET", path)["data"].map { |row| Record.new(row) }
11
+ def read(id, cursor: 0, wait_ms: 0) = Record.new(@t.json("GET", "#{path(id)}/events", query: { "cursor" => cursor, "waitMs" => wait_ms }))
12
+ def stop(id) = @t.json("DELETE", path(id)) && nil
13
+ def events(id, cursor, cancel, &block)
14
+ @t.events("GET", "#{path(id)}/events", query: { "cursor" => cursor, "follow" => true }, timeout: 150, cancel: cancel, &block)
15
+ end
16
+ end
17
+
18
+ # Each events block retains its cursor. Close only its reader, or stop the guest watch too.
19
+ class FileWatch
20
+ attr_reader :id, :path
21
+ def initialize(service, info)
22
+ @service, @id, @path, @cursor = service, info["id"], info["path"], info["cursor"] || 0
23
+ @lock, @stop_lock = Mutex.new, Mutex.new
24
+ @reading = @stopped = false
25
+ end
26
+ def cursor = @lock.synchronize { @cursor }
27
+ def close
28
+ cancel = @lock.synchronize { @cancel }
29
+ cancel&.cancel
30
+ end
31
+ def stop
32
+ @stop_lock.synchronize do
33
+ return if @lock.synchronize { @stopped }
34
+ @lock.synchronize { @stopped = true }
35
+ close
36
+ begin
37
+ @service.stop(@id)
38
+ rescue StandardError
39
+ @lock.synchronize { @stopped = false }
40
+ raise
41
+ end
42
+ end
43
+ nil
44
+ end
45
+ def events
46
+ return enum_for(:events) unless block_given?
47
+ cancel = @lock.synchronize do
48
+ raise Error.new("The watch is stopped or already being read.", code: "watch_reader_active") if @reading || @stopped
49
+ @reading = true
50
+ @cancel = Transport::Cancellation.new
51
+ end
52
+ begin
53
+ loop do
54
+ again = false
55
+ @service.events(@id, cursor, cancel) do |event|
56
+ @lock.synchronize { @cursor = event["cursor"] if event.key?("cursor") }
57
+ if event["k"] == "continue"
58
+ again = true
59
+ break
60
+ end
61
+ raise Error.new(event["message"], code: event["code"]) if event["k"] == "failure"
62
+ yield Record.new(event)
63
+ return if %w[end paused].include?(event["k"])
64
+ end
65
+ break unless again && !cancel.cancelled?
66
+ end
67
+ ensure
68
+ cancel.cancel
69
+ @lock.synchronize { @reading = false; @cancel = nil }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module WithRuntime
6
+ # +runtime.images+: custom images. Build one, then start sandboxes from it
7
+ # with +runtime.sandboxes.create(image: "app")+.
8
+ #
9
+ # runtime.images.build(name: "app", recipe: { pip: ["pandas"] })
10
+ # runtime.images.build(name: "app", dockerfile: File.read("Dockerfile"), context_dir: ".")
11
+ class Images < Product
12
+ UUID = /\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/i
13
+ CONTEXT_BYTES = 100 * 1_048_576
14
+ CONTEXT_FILES = 20_000
15
+ CHUNK = 1_048_576
16
+
17
+ # Queues a build and returns at once, in state "queued". Exactly one of
18
+ # +image:+, +dockerfile:+ or +recipe:+. With +context_dir:+ the folder is
19
+ # packed and its missing chunks uploaded first.
20
+ def create(context_dir: nil, idempotency_key: nil, **fields)
21
+ Record.new(@t.json("POST", "/v1/images", body: prepare(context_dir, fields), key: idempotency_key))
22
+ end
23
+
24
+ # Builds and waits until the image is ready, sending each log line to the
25
+ # block. Raises with code image_failed, and the build's own error, when it fails.
26
+ def build(context_dir: nil, idempotency_key: nil, **fields, &on_log)
27
+ image = create(context_dir: context_dir, idempotency_key: idempotency_key, **fields)
28
+ image = follow_logs(image.id, &on_log) if on_log
29
+ while %w[queued building].include?(image.state)
30
+ sleep 1
31
+ image = get(image.id)
32
+ end
33
+ unless image.state == "ready"
34
+ raise Error.new("Image #{image.id} #{image.state}: #{image.error || "no error given"}", code: "image_failed",
35
+ hint: "Read the build log with runtime.images.logs(id).")
36
+ end
37
+
38
+ image
39
+ end
40
+
41
+ def get(id) = Record.new(@t.json("GET", "/v1/images/#{seg(id)}"))
42
+
43
+ # An image by id, name (its latest tag), name:tag or name@version.
44
+ def resolve(ref) = UUID.match?(ref) ? get(ref) : Record.new(@t.json("GET", "/v1/images/resolve", query: { "ref" => ref }))
45
+
46
+ # Build output after line +after+: lines, next_after, state, truncated and done.
47
+ def logs(id, after: 0) = Record.new(@t.json("GET", "/v1/images/#{seg(id)}/logs", query: { "after" => after }))
48
+
49
+ # Sends build output after line +after+ to the block as it is written,
50
+ # until the build ends, and returns the image as it ended. It streams, and
51
+ # polls when a stream breaks.
52
+ def follow_logs(id, after: 0)
53
+ begin
54
+ loop do
55
+ resume = false
56
+ @t.events("GET", "/v1/images/#{seg(id)}/logs", query: { "after" => after, "follow" => "true" }, timeout: 180) do |event|
57
+ case event["type"]
58
+ when "line"
59
+ yield Record.new(event)
60
+ after = event["seq"]
61
+ when "done" then return event["image"].is_a?(Hash) && event["image"]["id"] ? Record.new(event["image"]) : get(id)
62
+ when "continue"
63
+ after = event["after"]
64
+ resume = true
65
+ when "error" then raise Error.new(event.dig("error", "message") || "The log stream failed.", code: event.dig("error", "code") || "stream_failed")
66
+ end
67
+ end
68
+ break unless resume
69
+ end
70
+ rescue ConnectionError
71
+ nil # Fall through to polling.
72
+ end
73
+ loop do
74
+ page = logs(id, after: after)
75
+ Array(page["lines"]).each { |line| yield Record.new(line) }
76
+ after = page["nextAfter"]
77
+ image = get(id)
78
+ return image if page["done"] || !%w[queued building].include?(image.state)
79
+
80
+ sleep 1
81
+ end
82
+ end
83
+
84
+ def list(state: nil, name: nil, limit: nil) = page("/v1/images", { "state" => state, "name" => name, "limit" => limit })
85
+
86
+ # Every version of a name, newest first.
87
+ def versions(name) = list(name: name, limit: 100)
88
+
89
+ # Points +tag+ of the image's name at this version (+ref+ is an id, name:tag or name@version).
90
+ def tag(ref, tag) = Record.new(@t.json("POST", "/v1/images/#{seg(id_of(ref))}:tag", body: { "tag" => tag }))
91
+ def untag(ref, tag) = Record.new(@t.json("POST", "/v1/images/#{seg(id_of(ref))}:untag", body: { "tag" => tag }))
92
+
93
+ # Deletes one version and its tags.
94
+ def delete(ref) = Record.new(@t.json("POST", "/v1/images/#{seg(id_of(ref))}:delete", body: {}))
95
+
96
+ # Saved credentials for private registries. The secret is never returned.
97
+ def registries = @t.json("GET", "/v1/images/registries")["data"].map { |registry| Record.new(registry) }
98
+
99
+ # A user name and token or password (Docker Hub, GitHub, Google with
100
+ # username _json_key), or +access_key_id:+ and +secret_access_key:+ for Amazon ECR.
101
+ def set_registry(registry, username: nil, password: nil, access_key_id: nil, secret_access_key: nil)
102
+ body = Fields.body(registry: registry, username: username, password: password, access_key_id: access_key_id,
103
+ secret_access_key: secret_access_key)
104
+ Record.new(@t.json("POST", "/v1/images/registries", body: body))
105
+ end
106
+
107
+ def delete_registry(registry) = @t.json("POST", "/v1/images/registries:delete", body: { "registry" => registry })["deleted"] == true
108
+
109
+ # Packs a folder as a build context, the way docker build does (its
110
+ # .dockerignore applied, .git left out when there is none), and uploads the
111
+ # chunks the server does not have yet. Returns the context and the
112
+ # .dockerignore to build with.
113
+ def upload_context(folder, dockerignore: nil)
114
+ archive, files, applied = self.class.pack(folder, dockerignore)
115
+ chunks = (0...archive.bytesize).step(CHUNK).map { |at| archive.byteslice(at, CHUNK) }
116
+ digests = chunks.map { |chunk| Digest::SHA256.hexdigest(chunk) }
117
+ missing = @t.json("POST", "/v1/images/context/missing", body: { "digests" => digests })["missing"]
118
+ queue = Queue.new
119
+ digests.each_with_index { |digest, index| queue << index if missing.include?(digest) }
120
+ queue.close
121
+ failure = nil
122
+ Array.new(4) do
123
+ Thread.new do
124
+ while !failure && (index = queue.pop)
125
+ begin
126
+ @t.json("PUT", "/v1/images/context/#{digests[index]}", raw: chunks[index])
127
+ rescue StandardError => e
128
+ failure ||= e
129
+ end
130
+ end
131
+ end
132
+ end.each(&:join)
133
+ raise failure if failure
134
+
135
+ { "context" => { "archive" => { "sha256" => Digest::SHA256.hexdigest(archive), "size" => archive.bytesize, "chunks" => digests },
136
+ "files" => files },
137
+ "dockerignore" => applied }
138
+ end
139
+
140
+ # A deterministic gzipped tar of the folder (sorted, no times, no owners,
141
+ # no links) and its files with their SHA-256.
142
+ def self.pack(folder, dockerignore = nil)
143
+ root = File.expand_path(folder)
144
+ ignore_file = File.join(root, ".dockerignore")
145
+ dockerignore ||= File.read(ignore_file) if File.file?(ignore_file)
146
+ ignored = dockerignore_filter(dockerignore || ".git\n")
147
+ reincludes = dockerignore.to_s.match?(/^\s*!/)
148
+ tar = "".b
149
+ files = []
150
+ walk = lambda do |directory|
151
+ Dir.children(directory).sort.each do |name|
152
+ full = File.join(directory, name)
153
+ relative = full.delete_prefix("#{root}/")
154
+ stat = File.lstat(full)
155
+ if stat.directory?
156
+ walk.call(full) unless ignored.call(relative) && !reincludes
157
+ elsif stat.file? && !ignored.call(relative)
158
+ raise Error.new("The build context has more than #{CONTEXT_FILES} files.", code: "context_too_large", hint: "Leave some out with a .dockerignore.") if files.size >= CONTEXT_FILES
159
+
160
+ data = File.binread(full)
161
+ mode = stat.mode & 0o777
162
+ files << { "path" => relative, "sha256" => Digest::SHA256.hexdigest(data), "size" => data.bytesize, "mode" => mode }
163
+ tar << Tar.header(relative, data.bytesize, mode, "0") << data << Tar.pad(data.bytesize)
164
+ end
165
+ end
166
+ end
167
+ walk.call(root)
168
+ archive = Tar.gzip(tar << ("\0".b * 1024))
169
+ if archive.bytesize > CONTEXT_BYTES
170
+ raise Error.new("The build context is #{(archive.bytesize / 1_048_576) + 1} MiB compressed; the most is 100 MiB.",
171
+ code: "context_too_large", hint: "Leave build outputs and dependencies out with a .dockerignore.")
172
+ end
173
+
174
+ [archive, files, dockerignore]
175
+ end
176
+
177
+ # A .dockerignore as Docker reads it: # comments, ! re-includes, **
178
+ # crosses directories, a pattern naming a directory excludes what is in it,
179
+ # and the last matching pattern decides.
180
+ def self.dockerignore_filter(text)
181
+ rules = text.gsub(/\r\n?/, "\n").split("\n").filter_map do |raw|
182
+ line = raw.strip
183
+ next if line.empty? || line.start_with?("#")
184
+
185
+ negate = line.start_with?("!")
186
+ line = line[1..].strip if negate
187
+ line = normalize(line.sub(%r{\A/+}, "")).sub(%r{/+\z}, "")
188
+ next if line.empty? || line == "."
189
+
190
+ [negate, Regexp.new("\\A#{translate(line)}\\z")]
191
+ end
192
+ lambda do |path|
193
+ parts = path.split("/")
194
+ rules.reduce(false) do |excluded, (negate, regex)|
195
+ parts.length.downto(1).any? { |n| regex.match?(parts[0, n].join("/")) } ? !negate : excluded
196
+ end
197
+ end
198
+ end
199
+
200
+ def self.translate(line)
201
+ source = +""
202
+ i = 0
203
+ while i < line.length
204
+ char = line[i]
205
+ if char == "*" && line[i + 1] == "*"
206
+ if line[i + 2] == "/"
207
+ source << "(?:.*/)?"
208
+ i += 2
209
+ else
210
+ source << ".*"
211
+ i += 1
212
+ end
213
+ elsif char == "*" then source << "[^/]*"
214
+ elsif char == "?" then source << "[^/]"
215
+ elsif char == "["
216
+ close = line.index("]", i + 1)
217
+ if close.nil?
218
+ source << "\\["
219
+ else
220
+ set = line[(i + 1)...close]
221
+ set = "^#{set[1..]}" if set.start_with?("!")
222
+ source << "[#{set.gsub("\\", "\\\\\\\\")}]"
223
+ i = close
224
+ end
225
+ elsif char == "\\" && i + 1 < line.length
226
+ i += 1
227
+ source << Regexp.escape(line[i])
228
+ else
229
+ source << Regexp.escape(char)
230
+ end
231
+ i += 1
232
+ end
233
+ source
234
+ end
235
+
236
+ def self.normalize(path)
237
+ out = []
238
+ path.split("/").each do |part|
239
+ next if part.empty? || part == "."
240
+
241
+ part == ".." ? out.pop : out.push(part)
242
+ end
243
+ out.empty? ? "." : out.join("/")
244
+ end
245
+
246
+ private
247
+
248
+ def id_of(ref) = UUID.match?(ref) ? ref : resolve(ref).id
249
+
250
+ def prepare(context_dir, fields)
251
+ body = Fields.body(fields)
252
+ return body unless context_dir
253
+
254
+ uploaded = upload_context(context_dir, dockerignore: body["dockerignore"])
255
+ body.merge("context" => uploaded["context"], "dockerignore" => uploaded["dockerignore"]).compact
256
+ end
257
+ end
258
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ class Domains < Product
5
+ def add(hostname:, sandbox_id:, port:, idempotency_key: nil)
6
+ Record.new(@t.json("POST", "/v1/domains", body: Fields.body(hostname: hostname, sandbox_id: sandbox_id, port: port), key: idempotency_key))
7
+ end
8
+ def get(hostname) = Record.new(@t.json("GET", "/v1/domains/#{seg(hostname)}"))
9
+ def verify(hostname) = Record.new(@t.json("POST", "/v1/domains/#{seg(hostname)}:verify", body: {}))
10
+ def list = @t.json("GET", "/v1/domains")["data"].map { |row| Record.new(row) }
11
+ def remove(hostname) = @t.json("DELETE", "/v1/domains/#{seg(hostname)}") && nil
12
+ end
13
+
14
+ class Ports < Product
15
+ def open(sandbox_id:, port:, idempotency_key: nil)
16
+ Record.new(@t.json("POST", "/v1/ports", body: Fields.body(sandbox_id: sandbox_id, port: port), key: idempotency_key))
17
+ end
18
+ def list(sandbox_id: nil) = @t.json("GET", "/v1/ports", query: { "sandboxId" => sandbox_id })["data"].map { |row| Record.new(row) }
19
+ def close(id) = @t.json("DELETE", "/v1/ports/#{seg(id)}") && nil
20
+ end
21
+
22
+ class Addresses < Product
23
+ def reserve(family: 4) = Record.new(@t.json("POST", "/v1/addresses", body: { "family" => family }))
24
+ def list = @t.json("GET", "/v1/addresses")["data"].map { |row| Record.new(row) }
25
+ def release(id) = @t.json("DELETE", "/v1/addresses/#{seg(id)}") && nil
26
+ end
27
+
28
+ # Account WireGuard networking, distinct from Sandbox#open_tunnel.
29
+ class PrivateTunnel < Product
30
+ def get = Record.new(@t.json("GET", "/v1/tunnel"))
31
+ def create(subnet: nil) = Record.new(@t.json("POST", "/v1/tunnel", body: { "subnet" => subnet }.compact))
32
+ def delete = @t.json("DELETE", "/v1/tunnel") && nil
33
+ # Save config even when config_ready is false: its private key is shown once.
34
+ def add_peer(name:, public_key: nil, routes: nil, idempotency_key: nil)
35
+ Record.new(@t.json("POST", "/v1/tunnel/peers", body: Fields.body(name: name, public_key: public_key, routes: routes), key: idempotency_key, no_retry: public_key.to_s.empty?))
36
+ end
37
+ def rotate_peer(id, public_key: nil)
38
+ Record.new(@t.json("POST", "/v1/tunnel/peers/#{seg(id)}:rotate", body: { "publicKey" => public_key }.compact, no_retry: public_key.to_s.empty?))
39
+ end
40
+ def remove_peer(id) = Record.new(@t.json("DELETE", "/v1/tunnel/peers/#{seg(id)}"))
41
+ end
42
+
43
+ class SSO < Product
44
+ def get = Record.new(@t.json("GET", "/v1/sso"))
45
+ end
46
+ class MCP < Product
47
+ def catalog = @t.json("GET", "/v1/mcp/catalog")["data"].map { |row| Record.new(row) }
48
+ end
49
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WithRuntime
4
+ class SandboxProduct
5
+ def initialize(sandbox)
6
+ @s = sandbox
7
+ @t = sandbox.transport
8
+ end
9
+ end
10
+
11
+ class Mounts < SandboxProduct
12
+ def add(idempotency_key: nil, **options) = Record.new(@t.json("POST", @s.path("/mounts"), body: Fields.body(options), key: idempotency_key))
13
+ def list = @t.json("GET", @s.path("/mounts"))["data"].map { |row| Record.new(row) }
14
+ def remove(path) = @t.json("POST", @s.path("/mounts:unmount"), body: { "path" => path }) && nil
15
+ end
16
+
17
+ class SandboxMCP < SandboxProduct
18
+ def start(servers:, port: nil, replace: nil, idempotency_key: nil)
19
+ # Secrets/env/options are customer dictionary keys, not SDK field names.
20
+ servers = servers.map do |entry|
21
+ entry.to_h.each_with_object({}) do |(key, value), out|
22
+ name = Fields.camel(key)
23
+ out[name] = %w[secrets env options].include?(name) ? Fields.stringify(value) : Fields.convert(value)
24
+ end
25
+ end
26
+ Record.new(@t.json("POST", @s.path("/mcp"), body: { "servers" => servers, "port" => port, "replace" => replace }.compact, key: idempotency_key))
27
+ end
28
+ def get = Record.new(@t.json("GET", @s.path("/mcp")))
29
+ def stop = @t.json("DELETE", @s.path("/mcp")) && nil
30
+ def ready(timeout: 600)
31
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
32
+ loop do
33
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ raise @t.connection_error(true, false, nil) unless left.positive?
35
+ state = Record.new(@t.json("GET", @s.path("/mcp"), timeout: left))
36
+ return state unless state.running && Array(state.servers).any? { |server| server.status == "installing" }
37
+ sleep([2, left].min)
38
+ end
39
+ end
40
+ end
41
+
42
+ class Recordings < SandboxProduct
43
+ def path(id = nil) = @s.path("/desktop/recordings") + (id ? "/#{Transport.segment(id)}" : "")
44
+ def start(idempotency_key: nil, **options) = Record.new(@t.json("POST", path, body: Fields.body(options), key: idempotency_key))
45
+ def get(id) = Record.new(@t.json("GET", path(id)))
46
+ def list = @t.json("GET", path)["data"].map { |row| Record.new(row) }
47
+ def stop(id) = Record.new(@t.json("POST", "#{path(id)}:stop", body: {}))
48
+ def download(id) = @t.bytes("GET", "#{path(id)}/video", accept: "video/mp4")
49
+ def delete(id) = @t.json("DELETE", path(id)) && nil
50
+ end
51
+ end