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.
- checksums.yaml +7 -0
- data/GUIDE.md +406 -0
- data/LICENSE +55 -0
- data/lib/withruntime/client.rb +79 -0
- data/lib/withruntime/credentials.rb +85 -0
- data/lib/withruntime/errors.rb +114 -0
- data/lib/withruntime/fields.rb +40 -0
- data/lib/withruntime/file_watch.rb +73 -0
- data/lib/withruntime/images.rb +258 -0
- data/lib/withruntime/network_products.rb +49 -0
- data/lib/withruntime/parity_products.rb +51 -0
- data/lib/withruntime/products.rb +373 -0
- data/lib/withruntime/record.rb +85 -0
- data/lib/withruntime/sandbox.rb +618 -0
- data/lib/withruntime/tar.rb +107 -0
- data/lib/withruntime/transport.rb +362 -0
- data/lib/withruntime/tunnel.rb +275 -0
- data/lib/withruntime/version.rb +5 -0
- data/lib/withruntime/websocket.rb +249 -0
- data/lib/withruntime.rb +32 -0
- metadata +64 -0
|
@@ -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
|