withruntime 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8912abd064916dd450a7fb5b9f0c1610c79b65918cf63a3a599b5906d46e3577
4
- data.tar.gz: c7e663ef88244b43c2c761bb6f55e43ea147631e7c22b9c19b7d68789510c0e0
3
+ metadata.gz: 53b4e63218b0587cacbd8e0ad8ec583ba4ce9b32ec0b792208b8f70750b5acd5
4
+ data.tar.gz: 3461a8000fc4817de3f06c6f6f40b6e5d29b49312a4e1f9c9dc94a649e91b8ae
5
5
  SHA512:
6
- metadata.gz: 041d53fd9761a1f1e0c9f94cfb70a77c6f0805329dbfbc3523f08cb7024ad4c3bcd06373e08fa6a391823ca48688d786be6643a2b2f460ad1c14c43ee8750c2b
7
- data.tar.gz: 4ad9e1df60b31416e7d35abb1499a2fd5e1c329e314a91d9f440f6b56ec2bde9975ae7765a030187f8c6dff540ad8268468188d301de716ae83fd3758117493c
6
+ metadata.gz: b1bb2aea12c2959b3afcd694ad90124d1c04fcf60201fdece3f5f1264b40bfa3b3f8deec865c5265d806b6e8c389ebacfd4da833648133fdba93b9ece3d7352f
7
+ data.tar.gz: d0238775905bea2408a2d930a402f49a4001bf900c3a8c13acbd511aaf5c486b7b01da8b5f93dfc188e50b696e135b8b92dfd75a7f9c74f5d4dcbdfcce63b668
@@ -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
- # +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).
36
+ # +wait_for_capacity+: seconds a sandbox create, wake or restart keeps retrying, with 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
- @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
@@ -98,12 +104,13 @@ module WithRuntime
98
104
  end
99
105
  end
100
106
 
101
- # +runtime.limits+: whether this key is read-only, and what its agent may still spend today.
107
+ # +runtime.limits+: whether this key is read-only, and what its agent may still spend today, plus +account+: the account's limits (limit, label, unit, max, used), or nil.
102
108
  class Limits < Product
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