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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8912abd064916dd450a7fb5b9f0c1610c79b65918cf63a3a599b5906d46e3577
4
- data.tar.gz: c7e663ef88244b43c2c761bb6f55e43ea147631e7c22b9c19b7d68789510c0e0
3
+ metadata.gz: e403c64b0fd311fd7ba3fb2a9fab6304afda36100ebc1fe0bd30f6b8f82bcece
4
+ data.tar.gz: a6eb390df90ab8f676704e8181c535d2dd23a41eebec44efdbf0219f46f675c6
5
5
  SHA512:
6
- metadata.gz: 041d53fd9761a1f1e0c9f94cfb70a77c6f0805329dbfbc3523f08cb7024ad4c3bcd06373e08fa6a391823ca48688d786be6643a2b2f460ad1c14c43ee8750c2b
7
- data.tar.gz: 4ad9e1df60b31416e7d35abb1499a2fd5e1c329e314a91d9f440f6b56ec2bde9975ae7765a030187f8c6dff540ad8268468188d301de716ae83fd3758117493c
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
@@ -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(funding: "trial")
11
- # puts sbx.exec("python3 -c 'print(6 * 7)'", check: true).stdout
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, :addresses, :tunnel, :sso, :mcp
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 trial, quota or region is full (120; 0 fails at once).
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
- @transport = Transport.new(api_key: key, base_url: origin, timeout: timeout, max_retries: max_retries,
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
- @addresses = Addresses.new(@transport)
56
- @tunnel = PrivateTunnel.new(@transport)
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, trial time and per-resource charges.
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
- # An HTTPS origin, or plain HTTP to localhost for tests, with nothing after the host.
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
- local = %w[localhost 127.0.0.1 [::1] ::1].include?(uri.host)
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
- raise ArgumentError, "Use an HTTPS API origin (or http://localhost for tests)."
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, "Use an HTTPS API origin (or http://localhost for tests)."
56
+ raise ArgumentError, BAD_ORIGIN
31
57
  end
32
58
 
33
59
  # Where the CLI keeps this machine's connection for the two origins.
@@ -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[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
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[trial_busy trial_domain_limit trial_capacity quota_exceeded no_capacity].freeze
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/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
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/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
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/tunnel/peers", body: Fields.body(name: name, public_key: public_key, routes: routes), key: idempotency_key, no_retry: public_key.to_s.empty?))
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/tunnel/peers/#{seg(id)}:rotate", body: { "publicKey" => public_key }.compact, no_retry: public_key.to_s.empty?))
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/tunnel/peers/#{seg(id)}"))
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.bytes("GET", "#{path(id)}/video", accept: "video/mp4")
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
@@ -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
@@ -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 trial while it lasts,
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 trial slot or the account's quota is taken, it waits for one to
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(funding: "trial", labels: { team: "search" }, timeout_seconds: 900)
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: 60)
28
+ sandbox.wait_for("running", timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
29
29
  unless sandbox.state == "running"
30
- raise Error.new("Sandbox #{sandbox.id} is #{sandbox.state}, not running.", code: "start_failed",
31
- hint: "Read it with runtime.sandboxes.get(id); stop_reason says why.")
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: 60)
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; +timeout_seconds+ is its new lease.
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
- # More time before the lease ends, at most an hour ahead of now.
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 running sandbox's lease ahead of now on a background thread, until
137
- # +stop+ or +stop_keep_alive+: every +every+ seconds it extends the lease so
138
- # that +margin+ seconds remain, never more than the hour ahead the API
139
- # allows. Running time is billed as it is used. A paused sandbox is left
140
- # paused; a stopped one ends the loop.
141
- def keep_alive(every: 60, margin: 600, &on_error)
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["expiresAt"]
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, funding: funding)
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
- body = command_body(command, cwd, env, stdin, timeout || 86_400).merge("stream" => true)
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
- @t.events("POST", path(":exec"), body: body, key: idempotency_key,
245
- timeout: timeout ? timeout + 60 : 86_400) do |event|
246
- case event["type"]
247
- when "continue" then handed_over = event
248
- when "error" then raise stream_error(event)
249
- else yield event
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
- break if handed_over
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 server's stream slices.
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
- @t.events("GET", path("/processes/#{Transport.segment(process_id)}/output"),
264
- query: { "cursor" => cursor, "follow" => "true" }, timeout: 180) do |event|
265
- case event["type"]
266
- when "continue"
267
- resume = event["cursor"]
268
- break
269
- when "stdout", "stderr" then cursor = event["offset"] + event["data"].bytesize
270
- when "error" then raise stream_error(event)
271
- when "exit" then exited = true
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
- yield event
274
- break if exited
327
+ rescue ConnectionError => e
328
+ raise if e.code != "connection_error"
329
+
330
+ cut = true
275
331
  end
276
- break if exited || resume.nil?
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.bytes("GET", @sandbox.path("/files/content"), query: { "path" => path }, accept: "application/octet-stream")
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 tar.
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
- staging = "/tmp/.runtime-upload-#{SecureRandom.uuid}.tar.gz"
544
- write(staging, Tar.pack_directory(local))
545
- result = @sandbox.exec(["sh", "-c", 'mkdir -p "$1" && tar -xzf "$2" -C "$1"; code=$?; rm -f "$2"; exit $code',
546
- "sh", remote, staging])
547
- raise CommandError, result unless result.ok?
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
- staging = "/tmp/.runtime-download-#{SecureRandom.uuid}.tar.gz"
563
- packed = @sandbox.exec(["tar", "-czf", staging, "-C", remote, "."])
564
- raise CommandError, packed unless packed.ok?
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
- Tar.unpack(read(staging), local)
651
+ t.chunks(@sandbox.path("/files/archive"), query: { "path" => remote, "gzip" => true }) { |chunk| unpacker.feed(chunk) }
652
+ unpacker.finish
568
653
  ensure
569
- begin
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 shared already.
591
- # +visibility+ is "private" (the default: a token is needed) or "public";
592
- # +ttl_seconds+ is how long the returned token lasts (60 s to 7 days).
593
- def create(port, visibility: nil, ttl_seconds: nil)
594
- body = { "port" => port, "visibility" => visibility, "ttlSeconds" => ttl_seconds }.compact
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