withruntime 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/withruntime/api_defaults.rb +17 -0
- data/lib/withruntime/client.rb +43 -9
- data/lib/withruntime/credentials.rb +31 -5
- data/lib/withruntime/errors.rb +9 -4
- data/lib/withruntime/network_products.rb +9 -9
- data/lib/withruntime/parity_products.rb +1 -1
- data/lib/withruntime/products.rb +8 -1
- data/lib/withruntime/sandbox.rb +150 -64
- data/lib/withruntime/tar.rb +218 -33
- data/lib/withruntime/transport.rb +189 -16
- data/lib/withruntime/version.rb +1 -1
- data/lib/withruntime/wallet.rb +68 -0
- data/lib/withruntime.rb +5 -2
- metadata +9 -4
- data/GUIDE.md +0 -406
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e403c64b0fd311fd7ba3fb2a9fab6304afda36100ebc1fe0bd30f6b8f82bcece
|
|
4
|
+
data.tar.gz: a6eb390df90ab8f676704e8181c535d2dd23a41eebec44efdbf0219f46f675c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a9994df04fad6ef2af83e9c1bb92e037b42e048bdf2806465d5969e43f7bc4ba9862fcbc77036d61b7d826579f9e0c316e302b05822cf6c326e6739a657ef677
|
|
7
|
+
data.tar.gz: de3343c87c44ea8dbed899137c0034de4683f64cb2ff44029ffcfbd6da98652f68d4fae8b53fbc90d3dc00a4ba5305d0239190eb756d8dff95ee54f0c0584043
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WithRuntime
|
|
4
|
+
# Every value this SDK sends the API for a bounded time field when the caller
|
|
5
|
+
# names none, in one place. packages/cloud/tests/sdk-defaults.test.ts holds
|
|
6
|
+
# each inside the range the API's registry gives that field, and the same
|
|
7
|
+
# names in the JavaScript, Python, Go and Java SDKs to theirs. The suffix is
|
|
8
|
+
# the unit; a new default goes here, under a name that test maps to its field.
|
|
9
|
+
module ApiDefaults
|
|
10
|
+
# A server-side wait for a state (GET /v1/sandboxes/{id}?waitFor=).
|
|
11
|
+
WAIT_FOR_TIMEOUT_SECONDS = 60
|
|
12
|
+
# A streamed exec's command limit when none is given: the API's longest.
|
|
13
|
+
STREAMED_EXEC_TIMEOUT_SECONDS = 86_400
|
|
14
|
+
# The time keep_alive keeps ahead of a time limit, and so the most it extends by.
|
|
15
|
+
KEEP_ALIVE_MARGIN_SECONDS = 600
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/withruntime/client.rb
CHANGED
|
@@ -7,25 +7,36 @@ module WithRuntime
|
|
|
7
7
|
# is safe for use from many threads and keeps its connections open.
|
|
8
8
|
#
|
|
9
9
|
# runtime = WithRuntime::Client.new # RUNTIME_API_KEY, or this machine's saved connection
|
|
10
|
-
# sbx = runtime.sandboxes.create
|
|
11
|
-
#
|
|
10
|
+
# sbx = runtime.sandboxes.create
|
|
11
|
+
# sbx.files.write("/workspace/invoice.py", "print(sum([125, 250, 375]))\n")
|
|
12
|
+
# puts sbx.exec("python3 /workspace/invoice.py", check: true).stdout
|
|
12
13
|
# sbx.stop
|
|
13
14
|
#
|
|
14
15
|
# Every product is +runtime.<product>.<verb>+. Money is integer microdollars
|
|
15
16
|
# (1,000,000 is one US dollar).
|
|
17
|
+
# The account's networks: +wireguard+, a tunnel from your own network into
|
|
18
|
+
# your sandboxes.
|
|
19
|
+
Networks = Struct.new(:wireguard)
|
|
20
|
+
|
|
21
|
+
# The day the 6 October 2026 rename stops serving its old names, set when it
|
|
22
|
+
# ships (packages/cloud/src/product-names.ts); nil until then.
|
|
23
|
+
RENAME_RETIRES = nil
|
|
24
|
+
|
|
16
25
|
class Client
|
|
17
26
|
attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :referrals,
|
|
18
|
-
:switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :
|
|
27
|
+
:switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp
|
|
19
28
|
|
|
20
29
|
# +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
|
|
21
30
|
# +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
|
|
31
|
+
# Inside a Runtime sandbox, calls for https://api.withruntime.com go to
|
|
32
|
+
# http://runtime.internal.
|
|
22
33
|
# +timeout+: seconds for each call, retries included (300).
|
|
23
34
|
# +max_retries+: of transport failures, 429, 502, 503 and 504 (4).
|
|
24
35
|
# +max_connections+: calls in flight at once; streams do not count (32).
|
|
25
36
|
# +wait_for_capacity+: seconds a sandbox create keeps retrying, with the
|
|
26
|
-
# same key and input, when the
|
|
37
|
+
# same key and input, when the slots without credit, quota or region are full (120; 0 fails at once).
|
|
27
38
|
def initialize(api_key: nil, base_url: nil, timeout: 300, max_retries: 4, max_connections: 32,
|
|
28
|
-
wait_for_capacity: 120, env: ENV)
|
|
39
|
+
wait_for_capacity: 120, env: ENV, in_sandbox: -> { Credentials.in_runtime_sandbox? })
|
|
29
40
|
origin = Credentials.origin(base_url || env["RUNTIME_API_URL"].then { |url| url.nil? || url.empty? ? DEFAULT_BASE_URL : url })
|
|
30
41
|
key = api_key
|
|
31
42
|
key ||= env["RUNTIME_API_KEY"] unless env["RUNTIME_API_KEY"].to_s.empty?
|
|
@@ -34,7 +45,10 @@ module WithRuntime
|
|
|
34
45
|
raise ArgumentError, "The Runtime API key contains whitespace." if key.match?(/\s/)
|
|
35
46
|
raise ArgumentError, "max_retries cannot be negative." if max_retries.negative?
|
|
36
47
|
|
|
37
|
-
|
|
48
|
+
# The saved key is found by the origin as given; the calls go where that
|
|
49
|
+
# origin is reachable from here.
|
|
50
|
+
@transport = Transport.new(api_key: key, base_url: Credentials.reachable(origin, in_sandbox: in_sandbox),
|
|
51
|
+
timeout: timeout, max_retries: max_retries,
|
|
38
52
|
max_connections: max_connections, wait_for_capacity: [0, wait_for_capacity].max)
|
|
39
53
|
@sandboxes = Sandboxes.new(@transport)
|
|
40
54
|
@snapshots = Snapshots.new(@transport)
|
|
@@ -52,18 +66,23 @@ module WithRuntime
|
|
|
52
66
|
@support = Support.new(@transport)
|
|
53
67
|
@domains = Domains.new(@transport)
|
|
54
68
|
@ports = Ports.new(@transport)
|
|
55
|
-
@
|
|
56
|
-
@
|
|
69
|
+
@ips = Addresses.new(@transport)
|
|
70
|
+
@networks = Networks.new(PrivateTunnel.new(@transport))
|
|
57
71
|
@sso = SSO.new(@transport)
|
|
58
72
|
@mcp = MCP.new(@transport)
|
|
59
73
|
end
|
|
60
74
|
|
|
75
|
+
# Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
|
|
76
|
+
# until RENAME_RETIRES, then raises name_retired.
|
|
77
|
+
def addresses = renamed("addresses", "ips") { @ips }
|
|
78
|
+
def tunnel = renamed("tunnel", "networks.wireguard") { @networks.wireguard }
|
|
79
|
+
|
|
61
80
|
def base_url = @transport.base_url
|
|
62
81
|
|
|
63
82
|
# Who this key is: organization, agent and credential.
|
|
64
83
|
def me = Record.new(@transport.json("GET", "/v1/me"))
|
|
65
84
|
|
|
66
|
-
# The account's credit, holds,
|
|
85
|
+
# The account's credit, holds, free time and per-resource charges.
|
|
67
86
|
def usage = Record.new(@transport.json("GET", "/v1/usage"))
|
|
68
87
|
|
|
69
88
|
# Calls any API path with this client's key, retries, idempotency keys and
|
|
@@ -75,5 +94,20 @@ module WithRuntime
|
|
|
75
94
|
body = {} if body.nil? && !%w[GET DELETE].include?(method)
|
|
76
95
|
@transport.json(method, path, query: query, body: body)
|
|
77
96
|
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def renamed(old, new, retires: RENAME_RETIRES, now: Time.now.utc)
|
|
101
|
+
if retires && now >= Time.utc(*retires.split("-").map(&:to_i))
|
|
102
|
+
raise Error.new("runtime.#{old} was renamed runtime.#{new} and stopped working on #{retires}.",
|
|
103
|
+
code: "name_retired", status: 410, hint: "Use runtime.#{new}.")
|
|
104
|
+
end
|
|
105
|
+
@renamed_said ||= {}
|
|
106
|
+
unless @renamed_said[old]
|
|
107
|
+
@renamed_said[old] = true
|
|
108
|
+
warn("Runtime: runtime.#{old} is now runtime.#{new}.#{retires ? " It stops working on #{retires}." : ""}")
|
|
109
|
+
end
|
|
110
|
+
yield
|
|
111
|
+
end
|
|
78
112
|
end
|
|
79
113
|
end
|
|
@@ -12,22 +12,48 @@ module WithRuntime
|
|
|
12
12
|
module Credentials
|
|
13
13
|
KEY = /\Artcloud_[a-f0-9-]{36}_[A-Za-z0-9_-]{43}\z/
|
|
14
14
|
|
|
15
|
+
BAD_ORIGIN = "Use an HTTPS API origin (or http://runtime.internal inside a sandbox, http://localhost for tests)."
|
|
16
|
+
# Runtime's API as code inside a Runtime sandbox reaches it: the sandbox's
|
|
17
|
+
# own host sends each request on to the public API over HTTPS. The API runs
|
|
18
|
+
# on that host, whose addresses a sandbox cannot reach directly. Plain HTTP
|
|
19
|
+
# because the hop never leaves the machine: from the program to the guest's
|
|
20
|
+
# own proxy, then over the sandbox's private channel to its host.
|
|
21
|
+
SANDBOX_BASE_URL = "http://runtime.internal"
|
|
22
|
+
# A file every Runtime sandbox has; the guest keeps it current.
|
|
23
|
+
SANDBOX_MARKER = "/run/runtime/environment.json"
|
|
24
|
+
|
|
15
25
|
module_function
|
|
16
26
|
|
|
17
|
-
|
|
27
|
+
def in_runtime_sandbox?(marker = SANDBOX_MARKER)
|
|
28
|
+
File.exist?(marker)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The origin calls for +api_origin+ are sent to from here. In a sandbox the
|
|
32
|
+
# public API is its own host, which it cannot reach directly, so calls for
|
|
33
|
+
# it go to runtime.internal; every other origin is left as it is.
|
|
34
|
+
def reachable(api_origin, in_sandbox: -> { in_runtime_sandbox? })
|
|
35
|
+
api_origin == DEFAULT_BASE_URL && in_sandbox.call ? SANDBOX_BASE_URL : api_origin
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# An HTTPS origin, plain HTTP to runtime.internal inside a sandbox, or plain
|
|
39
|
+
# HTTP to localhost for tests, with nothing after the host. runtime.internal
|
|
40
|
+
# is reserved and never resolves outside a sandbox, so a key sent there in
|
|
41
|
+
# plain HTTP never leaves the sandbox's host.
|
|
18
42
|
def origin(value)
|
|
19
43
|
uri = URI(value)
|
|
20
|
-
|
|
44
|
+
internal = uri.host == "runtime.internal"
|
|
45
|
+
local = %w[localhost 127.0.0.1 [::1] ::1].include?(uri.host) || internal
|
|
21
46
|
unless uri.host && (uri.scheme == "https" || (local && uri.scheme == "http")) && uri.userinfo.nil? &&
|
|
22
|
-
uri.query.nil? && uri.fragment.nil? && ["", "/"].include?(uri.path.to_s)
|
|
23
|
-
|
|
47
|
+
uri.query.nil? && uri.fragment.nil? && ["", "/"].include?(uri.path.to_s) &&
|
|
48
|
+
!(internal && (uri.scheme != "http" || uri.port != 80))
|
|
49
|
+
raise ArgumentError, BAD_ORIGIN
|
|
24
50
|
end
|
|
25
51
|
|
|
26
52
|
default_port = uri.scheme == "https" ? 443 : 80
|
|
27
53
|
host = uri.host.include?(":") && !uri.host.start_with?("[") ? "[#{uri.host}]" : uri.host
|
|
28
54
|
"#{uri.scheme}://#{host}#{uri.port == default_port ? "" : ":#{uri.port}"}"
|
|
29
55
|
rescue URI::InvalidURIError
|
|
30
|
-
raise ArgumentError,
|
|
56
|
+
raise ArgumentError, BAD_ORIGIN
|
|
31
57
|
end
|
|
32
58
|
|
|
33
59
|
# Where the CLI keeps this machine's connection for the two origins.
|
data/lib/withruntime/errors.rb
CHANGED
|
@@ -7,11 +7,16 @@ module WithRuntime
|
|
|
7
7
|
# Java SDKs.
|
|
8
8
|
class Error < StandardError
|
|
9
9
|
# 503s that are a deliberate state, not a passing one: retrying cannot change them.
|
|
10
|
-
DELIBERATE = %w[
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
DELIBERATE = %w[
|
|
11
|
+
unavailable unsupported fork_unavailable previews_unavailable
|
|
12
|
+
network_unavailable network_rules_unavailable secrets_unavailable identity_unavailable env_unavailable
|
|
13
|
+
resize_unavailable
|
|
14
|
+
].freeze
|
|
15
|
+
# Refusals that pass on their own: a host frees room, a sandbox of an
|
|
16
|
+
# account without credit stops.
|
|
17
|
+
PASSING = %w[no_capacity no_credit_running_limit no_credit_total_limit].freeze
|
|
13
18
|
# Refusals of a create that clear when a sandbox stops or a host frees room.
|
|
14
|
-
WAITS_FOR_ROOM = %w[
|
|
19
|
+
WAITS_FOR_ROOM = %w[no_credit_running_limit no_credit_total_limit no_credit_domain_limit no_credit_capacity_full quota_exceeded no_capacity volume_releasing].freeze
|
|
15
20
|
|
|
16
21
|
attr_reader :code, :status, :hint, :request_id, :details, :idempotency_key, :retry_after
|
|
17
22
|
|
|
@@ -20,24 +20,24 @@ module WithRuntime
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
class Addresses < Product
|
|
23
|
-
def reserve(family: 4) = Record.new(@t.json("POST", "/v1/
|
|
24
|
-
def list = @t.json("GET", "/v1/
|
|
25
|
-
def release(id) = @t.json("DELETE", "/v1/
|
|
23
|
+
def reserve(family: 4) = Record.new(@t.json("POST", "/v1/ips", body: { "family" => family }))
|
|
24
|
+
def list = @t.json("GET", "/v1/ips")["data"].map { |row| Record.new(row) }
|
|
25
|
+
def release(id) = @t.json("DELETE", "/v1/ips/#{seg(id)}") && nil
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Account WireGuard networking, distinct from Sandbox#open_tunnel.
|
|
29
29
|
class PrivateTunnel < Product
|
|
30
|
-
def get = Record.new(@t.json("GET", "/v1/
|
|
31
|
-
def create(subnet: nil) = Record.new(@t.json("POST", "/v1/
|
|
32
|
-
def delete = @t.json("DELETE", "/v1/
|
|
30
|
+
def get = Record.new(@t.json("GET", "/v1/networks/wireguard"))
|
|
31
|
+
def create(subnet: nil) = Record.new(@t.json("POST", "/v1/networks/wireguard", body: { "subnet" => subnet }.compact))
|
|
32
|
+
def delete = @t.json("DELETE", "/v1/networks/wireguard") && nil
|
|
33
33
|
# Save config even when config_ready is false: its private key is shown once.
|
|
34
34
|
def add_peer(name:, public_key: nil, routes: nil, idempotency_key: nil)
|
|
35
|
-
Record.new(@t.json("POST", "/v1/
|
|
35
|
+
Record.new(@t.json("POST", "/v1/networks/wireguard/peers", body: Fields.body(name: name, public_key: public_key, routes: routes), key: idempotency_key, no_retry: public_key.to_s.empty?))
|
|
36
36
|
end
|
|
37
37
|
def rotate_peer(id, public_key: nil)
|
|
38
|
-
Record.new(@t.json("POST", "/v1/
|
|
38
|
+
Record.new(@t.json("POST", "/v1/networks/wireguard/peers/#{seg(id)}:rotate", body: { "publicKey" => public_key }.compact, no_retry: public_key.to_s.empty?))
|
|
39
39
|
end
|
|
40
|
-
def remove_peer(id) = Record.new(@t.json("DELETE", "/v1/
|
|
40
|
+
def remove_peer(id) = Record.new(@t.json("DELETE", "/v1/networks/wireguard/peers/#{seg(id)}"))
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
class SSO < Product
|
|
@@ -45,7 +45,7 @@ module WithRuntime
|
|
|
45
45
|
def get(id) = Record.new(@t.json("GET", path(id)))
|
|
46
46
|
def list = @t.json("GET", path)["data"].map { |row| Record.new(row) }
|
|
47
47
|
def stop(id) = Record.new(@t.json("POST", "#{path(id)}:stop", body: {}))
|
|
48
|
-
def download(id) = @t.
|
|
48
|
+
def download(id) = @t.file_bytes("#{path(id)}/video", accept: "video/mp4")
|
|
49
49
|
def delete(id) = @t.json("DELETE", path(id)) && nil
|
|
50
50
|
end
|
|
51
51
|
end
|
data/lib/withruntime/products.rb
CHANGED
|
@@ -71,6 +71,12 @@ module WithRuntime
|
|
|
71
71
|
|
|
72
72
|
def list(state: nil, name: nil, limit: nil) = page("/v1/volumes", { "state" => state, "name" => name, "limit" => limit })
|
|
73
73
|
|
|
74
|
+
# Grows a volume no sandbox holds to +size_mib+, on its host, and waits
|
|
75
|
+
# (up to +wait+ seconds) for it. The answer's +resize+ shows how far it got.
|
|
76
|
+
def resize(id, size_mib:, wait: 10, idempotency_key: nil)
|
|
77
|
+
Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:resize", body: Fields.body(size_mib: size_mib), wait: wait, key: idempotency_key))
|
|
78
|
+
end
|
|
79
|
+
|
|
74
80
|
# Deletes a volume and everything on it.
|
|
75
81
|
def delete(id) = Record.new(@t.json("POST", "/v1/volumes/#{seg(id)}:delete", body: {}))
|
|
76
82
|
end
|
|
@@ -103,7 +109,8 @@ module WithRuntime
|
|
|
103
109
|
def get = Record.new(@t.json("GET", "/v1/limits"))
|
|
104
110
|
end
|
|
105
111
|
|
|
106
|
-
# +runtime.referrals+: the account's referral link and what it earned.
|
|
112
|
+
# +runtime.referrals+: the account's referral link and what it earned. Money is
|
|
113
|
+
# integer microdollars in strings.
|
|
107
114
|
class Referrals < Product
|
|
108
115
|
def get = Record.new(@t.json("GET", "/v1/referrals"))
|
|
109
116
|
end
|
data/lib/withruntime/sandbox.rb
CHANGED
|
@@ -13,22 +13,28 @@ module WithRuntime
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Creates a sandbox and, unless +wait: false+, waits until it is running.
|
|
16
|
-
# Every field is optional: with none you get the free
|
|
16
|
+
# Every field is optional: with none you get the free usage while it lasts,
|
|
17
17
|
# the default region and a 2 vCPU / 4 GiB machine for up to 30 minutes.
|
|
18
|
-
# When every
|
|
18
|
+
# When every slot without credit or the account's quota is taken, it waits for one to
|
|
19
19
|
# free, up to +wait_for_capacity+ seconds (the client's, two minutes by default).
|
|
20
20
|
#
|
|
21
|
-
# runtime.sandboxes.create(
|
|
21
|
+
# runtime.sandboxes.create(labels: { team: "search" }, timeout_seconds: 900)
|
|
22
22
|
def create(wait: true, idempotency_key: nil, wait_for_capacity: nil, **fields)
|
|
23
23
|
room = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
|
|
24
24
|
info = @t.json("POST", "/v1/sandboxes", body: Fields.body(fields), wait: wait ? 60 : 0,
|
|
25
25
|
key: idempotency_key, room: room)
|
|
26
26
|
sandbox = Sandbox.new(@t, info)
|
|
27
27
|
if wait && sandbox.state != "running"
|
|
28
|
-
sandbox.wait_for("running", timeout:
|
|
28
|
+
sandbox.wait_for("running", timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
|
|
29
29
|
unless sandbox.state == "running"
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
message = if info["replayed"]
|
|
31
|
+
"Sandbox #{sandbox.id} was made by an earlier call with this idempotency key and is #{sandbox.state} now. " \
|
|
32
|
+
"It did not fail to start; create again with a new key."
|
|
33
|
+
else
|
|
34
|
+
"Sandbox #{sandbox.id} is #{sandbox.state}, not running."
|
|
35
|
+
end
|
|
36
|
+
raise Error.new(message, code: "start_failed",
|
|
37
|
+
hint: "Read it with runtime.sandboxes.get(id); stop_reason says why.")
|
|
32
38
|
end
|
|
33
39
|
end
|
|
34
40
|
sandbox
|
|
@@ -94,7 +100,7 @@ module WithRuntime
|
|
|
94
100
|
|
|
95
101
|
# Waits, on the server with no polling, until the sandbox reaches +state+
|
|
96
102
|
# (running, paused or stopped) or +timeout+ seconds pass.
|
|
97
|
-
def wait_for(state, timeout:
|
|
103
|
+
def wait_for(state, timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
|
|
98
104
|
seconds = [1, timeout.to_i].max
|
|
99
105
|
@info = Record.new(@t.json("GET", path, query: { "waitFor" => state, "timeoutSeconds" => seconds },
|
|
100
106
|
timeout: seconds + 60))
|
|
@@ -110,12 +116,14 @@ module WithRuntime
|
|
|
110
116
|
# Saves the sandbox's memory and files; compute billing stops.
|
|
111
117
|
def pause(wait: true, idempotency_key: nil) = lifecycle("pause", {}, wait, idempotency_key)
|
|
112
118
|
|
|
113
|
-
# Carries on a paused sandbox, with its memory and processes;
|
|
119
|
+
# Carries on a paused sandbox, with its memory and processes;
|
|
120
|
+
# +timeout_seconds+ is its new time limit, for a sandbox that has one.
|
|
114
121
|
def wake(wait: true, timeout_seconds: nil, idempotency_key: nil)
|
|
115
122
|
lifecycle("wake", timeout_seconds ? { "timeoutSeconds" => timeout_seconds } : {}, wait, idempotency_key)
|
|
116
123
|
end
|
|
117
124
|
|
|
118
|
-
#
|
|
125
|
+
# Moves a sandbox's time limit on, at most an hour ahead of now. A sandbox
|
|
126
|
+
# with no time limit answers at once and nothing changes.
|
|
119
127
|
def extend_lease(seconds, idempotency_key: nil) = lifecycle("extend", { "seconds" => seconds }, false, idempotency_key)
|
|
120
128
|
|
|
121
129
|
# Days (1 to 365) a paused sandbox is kept before it is deleted.
|
|
@@ -133,12 +141,13 @@ module WithRuntime
|
|
|
133
141
|
lifecycle("update", body, false, idempotency_key)
|
|
134
142
|
end
|
|
135
143
|
|
|
136
|
-
# Keeps a
|
|
137
|
-
# +stop+ or +stop_keep_alive+: every +every+ seconds it extends the
|
|
138
|
-
# that +margin+ seconds remain, never more than the hour ahead the
|
|
139
|
-
# allows.
|
|
140
|
-
#
|
|
141
|
-
|
|
144
|
+
# Keeps a sandbox with a time limit running past it on a background thread,
|
|
145
|
+
# until +stop+ or +stop_keep_alive+: every +every+ seconds it extends the
|
|
146
|
+
# limit so that +margin+ seconds remain, never more than the hour ahead the
|
|
147
|
+
# API allows. A sandbox with no time limit needs none and is only watched.
|
|
148
|
+
# Running time is billed as it is used. A paused sandbox is left paused; a
|
|
149
|
+
# stopped one ends the loop.
|
|
150
|
+
def keep_alive(every: 60, margin: ApiDefaults::KEEP_ALIVE_MARGIN_SECONDS, &on_error)
|
|
142
151
|
stop_keep_alive
|
|
143
152
|
every = [10, every].max
|
|
144
153
|
margin = margin.clamp(60, 3600)
|
|
@@ -148,7 +157,7 @@ module WithRuntime
|
|
|
148
157
|
refresh
|
|
149
158
|
break if %w[stopped stopping].include?(state)
|
|
150
159
|
|
|
151
|
-
expires = @info
|
|
160
|
+
expires = Sandbox.limit_end(@info)
|
|
152
161
|
if state == "running" && expires
|
|
153
162
|
need = (margin - (Time.parse(expires) - Time.now)).ceil
|
|
154
163
|
extend_lease([3600, need].min) if need >= 1
|
|
@@ -163,6 +172,17 @@ module WithRuntime
|
|
|
163
172
|
self
|
|
164
173
|
end
|
|
165
174
|
|
|
175
|
+
# Where a sandbox's time limit ends, or nil when it has none: it renews
|
|
176
|
+
# itself (timeoutSeconds 0, or persistent) unless endsAt says its renewal
|
|
177
|
+
# stopped. An older server sends no endsAt and a timeout of at least a
|
|
178
|
+
# minute.
|
|
179
|
+
def self.limit_end(info)
|
|
180
|
+
return info["endsAt"] if info["endsAt"]
|
|
181
|
+
return nil if info["timeoutSeconds"].to_i.zero? || info["persistent"]
|
|
182
|
+
|
|
183
|
+
info["expiresAt"]
|
|
184
|
+
end
|
|
185
|
+
|
|
166
186
|
# Ends a keep-alive, if one runs.
|
|
167
187
|
def stop_keep_alive
|
|
168
188
|
thread = @keep_alive
|
|
@@ -174,8 +194,9 @@ module WithRuntime
|
|
|
174
194
|
# processes), each its own sandbox, on the same server, answered once they
|
|
175
195
|
# run. One copy without +count+; an array with it. If a copy fails, the
|
|
176
196
|
# error's +details["startedSandboxIds"]+ names the copies that did start.
|
|
197
|
+
# +funding+ is accepted and ignored: copies run on free usage first, then credit.
|
|
177
198
|
def fork(count: nil, name: nil, labels: nil, keep_snapshot: nil, funding: nil, idempotency_key: nil)
|
|
178
|
-
body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot
|
|
199
|
+
body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot)
|
|
179
200
|
reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
|
|
180
201
|
copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
|
|
181
202
|
count.nil? ? copies.first : copies
|
|
@@ -239,43 +260,89 @@ module WithRuntime
|
|
|
239
260
|
idempotency_key: idempotency_key)
|
|
240
261
|
end
|
|
241
262
|
|
|
242
|
-
|
|
263
|
+
longest = ApiDefaults::STREAMED_EXEC_TIMEOUT_SECONDS
|
|
264
|
+
body = command_body(command, cwd, env, stdin, timeout || longest).merge("stream" => true)
|
|
243
265
|
handed_over = nil
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
266
|
+
process_id = nil
|
|
267
|
+
cursor = 0
|
|
268
|
+
exited = false
|
|
269
|
+
begin
|
|
270
|
+
@t.events("POST", path(":exec"), body: body, key: idempotency_key,
|
|
271
|
+
timeout: timeout ? timeout + 60 : longest) do |event|
|
|
272
|
+
case event["type"]
|
|
273
|
+
when "continue" then handed_over = event
|
|
274
|
+
when "error" then raise stream_error(event)
|
|
275
|
+
else
|
|
276
|
+
process_id = event["processId"] if event["type"] == "start"
|
|
277
|
+
cursor = event["offset"] + event["data"].bytesize if %w[stdout stderr].include?(event["type"])
|
|
278
|
+
exited = true if event["type"] == "exit"
|
|
279
|
+
yield event
|
|
280
|
+
end
|
|
281
|
+
break if handed_over || exited
|
|
250
282
|
end
|
|
251
|
-
|
|
283
|
+
rescue ConnectionError => e
|
|
284
|
+
raise if e.code != "connection_error" || process_id.nil?
|
|
252
285
|
end
|
|
253
|
-
follow(handed_over["processId"], handed_over["cursor"], &block) if handed_over
|
|
286
|
+
return follow(handed_over["processId"], handed_over["cursor"], &block) if handed_over
|
|
287
|
+
return if exited
|
|
288
|
+
|
|
289
|
+
# The stream closed before the command's exit. Its output is kept on the
|
|
290
|
+
# sandbox, so follow it from where this stream stopped; an end with no
|
|
291
|
+
# exit is never a result.
|
|
292
|
+
if process_id.nil?
|
|
293
|
+
raise ConnectionError.new("The exec stream closed before the command started.",
|
|
294
|
+
code: "connection_error", idempotency_key: idempotency_key,
|
|
295
|
+
hint: "Run it again; to be sure it runs once, pass the same idempotency_key.")
|
|
296
|
+
end
|
|
297
|
+
follow(process_id, cursor, &block)
|
|
254
298
|
end
|
|
255
299
|
|
|
256
|
-
# A process's output events from +cursor+ until it exits, across the
|
|
300
|
+
# A process's output events from +cursor+ until it exits, across the
|
|
301
|
+
# server's stream slices. A stream that closes or is cut before the exit
|
|
302
|
+
# is followed again from where it stopped; four in a row with nothing new
|
|
303
|
+
# are an error, never an end.
|
|
257
304
|
def follow(process_id, cursor = 0)
|
|
258
305
|
return enum_for(:follow, process_id, cursor) unless block_given?
|
|
259
306
|
|
|
307
|
+
idle = 0
|
|
260
308
|
loop do
|
|
261
309
|
resume = nil
|
|
262
310
|
exited = false
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
311
|
+
before = cursor
|
|
312
|
+
cut = false
|
|
313
|
+
begin
|
|
314
|
+
@t.events("GET", path("/processes/#{Transport.segment(process_id)}/output"),
|
|
315
|
+
query: { "cursor" => cursor, "follow" => "true" }, timeout: 180) do |event|
|
|
316
|
+
case event["type"]
|
|
317
|
+
when "continue"
|
|
318
|
+
resume = event["cursor"]
|
|
319
|
+
break
|
|
320
|
+
when "stdout", "stderr" then cursor = event["offset"] + event["data"].bytesize
|
|
321
|
+
when "error" then raise stream_error(event)
|
|
322
|
+
when "exit" then exited = true
|
|
323
|
+
end
|
|
324
|
+
yield event
|
|
325
|
+
break if exited
|
|
272
326
|
end
|
|
273
|
-
|
|
274
|
-
|
|
327
|
+
rescue ConnectionError => e
|
|
328
|
+
raise if e.code != "connection_error"
|
|
329
|
+
|
|
330
|
+
cut = true
|
|
275
331
|
end
|
|
276
|
-
break if exited
|
|
332
|
+
break if exited
|
|
277
333
|
|
|
278
|
-
cursor = resume
|
|
334
|
+
cursor = [cursor, resume].max if resume
|
|
335
|
+
if resume || cursor > before
|
|
336
|
+
idle = 0
|
|
337
|
+
next
|
|
338
|
+
end
|
|
339
|
+
idle += 1
|
|
340
|
+
if idle > 3
|
|
341
|
+
raise ConnectionError.new("The output stream of process #{process_id} keeps closing before it ends.",
|
|
342
|
+
code: "connection_error",
|
|
343
|
+
hint: "Read what it printed so far: runtime sandboxes logs #{id} #{process_id}")
|
|
344
|
+
end
|
|
345
|
+
sleep([0.05 * (2**idle), 2].min) if cut
|
|
279
346
|
end
|
|
280
347
|
end
|
|
281
348
|
|
|
@@ -421,7 +488,7 @@ module WithRuntime
|
|
|
421
488
|
end
|
|
422
489
|
|
|
423
490
|
# A file's bytes (a binary String), any size.
|
|
424
|
-
def read(path) = t.
|
|
491
|
+
def read(path) = t.file_bytes(@sandbox.path("/files/content"), query: { "path" => path })
|
|
425
492
|
|
|
426
493
|
def read_text(path) = read(path).force_encoding(Encoding::UTF_8)
|
|
427
494
|
|
|
@@ -536,20 +603,38 @@ module WithRuntime
|
|
|
536
603
|
nil
|
|
537
604
|
end
|
|
538
605
|
|
|
539
|
-
# Copies a local file or directory in. A directory travels as one gzipped
|
|
606
|
+
# Copies a local file or directory in. A directory travels as one gzipped
|
|
607
|
+
# tar to the API's folder routes, unpacked by the sandbox's own tar as it
|
|
608
|
+
# arrives.
|
|
540
609
|
def upload(local, remote)
|
|
541
610
|
return write(remote, File.binread(local)) unless File.directory?(local)
|
|
542
611
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
612
|
+
archive = Tar.pack_directory(local)
|
|
613
|
+
if archive.bytesize <= CHUNK
|
|
614
|
+
t.bytes("PUT", @sandbox.path("/files/archive"), query: { "path" => remote }, raw: archive)
|
|
615
|
+
return nil
|
|
616
|
+
end
|
|
617
|
+
# Larger: parts in order; a part resent after a lost answer is not written twice.
|
|
618
|
+
upload = t.json("POST", @sandbox.path("/files/archive/uploads"), body: { "path" => remote, "gzip" => true })
|
|
619
|
+
base = @sandbox.path("/files/archive/uploads/#{Transport.segment(upload["uploadId"])}")
|
|
620
|
+
begin
|
|
621
|
+
(0...archive.bytesize).step(upload["chunkBytes"].to_i) do |offset|
|
|
622
|
+
t.bytes("PUT", base, query: { "offset" => offset }, raw: archive.byteslice(offset, upload["chunkBytes"].to_i))
|
|
623
|
+
end
|
|
624
|
+
t.json("POST", "#{base}:commit", body: {})
|
|
625
|
+
rescue StandardError
|
|
626
|
+
begin
|
|
627
|
+
t.json("POST", "#{base}:abort", body: {}, timeout: 10)
|
|
628
|
+
rescue Error
|
|
629
|
+
nil
|
|
630
|
+
end
|
|
631
|
+
raise
|
|
632
|
+
end
|
|
549
633
|
nil
|
|
550
634
|
end
|
|
551
635
|
|
|
552
|
-
# Copies a file or directory out.
|
|
636
|
+
# Copies a file or directory out. A directory comes as one tar from the
|
|
637
|
+
# API's folder route.
|
|
553
638
|
def download(remote, local)
|
|
554
639
|
entry = stat(remote)
|
|
555
640
|
raise NotFoundError.new("#{remote} does not exist.", code: "file_not_found", status: 404) unless entry
|
|
@@ -559,18 +644,14 @@ module WithRuntime
|
|
|
559
644
|
File.binwrite(local, read(remote))
|
|
560
645
|
return nil
|
|
561
646
|
end
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
647
|
+
# The sandbox's own tar packs it as it streams, and it is unpacked as it
|
|
648
|
+
# arrives; one cut short is refused and nothing is put in place.
|
|
649
|
+
unpacker = Tar::Unpacker.new(local)
|
|
566
650
|
begin
|
|
567
|
-
|
|
651
|
+
t.chunks(@sandbox.path("/files/archive"), query: { "path" => remote, "gzip" => true }) { |chunk| unpacker.feed(chunk) }
|
|
652
|
+
unpacker.finish
|
|
568
653
|
ensure
|
|
569
|
-
|
|
570
|
-
remove(staging)
|
|
571
|
-
rescue Error
|
|
572
|
-
nil # /tmp is cleared with the sandbox.
|
|
573
|
-
end
|
|
654
|
+
unpacker.discard
|
|
574
655
|
end
|
|
575
656
|
nil
|
|
576
657
|
end
|
|
@@ -587,11 +668,16 @@ module WithRuntime
|
|
|
587
668
|
@sandbox = sandbox
|
|
588
669
|
end
|
|
589
670
|
|
|
590
|
-
# Shares +port+, or changes its visibility if it is
|
|
591
|
-
# +visibility+ is "private" (the default: a token is
|
|
592
|
-
# +ttl_seconds+ is how long the returned token lasts
|
|
593
|
-
|
|
594
|
-
|
|
671
|
+
# Shares +port+, or changes its visibility and embed origins if it is
|
|
672
|
+
# shared already. +visibility+ is "private" (the default: a token is
|
|
673
|
+
# needed) or "public"; +ttl_seconds+ is how long the returned token lasts
|
|
674
|
+
# (60 s to 7 days). +embed_origins+ names the sites that may show it in an
|
|
675
|
+
# iframe (["https://app.example.com", "https://*.example.com"]); any other
|
|
676
|
+
# site's iframe is refused. Omitted, a shared port keeps its list; [] is
|
|
677
|
+
# any site, the default.
|
|
678
|
+
def create(port, visibility: nil, ttl_seconds: nil, embed_origins: nil)
|
|
679
|
+
body = { "port" => port, "visibility" => visibility, "ttlSeconds" => ttl_seconds,
|
|
680
|
+
"embedOrigins" => embed_origins }.compact
|
|
595
681
|
Record.new(t.json("POST", path, body: body))
|
|
596
682
|
end
|
|
597
683
|
|