withruntime 0.5.0 → 0.8.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/agents.rb +79 -33
- data/lib/withruntime/apps.rb +246 -0
- data/lib/withruntime/builds.rb +225 -0
- data/lib/withruntime/client.rb +7 -1
- data/lib/withruntime/containers.rb +39 -0
- data/lib/withruntime/fields.rb +2 -2
- data/lib/withruntime/products.rb +8 -0
- data/lib/withruntime/sandbox.rb +14 -2
- data/lib/withruntime/version.rb +1 -1
- data/lib/withruntime.rb +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b82967a1882d29df57dcef740f00077ce3a9afd05a1fdb6b031122438adfb92a
|
|
4
|
+
data.tar.gz: d79d6dc698f67d2e8fb8ee287f9830c243607cb7e127935ec6df6b21b2f2d736
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3f846d1588e641fa9e781707be27af354a9b474e808b5657c918c6792a468b3cf49a08ea1eed6ecfe98591bec3d6935b3ff964c3382a8863ee143665ad936d0
|
|
7
|
+
data.tar.gz: 8839301368e7bf649663864afba4a313c7c0936b6c228888a6eb3d4ed72a517410502cf231f1f6e9da34d89c3ae8905fcb23307446700a68453a3d4e530bdae0
|
data/lib/withruntime/agents.rb
CHANGED
|
@@ -10,12 +10,44 @@ module WithRuntime
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
# One entry of a session's log: +seq+, +kind+ (input, answer, reply, ask,
|
|
13
|
-
# status, result, error
|
|
14
|
-
#
|
|
13
|
+
# status, result, error, note, or child: a child session's end, posted into
|
|
14
|
+
# its parent's log), +actor+, +body+ and +created_at+. +text+ is its words,
|
|
15
|
+
# +body.text+.
|
|
15
16
|
class AgentEvent < Record
|
|
16
17
|
def text = self["body"].is_a?(Hash) ? self["body"]["text"] : nil
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
# A session as the API answers it. Times are ISO 8601 strings.
|
|
21
|
+
#
|
|
22
|
+
# +id+, +agent_id+, +agent_version+, +key+, +trigger+ (api, slack, github,
|
|
23
|
+
# schedule, webhook, or spawn: a child its parent's harness started),
|
|
24
|
+
# +state+ (queued, running, waiting, done, failed or stopped), +reason+
|
|
25
|
+
# (why it ended; among others "budget", "parent_ended", and "deleted" on a
|
|
26
|
+
# deleted record), +result+, +sandbox_id+, +last_seq+, +labels+,
|
|
27
|
+
# +created_at+, +started_at+, +ended_at+.
|
|
28
|
+
#
|
|
29
|
+
# Its tree: +parent_session_id+ (nil for a top session), +root_session_id+
|
|
30
|
+
# (itself for a top session) and +depth+ (0 to 3). Its use: +cost+ (this
|
|
31
|
+
# session's so far) and +tree_cost+ (it and every session under it), each
|
|
32
|
+
# an AgentCost. Its storage: +history_bytes+ (its log, kept until the
|
|
33
|
+
# session is deleted) and +attachment_bytes+ (its files and screenshots).
|
|
34
|
+
# +stop_why+ is why a stop was asked ("stopped", "budget" or
|
|
35
|
+
# "parent_ended") or nil, and +deleted_at+ is set once its history was
|
|
36
|
+
# deleted (+sessions.delete+).
|
|
37
|
+
class AgentSessionInfo < Record
|
|
38
|
+
def cost = AgentCost.new(self["cost"])
|
|
39
|
+
def tree_cost = AgentCost.new(self["treeCost"])
|
|
40
|
+
def deleted? = !self["deletedAt"].nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What a session, or its tree, has used: +dollars+, +vcpu_seconds+ and
|
|
44
|
+
# +gib_seconds+ (0 when not given).
|
|
45
|
+
class AgentCost < Record
|
|
46
|
+
def dollars = self["dollars"] || 0
|
|
47
|
+
def vcpu_seconds = self["vcpuSeconds"] || 0
|
|
48
|
+
def gib_seconds = self["gibSeconds"] || 0
|
|
49
|
+
end
|
|
50
|
+
|
|
19
51
|
# +runtime.agents+: agents that run in their own sandbox per session, started
|
|
20
52
|
# by your own app through the API, Slack, GitHub, a schedule or a webhook. A private
|
|
21
53
|
# beta: an account outside it is refused +product_in_beta+
|
|
@@ -28,14 +60,18 @@ module WithRuntime
|
|
|
28
60
|
#
|
|
29
61
|
# A definition is a Hash, by snake_case keyword (+memory_mib:+) or by the
|
|
30
62
|
# API's own key ("memoryMiB"): +command+ (required), +from+ ({ image: } or
|
|
31
|
-
# { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
63
|
+
# { snapshot: }), +vcpu+ and +memory_mib+ (4 vCPU and 8 GiB unless set; a
|
|
64
|
+
# definition from a snapshot takes neither, InvalidRequestError, since a
|
|
65
|
+
# snapshot keeps its own size), +instructions+, +env+, +secrets+ (names of
|
|
66
|
+
# egress secrets: model keys go here), +mcp+ ([{ name:, url: }]), +volumes+
|
|
67
|
+
# ([{ volume:, path: }]), +idle_pause_seconds+, +max_session_seconds+,
|
|
68
|
+
# +max_concurrent+, +max_session_dollars+ (more than 0, at most 10000, 5
|
|
69
|
+
# unless set: the session and every session under it, the root's covering
|
|
70
|
+
# its tree; a tree at it stops, reason "budget"), +max_day_dollars+ (more
|
|
71
|
+
# than 0, at most 100000, 100 unless set: the agent's sessions started that
|
|
72
|
+
# UTC day; an agent at it starts nothing new, ConflictError
|
|
73
|
+
# +budget_reached+, until the next day) and +template+ ("support" or
|
|
74
|
+
# "team").
|
|
39
75
|
class Agents < Product
|
|
40
76
|
attr_reader :sessions, :triggers, :templates
|
|
41
77
|
|
|
@@ -99,37 +135,42 @@ module WithRuntime
|
|
|
99
135
|
AgentSession.new(@t, info, joined: false, seq: info["lastSeq"])
|
|
100
136
|
end
|
|
101
137
|
|
|
102
|
-
# Deletes an ended session
|
|
103
|
-
#
|
|
104
|
-
#
|
|
138
|
+
# Deletes an ended session's history: its log, and every log under it in
|
|
139
|
+
# its tree. Until then the history is kept, counted as storage. The
|
|
140
|
+
# session is kept as a deleted record: +get+ still reads it, with
|
|
141
|
+
# +deleted_at+ set, +reason+ "deleted", +key+ and +result+ cleared and its
|
|
142
|
+
# cost kept (a delete never frees budget), and its log reads empty. An
|
|
143
|
+
# open session or tree is refused +session_open+ (ConflictError): stop it
|
|
144
|
+
# first.
|
|
105
145
|
def delete(id) = @t.json("DELETE", "/v1/agents/sessions/#{seg(id)}") && nil
|
|
106
146
|
|
|
107
|
-
# The first page of sessions, as records; +each+ walks
|
|
108
|
-
# +agent+ is an id or a name; +state+ is queued, running,
|
|
109
|
-
# failed or stopped;
|
|
110
|
-
|
|
111
|
-
|
|
147
|
+
# The first page of sessions, as AgentSessionInfo records; +each+ walks
|
|
148
|
+
# every page. +agent+ is an id or a name; +state+ is queued, running,
|
|
149
|
+
# waiting, done, failed or stopped; +parent+ (a session's id) lists its
|
|
150
|
+
# direct children; +root+ (a top session's id) its whole tree, the root
|
|
151
|
+
# included; +deleted: true+ includes deleted records, hidden otherwise; at
|
|
152
|
+
# most +limit+ a page.
|
|
153
|
+
def list(agent: nil, state: nil, parent: nil, root: nil, deleted: nil, cursor: nil, limit: nil)
|
|
154
|
+
query = { "agent" => agent, "state" => state, "parent" => parent, "root" => root, "deleted" => deleted, "limit" => limit }
|
|
155
|
+
sessions_page(query, cursor)
|
|
112
156
|
end
|
|
113
157
|
|
|
114
158
|
private
|
|
115
159
|
|
|
116
160
|
def sessions_page(query, cursor)
|
|
117
161
|
body = @t.json("GET", "/v1/agents/sessions", query: query.merge("cursor" => cursor))
|
|
118
|
-
Page.new(body["data"].map { |item|
|
|
162
|
+
Page.new(body["data"].map { |item| AgentSessionInfo.new(item) }, body["nextCursor"]) { |after| sessions_page(query, after) }
|
|
119
163
|
end
|
|
120
164
|
end
|
|
121
165
|
|
|
122
|
-
# A session of an agent. +info+ is its latest record
|
|
123
|
-
# running, waiting, done, failed or stopped), +reason+,
|
|
124
|
-
# +last_seq
|
|
125
|
-
# +
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
# +
|
|
129
|
-
#
|
|
130
|
-
# (+sessions.delete+). +joined+ is whether +start+
|
|
131
|
-
# joined an open session with the same key, and +seq+ the number of the
|
|
132
|
-
# words it sent.
|
|
166
|
+
# A session of an agent. +info+ is its latest record, an AgentSessionInfo:
|
|
167
|
+
# +state+ (queued, running, waiting, done, failed or stopped), +reason+,
|
|
168
|
+
# +result+, +last_seq+, its tree (+parent_session_id+, +root_session_id+,
|
|
169
|
+
# +depth+), its use (+cost+, +tree_cost+), its storage (+history_bytes+,
|
|
170
|
+
# +attachment_bytes+), +stop_why+, +deleted_at+ and the rest; history is
|
|
171
|
+
# kept until the session is deleted (+sessions.delete+). +joined+ is
|
|
172
|
+
# whether +start+ joined an open session with the same key, and +seq+ the
|
|
173
|
+
# number of the words it sent.
|
|
133
174
|
class AgentSession
|
|
134
175
|
ENDED = %w[done failed stopped].freeze
|
|
135
176
|
# How long the API holds an events read open, and the margin past it a
|
|
@@ -141,7 +182,7 @@ module WithRuntime
|
|
|
141
182
|
|
|
142
183
|
def initialize(transport, info, joined: false, seq: nil)
|
|
143
184
|
@t = transport
|
|
144
|
-
@info =
|
|
185
|
+
@info = AgentSessionInfo.new(info)
|
|
145
186
|
@joined = joined
|
|
146
187
|
@seq = seq
|
|
147
188
|
end
|
|
@@ -153,10 +194,15 @@ module WithRuntime
|
|
|
153
194
|
|
|
154
195
|
# Reads the session again.
|
|
155
196
|
def refresh(timeout: nil)
|
|
156
|
-
@info =
|
|
197
|
+
@info = AgentSessionInfo.new(@t.json("GET", path, timeout: timeout))
|
|
157
198
|
self
|
|
158
199
|
end
|
|
159
200
|
|
|
201
|
+
# The first page of its direct children, the sessions its harness started
|
|
202
|
+
# (+trigger+ "spawn"), newest first; +each+ walks every page.
|
|
203
|
+
# +sessions.list(root:)+ reads a whole tree.
|
|
204
|
+
def children(cursor: nil, limit: nil) = AgentSessions.new(@t).list(parent: id, cursor: cursor, limit: limit)
|
|
205
|
+
|
|
160
206
|
# Sends words into a running or waiting session and answers their seq.
|
|
161
207
|
# The idempotency key, made here when not given, means a retried send is
|
|
162
208
|
# never two messages. Named send_message, not send, so Ruby's own
|
|
@@ -192,7 +238,7 @@ module WithRuntime
|
|
|
192
238
|
# Asks the session to stop and keeps the answer. A running session ends
|
|
193
239
|
# once its sandbox is deleted; this does not wait for that (+wait+ does).
|
|
194
240
|
def stop
|
|
195
|
-
@info =
|
|
241
|
+
@info = AgentSessionInfo.new(@t.json("POST", path("/stop"), body: {}))
|
|
196
242
|
end
|
|
197
243
|
|
|
198
244
|
# The session once it has ended (done, failed or stopped); a failed
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module WithRuntime
|
|
6
|
+
# A product built on the five verbs every resource shares (create, get,
|
|
7
|
+
# list, update, delete) and following one to a state: the same rules as the
|
|
8
|
+
# JavaScript SDK's resources.ts. One deadline covers a write, its retries
|
|
9
|
+
# and every read after it.
|
|
10
|
+
class ResourceProduct < Product
|
|
11
|
+
# A whole wait's deadline and its pause between reads.
|
|
12
|
+
class Following
|
|
13
|
+
attr_reader :key
|
|
14
|
+
|
|
15
|
+
def initialize(timeout, poll, key)
|
|
16
|
+
@poll = poll
|
|
17
|
+
@key = key
|
|
18
|
+
@deadline = timeout.positive? ? now + timeout : nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Seconds left for a call, or nil when the wait has no deadline (the
|
|
22
|
+
# client's own timeout then bounds each call).
|
|
23
|
+
def left = @deadline && [@deadline - now, 0.001].max
|
|
24
|
+
|
|
25
|
+
def expired? = !@deadline.nil? && now >= @deadline
|
|
26
|
+
|
|
27
|
+
def pause
|
|
28
|
+
sleep(@deadline ? [@poll, [@deadline - now, 0].max].min : @poll)
|
|
29
|
+
raise timed_out if expired?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def timed_out
|
|
33
|
+
ConnectionError.new("Waiting was cancelled or reached its deadline; the resource may still change.",
|
|
34
|
+
code: "timeout", idempotency_key: key,
|
|
35
|
+
hint: "Read the resource to resume waiting. Retry a write with the same idempotency_key.")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# +path+ is the product's path (/v1/apps); a wait ends at its target or at
|
|
44
|
+
# any of +settled+, which raises the resource's own error.
|
|
45
|
+
def initialize(transport, path, settled)
|
|
46
|
+
super(transport)
|
|
47
|
+
@base = path
|
|
48
|
+
@settled = settled
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Creates one; defaults belong to the API, so the SDK sends none.
|
|
52
|
+
def create(idempotency_key: nil, **fields)
|
|
53
|
+
Record.new(@t.json("POST", @base, body: Fields.body(fields), key: idempotency_key))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# With +wait_for:+ the API holds the answer until that state or
|
|
57
|
+
# +timeout_seconds+ (1 to 60; 30 by default) pass. Check the state it returns.
|
|
58
|
+
def get(id, wait_for: nil, timeout_seconds: nil)
|
|
59
|
+
read(id, { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds },
|
|
60
|
+
wait_for && ((timeout_seconds || 30) + 60))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# +label:+ is key:value filters that all must match; +state:+ defaults to
|
|
64
|
+
# the product's live states; +limit:+ 1 to 100 (50).
|
|
65
|
+
def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
|
|
66
|
+
page(@base, { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Fields left out stay as they are; +name: :remove+ clears the name.
|
|
70
|
+
def update(id, idempotency_key: nil, **changes)
|
|
71
|
+
Record.new(@t.json("POST", "#{@base}/#{seg(id)}:update", body: patch(changes), key: idempotency_key))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Follows the resource's state to +target+, reading it every +poll+
|
|
75
|
+
# seconds (0.25) for at most +timeout+ seconds in all (300; 0 waits as long
|
|
76
|
+
# as it takes). Raises the resource's error when it settles elsewhere, and
|
|
77
|
+
# ConnectionError with code timeout at the deadline.
|
|
78
|
+
def wait(id, target, timeout: 300, poll: 0.25)
|
|
79
|
+
following(timeout, poll, nil) { |wait| follow(id, target, wait) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Deletes it. With +wait: true+, follows it to deleted under one
|
|
83
|
+
# idempotency key made before the first try, so a timed-out delete can be
|
|
84
|
+
# retried after a restart with the key its error carries.
|
|
85
|
+
def delete(id, wait: false, timeout: 300, poll: 0.25, idempotency_key: nil)
|
|
86
|
+
return remove(id, idempotency_key, nil) unless wait
|
|
87
|
+
|
|
88
|
+
key = idempotency_key || SecureRandom.uuid
|
|
89
|
+
following(timeout, poll, key) { |follow| follow(id, "deleted", follow, remove(id, key, follow.left)) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def read(id, query = nil, timeout = nil)
|
|
95
|
+
Record.new(@t.json("GET", "#{@base}/#{seg(id)}", query: query, timeout: timeout))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def remove(id, key, timeout) = Record.new(@t.json("DELETE", "#{@base}/#{seg(id)}", key: key, timeout: timeout))
|
|
99
|
+
|
|
100
|
+
def follow(id, target, wait, initial = nil)
|
|
101
|
+
record = initial || read(id, nil, wait.left)
|
|
102
|
+
loop do
|
|
103
|
+
return record if record.state == target
|
|
104
|
+
if @settled.include?(record.state)
|
|
105
|
+
error = record["error"].is_a?(Hash) ? record["error"] : {}
|
|
106
|
+
raise Error.new(error["message"] || "Expected #{target}, but #{id} reached #{record.state}.",
|
|
107
|
+
code: error["code"] || "unexpected_state", idempotency_key: wait.key,
|
|
108
|
+
hint: "Read the resource and inspect its error before starting another change.",
|
|
109
|
+
details: { "id" => id, "state" => record.state, "target" => target, "resource" => record.to_h })
|
|
110
|
+
end
|
|
111
|
+
wait.pause
|
|
112
|
+
record = read(id, nil, wait.left)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One deadline covers the write, retries and every read. A read's failure
|
|
117
|
+
# still belongs to the write that started the wait, so it carries its key.
|
|
118
|
+
def following(timeout, poll, key)
|
|
119
|
+
check(timeout, "timeout")
|
|
120
|
+
check(poll, "poll")
|
|
121
|
+
wait = Following.new(timeout, poll, key)
|
|
122
|
+
begin
|
|
123
|
+
result = yield wait
|
|
124
|
+
raise wait.timed_out if wait.expired?
|
|
125
|
+
|
|
126
|
+
result
|
|
127
|
+
rescue Error => e
|
|
128
|
+
raise wait.timed_out if wait.expired?
|
|
129
|
+
raise e if key.nil? || e.idempotency_key
|
|
130
|
+
|
|
131
|
+
raise e.class.new(e.message, code: e.code, status: e.status, hint: e.hint, request_id: e.request_id,
|
|
132
|
+
details: e.details, idempotency_key: key, retry_after: e.retry_after)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def check(value, field)
|
|
137
|
+
return if value.is_a?(Numeric) && value.finite? && !value.negative?
|
|
138
|
+
|
|
139
|
+
raise Error.new("#{field} must be a finite, nonnegative number.", code: "invalid_request",
|
|
140
|
+
hint: "Fix the option and call again; nothing was sent.",
|
|
141
|
+
details: { "field" => field })
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# +runtime.apps+ and +runtime.functions+: your server, deployed from an
|
|
146
|
+
# image or a build, kept running in copies (an App keeps +min_copies+ awake;
|
|
147
|
+
# a Function sleeps to zero). In a private beta: an account outside it is
|
|
148
|
+
# refused with product_in_beta (403, PermissionDeniedError).
|
|
149
|
+
#
|
|
150
|
+
# app = runtime.apps.create(name: "web", max_copies: 3, vcpu: 1, memory_mib: 1024)
|
|
151
|
+
# deploy = runtime.apps.deploy(app.id, image: "web:latest", env: { "MODE" => "prod" }, wait: true)
|
|
152
|
+
# puts deploy.url
|
|
153
|
+
#
|
|
154
|
+
# An App or Function: id, name, labels, state (waiting, live, failed,
|
|
155
|
+
# deleting, deleted), min_copies, max_copies, concurrency, spares, vcpu,
|
|
156
|
+
# memory_mib, port, health_path, live_version, previous_version, error
|
|
157
|
+
# ({code, message} or nil), created_at, updated_at, deleted_at, and
|
|
158
|
+
# release_watch (rollback, alert or off) where the API sends it.
|
|
159
|
+
#
|
|
160
|
+
# A deploy: id, version, url and state (building, warming, switching, live,
|
|
161
|
+
# failed, superseded); a failed or superseded one has error with code
|
|
162
|
+
# (deploy_build_failed, deploy_health_failed, deploy_superseded), message,
|
|
163
|
+
# hint and last_output. A version: version, live, previous, saved, deploy
|
|
164
|
+
# (its deploy's id) and build (nil for an image). A copy: id, version,
|
|
165
|
+
# region, state (starting, serving, draining, frozen, parked) and since.
|
|
166
|
+
class Apps < ResourceProduct
|
|
167
|
+
DEPLOY_ENDED = %w[live failed superseded].freeze
|
|
168
|
+
|
|
169
|
+
# +path+: /v1/apps or /v1/functions.
|
|
170
|
+
def initialize(transport, path = "/v1/apps")
|
|
171
|
+
super(transport, path, %w[failed deleted live])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Creates one waiting for its first version: +max_copies:+ (1 to 1000),
|
|
175
|
+
# +vcpu:+ (1 to 16) and +memory_mib:+ (128 to 65536) are required;
|
|
176
|
+
# +concurrency:+, +spares:+, +port:+ (8080), +health_path:+, +name:+,
|
|
177
|
+
# +labels:+, and for an App +min_copies:+ (0). A Function leaves
|
|
178
|
+
# +min_copies+ out, zero included.
|
|
179
|
+
def create(idempotency_key: nil, **fields) = super
|
|
180
|
+
|
|
181
|
+
# Follows resource state, not a deploy: an older live version can survive
|
|
182
|
+
# a failed rollout of a newer one. +target+ is "live" or "deleted".
|
|
183
|
+
def wait(id, target, timeout: 300, poll: 0.25) = super
|
|
184
|
+
|
|
185
|
+
# Deploys a new version from +image:+ (an image of this account: id,
|
|
186
|
+
# name, name:tag or name@version, ready) or +build:+ (a build's id), with
|
|
187
|
+
# +env:+, +port:+ and +health_path:+. +keep_env: true+ carries the last
|
|
188
|
+
# deploy's environment, still sealed, with +env:+'s names set over it.
|
|
189
|
+
# With +wait: true+, follows the deploy, under one idempotency key, until
|
|
190
|
+
# it is live, failed or superseded, and returns it (check its state).
|
|
191
|
+
def deploy(id, image: nil, build: nil, env: nil, keep_env: nil, port: nil, health_path: nil, wait: false,
|
|
192
|
+
timeout: 300, poll: 0.25, idempotency_key: nil)
|
|
193
|
+
unless image.nil? ^ build.nil?
|
|
194
|
+
raise ArgumentError, "Deploy exactly one of image: or build:; nothing was sent."
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
body = Fields.body(image: image, build: build, env: env, keep_env: keep_env, port: port, health_path: health_path)
|
|
198
|
+
write_deploy(id, "deploys", body, wait, timeout, poll, idempotency_key)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A deploy, by the App's id and its own. With +wait_for:+ the API holds
|
|
202
|
+
# the answer until that state or +timeout_seconds+ pass.
|
|
203
|
+
def get_deploy(id, deploy_id, wait_for: nil, timeout_seconds: nil)
|
|
204
|
+
read_deploy(id, deploy_id, wait_for, timeout_seconds, wait_for && ((timeout_seconds || 30) + 60))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Resumes following a deploy by id after a restart, without making another version.
|
|
208
|
+
def wait_deploy(id, deploy_id, timeout: 300, poll: 0.25)
|
|
209
|
+
following(timeout, poll, nil) do |wait|
|
|
210
|
+
follow_deploy(id, read_deploy(id, deploy_id, nil, nil, wait.left), wait)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Goes back to the previous version, as a new deploy; +wait: true+ as for +deploy+.
|
|
215
|
+
def rollback(id, wait: false, timeout: 300, poll: 0.25, idempotency_key: nil)
|
|
216
|
+
write_deploy(id, "rollback", {}, wait, timeout, poll, idempotency_key)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def versions(id) = @t.json("GET", "#{@base}/#{seg(id)}/versions").map { |version| Record.new(version) }
|
|
220
|
+
|
|
221
|
+
def copies(id) = @t.json("GET", "#{@base}/#{seg(id)}/copies").map { |copy| Record.new(copy) }
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
def read_deploy(id, deploy_id, wait_for, timeout_seconds, timeout)
|
|
226
|
+
Record.new(@t.json("GET", "#{@base}/#{seg(id)}/deploys/#{seg(deploy_id)}",
|
|
227
|
+
query: { "waitFor" => wait_for, "timeoutSeconds" => timeout_seconds }, timeout: timeout))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def follow_deploy(id, record, wait)
|
|
231
|
+
until DEPLOY_ENDED.include?(record.state)
|
|
232
|
+
record = read_deploy(id, record.id, "live", 60, wait.left)
|
|
233
|
+
wait.pause unless DEPLOY_ENDED.include?(record.state)
|
|
234
|
+
end
|
|
235
|
+
record
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def write_deploy(id, verb, body, wait, timeout, poll, idempotency_key)
|
|
239
|
+
write = ->(key, left) { Record.new(@t.json("POST", "#{@base}/#{seg(id)}/#{verb}", body: body, key: key, timeout: left)) }
|
|
240
|
+
return write.call(idempotency_key, nil) unless wait
|
|
241
|
+
|
|
242
|
+
key = idempotency_key || SecureRandom.uuid
|
|
243
|
+
following(timeout, poll, key) { |follow| follow_deploy(id, write.call(key, follow.left), follow) }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WithRuntime
|
|
4
|
+
# +runtime.builds+: a connected repository's commit built into the server an
|
|
5
|
+
# App or Function deploys. In a private beta: an account outside it is
|
|
6
|
+
# refused with product_in_beta (403, PermissionDeniedError).
|
|
7
|
+
#
|
|
8
|
+
# repo = runtime.builds.repositories.connect(provider: "github", repository: "acme/web", deploy: "app:#{app.id}")
|
|
9
|
+
# build = runtime.builds.create(repository_id: repo.id, ref: "main")
|
|
10
|
+
# runtime.builds.logs(build.id, follow: true) { |event| puts event.text if event.type == "line" }
|
|
11
|
+
#
|
|
12
|
+
# A build: id, name, labels, repository_id, ref, branch, commit, state
|
|
13
|
+
# (queued, fetching, building, deploying, succeeded, failed, cancelled,
|
|
14
|
+
# superseded), reason, timings (milliseconds per step once done, or nil),
|
|
15
|
+
# output_snapshot_id, error, created_at, updated_at and deleted_at.
|
|
16
|
+
class Builds < Product
|
|
17
|
+
TERMINAL = %w[succeeded failed cancelled superseded].freeze
|
|
18
|
+
# Reconnections in a row with no new line before a follow gives up.
|
|
19
|
+
RECONNECTS = 3
|
|
20
|
+
SEEN = 10_000
|
|
21
|
+
SAFE_INTEGER = (2**53) - 1
|
|
22
|
+
|
|
23
|
+
attr_reader :repositories
|
|
24
|
+
|
|
25
|
+
def initialize(transport)
|
|
26
|
+
super
|
|
27
|
+
@repositories = Repositories.new(transport)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Builds +ref+ of a connected repository. The same +branch:+ and commit is
|
|
31
|
+
# one build; a newer build of the branch supersedes older ones not ended.
|
|
32
|
+
def create(repository_id:, ref:, branch: nil, name: nil, labels: nil, idempotency_key: nil)
|
|
33
|
+
body = Fields.body(repository_id: repository_id, ref: ref, branch: branch, name: name, labels: labels)
|
|
34
|
+
Record.new(@t.json("POST", "/v1/builds", body: body, key: idempotency_key))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get(id) = Record.new(@t.json("GET", path(id)))
|
|
38
|
+
|
|
39
|
+
def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
|
|
40
|
+
page("/v1/builds", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Cancels a build; one that already ended stays as it is.
|
|
44
|
+
def cancel(id, idempotency_key: nil) = Record.new(@t.json("POST", "#{path(id)}:cancel", key: idempotency_key))
|
|
45
|
+
|
|
46
|
+
# The last 200 lines and the failing step: lines (cursor, at, step, text,
|
|
47
|
+
# stream), cursor, state, reason, failing_step (name, exit_code,
|
|
48
|
+
# last_lines), has_more and next_cursor; +page_cursor:+ pages older lines.
|
|
49
|
+
#
|
|
50
|
+
# With +follow: true+, each event to the block as it is written (or an
|
|
51
|
+
# Enumerator without one), until the build ends: a line (type "line"), a
|
|
52
|
+
# gap where lines were lost (type "gap", earliest_cursor), then one
|
|
53
|
+
# terminal event (state, reason, failing_step). +cursor:+ resumes after
|
|
54
|
+
# that line. A lost connection reconnects from the last line, up to three
|
|
55
|
+
# times in a row; then build_log_interrupted, whose details cursor resumes.
|
|
56
|
+
# +timeout:+ bounds the whole follow, reconnections included (120 seconds;
|
|
57
|
+
# 0 for none).
|
|
58
|
+
def logs(id, follow: false, cursor: nil, page_cursor: nil, timeout: 120, &block)
|
|
59
|
+
unless follow
|
|
60
|
+
return Record.new(@t.json("GET", "#{path(id)}/logs", query: { "cursor" => cursor, "pageCursor" => page_cursor }))
|
|
61
|
+
end
|
|
62
|
+
return enum_for(:logs, id, follow: true, cursor: cursor, page_cursor: page_cursor, timeout: timeout) unless block
|
|
63
|
+
|
|
64
|
+
follow_logs(id, cursor, page_cursor, timeout, &block)
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def path(id) = "/v1/builds/#{seg(id)}"
|
|
71
|
+
|
|
72
|
+
def failure(code, hint, cursor) = Error.new(hint, code: code, hint: hint, details: { "cursor" => cursor })
|
|
73
|
+
|
|
74
|
+
def invalid(cursor) = failure("build_log_invalid", "Retry the log from the last received cursor.", cursor)
|
|
75
|
+
|
|
76
|
+
def valid_cursor?(value)
|
|
77
|
+
value.is_a?(String) && !value.empty? && value.length <= 4096 && !value.match?(/[\x00-\x1f\x7f]/)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def valid_failure?(value)
|
|
81
|
+
return true if value.nil?
|
|
82
|
+
return false unless value.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
lines = value["lastLines"]
|
|
85
|
+
value["name"].is_a?(String) && (value["exitCode"].nil? || safe_integer?(value["exitCode"])) &&
|
|
86
|
+
lines.is_a?(Array) && lines.size <= 20 && lines.all?(String)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def safe_integer?(value) = value.is_a?(Integer) && value.abs <= SAFE_INTEGER
|
|
90
|
+
|
|
91
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
92
|
+
|
|
93
|
+
# Cursors are opaque physical positions: they cannot be ordered or
|
|
94
|
+
# counted on, so lines already given are known by their cursor.
|
|
95
|
+
def follow_logs(id, cursor, page_cursor, timeout)
|
|
96
|
+
if (cursor && !valid_cursor?(cursor)) || page_cursor
|
|
97
|
+
raise failure("invalid_log_options", "Use an opaque cursor for follow; page_cursor is only for search pages.", cursor)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
seen = cursor ? { cursor => true } : {}
|
|
101
|
+
failures = 0
|
|
102
|
+
deadline = timeout.positive? ? now + timeout : nil
|
|
103
|
+
timed_out = lambda do
|
|
104
|
+
ConnectionError.new("The log follow reached its deadline.", code: "timeout", details: { "cursor" => cursor },
|
|
105
|
+
hint: "Resume with logs(id, follow: true, cursor: details[\"cursor\"]).")
|
|
106
|
+
end
|
|
107
|
+
loop do
|
|
108
|
+
raise timed_out.call if deadline && now >= deadline
|
|
109
|
+
|
|
110
|
+
continued = false
|
|
111
|
+
begin
|
|
112
|
+
@t.events("GET", "#{path(id)}/logs", query: { "follow" => true, "cursor" => cursor },
|
|
113
|
+
timeout: deadline && (deadline - now)) do |event|
|
|
114
|
+
raise invalid(cursor) unless event.is_a?(Hash)
|
|
115
|
+
raise invalid(cursor) unless event.key?("cursor") && (event["cursor"].nil? || valid_cursor?(event["cursor"]))
|
|
116
|
+
|
|
117
|
+
case event["type"]
|
|
118
|
+
when "line"
|
|
119
|
+
unless event["text"].is_a?(String) && %w[stdout stderr].include?(event["stream"]) &&
|
|
120
|
+
safe_integer?(event["at"]) && !event["at"].negative? &&
|
|
121
|
+
(event["step"].nil? || event["step"].is_a?(String)) && valid_cursor?(event["cursor"])
|
|
122
|
+
raise invalid(cursor)
|
|
123
|
+
end
|
|
124
|
+
next if seen.key?(event["cursor"])
|
|
125
|
+
|
|
126
|
+
seen[event["cursor"]] = true
|
|
127
|
+
seen.delete(seen.first[0]) if seen.size > SEEN
|
|
128
|
+
cursor = event["cursor"]
|
|
129
|
+
failures = 0
|
|
130
|
+
yield Record.new(event)
|
|
131
|
+
when "gap"
|
|
132
|
+
raise invalid(cursor) if !valid_cursor?(event["earliestCursor"]) || (cursor && event["cursor"] != cursor)
|
|
133
|
+
|
|
134
|
+
cursor = event["cursor"]
|
|
135
|
+
yield Record.new(event)
|
|
136
|
+
when "terminal"
|
|
137
|
+
if (cursor && event["cursor"] != cursor) || !TERMINAL.include?(event["state"]) ||
|
|
138
|
+
!event["reason"].is_a?(String) || event["reason"].empty? || !valid_failure?(event["failingStep"]) ||
|
|
139
|
+
(event["state"] != "failed" && !event["failingStep"].nil?)
|
|
140
|
+
raise invalid(cursor)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
yield Record.new(event)
|
|
144
|
+
return
|
|
145
|
+
when "continue"
|
|
146
|
+
if cursor && event["cursor"] != cursor
|
|
147
|
+
raise failure("build_log_gap", "Resume from details.cursor; report a persistent missing log line.", cursor)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
cursor = event["cursor"]
|
|
151
|
+
continued = true
|
|
152
|
+
break
|
|
153
|
+
when "error"
|
|
154
|
+
unless event["cursor"] == cursor && event["code"].is_a?(String) && !event["code"].empty? &&
|
|
155
|
+
event["hint"].is_a?(String) && !event["hint"].empty?
|
|
156
|
+
raise invalid(cursor)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
raise failure(event["code"], event["hint"], cursor)
|
|
160
|
+
else
|
|
161
|
+
raise invalid(cursor)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
rescue ConnectionError
|
|
165
|
+
# A lost connection, not a refusal: the transport itself retries
|
|
166
|
+
# retryable answers. Reconnect from the last line.
|
|
167
|
+
raise timed_out.call if deadline && now >= deadline
|
|
168
|
+
rescue Error => e
|
|
169
|
+
raise e unless e.code == "unexpected_answer" && e.status.zero?
|
|
170
|
+
|
|
171
|
+
raise failure("build_log_invalid", "The log contained invalid JSON; retry from details.cursor.", cursor)
|
|
172
|
+
end
|
|
173
|
+
failures += 1 unless continued
|
|
174
|
+
if failures > RECONNECTS
|
|
175
|
+
raise failure("build_log_interrupted", "Reconnect with logs(id, follow: true, cursor: details[\"cursor\"]).", cursor)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# A service that is down, or continuations one after another, never busy-loop.
|
|
179
|
+
pause = 0.05 * [1, failures].max
|
|
180
|
+
pause = [pause, [deadline - now, 0].max].min if deadline
|
|
181
|
+
sleep(pause)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# +runtime.builds.repositories+: the repositories Runtime builds. Every push
|
|
187
|
+
# to a repository's production branch builds it and deploys it to its
|
|
188
|
+
# +deploy+ target. In the Builds private beta (product_in_beta, 403, outside it).
|
|
189
|
+
#
|
|
190
|
+
# A repository: id, name, labels, provider (github or git), repository,
|
|
191
|
+
# deploy (app:<id>, function:<id> or container:<id>), production_branch,
|
|
192
|
+
# previews, root_directory, install_command, build_command, start_command,
|
|
193
|
+
# port, health_path, build_env, state (connected or disconnected), error,
|
|
194
|
+
# created_at, updated_at and deleted_at.
|
|
195
|
+
class Repositories < Product
|
|
196
|
+
# Connects +repository+ from +provider+ ("github" or "git") to +deploy+.
|
|
197
|
+
# Optional: production_branch, previews, root_directory, install_command
|
|
198
|
+
# (chosen by the lockfile when left out), build_command, start_command
|
|
199
|
+
# (the build then ends as that server, answering health_path with 200),
|
|
200
|
+
# port, health_path, build_env (never a secret: anyone who reads the
|
|
201
|
+
# repository sees it), name and labels.
|
|
202
|
+
def connect(provider:, repository:, deploy:, idempotency_key: nil, **settings)
|
|
203
|
+
body = Fields.body(settings.merge(provider: provider, repository: repository, deploy: deploy))
|
|
204
|
+
Record.new(@t.json("POST", "/v1/repositories", body: body, key: idempotency_key))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def list(name: nil, label: nil, state: nil, limit: nil, cursor: nil)
|
|
208
|
+
page("/v1/repositories", { "name" => name, "label" => label, "state" => state, "limit" => limit, "cursor" => cursor })
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Changes settings in place; its id and builds stay. Fields left out stay
|
|
212
|
+
# as they are; +:remove+ clears name, install_command, build_command,
|
|
213
|
+
# start_command or health_path.
|
|
214
|
+
def update(id, idempotency_key: nil, **changes)
|
|
215
|
+
Record.new(@t.json("POST", "/v1/repositories/#{seg(id)}:update", body: patch(changes), key: idempotency_key))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def disconnect(id, idempotency_key: nil) = Record.new(@t.json("DELETE", "/v1/repositories/#{seg(id)}", key: idempotency_key))
|
|
219
|
+
|
|
220
|
+
# A link (url, expires_at) to install Runtime's GitHub App, so private
|
|
221
|
+
# repositories build. Open it in a browser within 15 minutes, install the
|
|
222
|
+
# App where Runtime may read, then connect the repository with provider github.
|
|
223
|
+
def install_github = Record.new(@t.json("POST", "/v1/repositories/github:install"))
|
|
224
|
+
end
|
|
225
|
+
end
|
data/lib/withruntime/client.rb
CHANGED
|
@@ -24,7 +24,8 @@ module WithRuntime
|
|
|
24
24
|
|
|
25
25
|
class Client
|
|
26
26
|
attr_reader :transport, :sandboxes, :snapshots, :images, :volumes, :secrets, :limits, :emails, :referrals,
|
|
27
|
-
:switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents
|
|
27
|
+
:switching, :events, :webhooks, :otel, :audit, :feedback, :support, :domains, :ports, :ips, :networks, :sso, :mcp, :agents,
|
|
28
|
+
:apps, :functions, :builds, :containers
|
|
28
29
|
|
|
29
30
|
# +api_key+: default RUNTIME_API_KEY, then this machine's saved connection.
|
|
30
31
|
# +base_url+: default RUNTIME_API_URL, then https://api.withruntime.com.
|
|
@@ -72,6 +73,11 @@ module WithRuntime
|
|
|
72
73
|
@sso = SSO.new(@transport)
|
|
73
74
|
@mcp = MCP.new(@transport)
|
|
74
75
|
@agents = Agents.new(@transport)
|
|
76
|
+
# Private beta: outside it, product_in_beta (403).
|
|
77
|
+
@apps = Apps.new(@transport, "/v1/apps")
|
|
78
|
+
@functions = Apps.new(@transport, "/v1/functions")
|
|
79
|
+
@builds = Builds.new(@transport)
|
|
80
|
+
@containers = Containers.new(@transport)
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
# Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WithRuntime
|
|
4
|
+
# +runtime.containers+: one OCI image's process on its own microVM, billed
|
|
5
|
+
# at the sandbox rates, at /v1/containers. In a private beta: an account
|
|
6
|
+
# outside it is refused with product_in_beta (403, PermissionDeniedError).
|
|
7
|
+
#
|
|
8
|
+
# box = runtime.containers.create(image: "nginx:1.27", vcpu: 1, memory_mib: 512, name: "web")
|
|
9
|
+
# box = runtime.containers.wait(box.id, "running")
|
|
10
|
+
# puts box.port
|
|
11
|
+
#
|
|
12
|
+
# A container: id, name, labels, state (waiting, running, sleeping, stopped,
|
|
13
|
+
# failed, deleting, deleted), image (pinned to its digest), entrypoint,
|
|
14
|
+
# command, working_dir, user, port (or nil), exposed_ports, on_exit, sleep,
|
|
15
|
+
# vcpu, memory_mib, env_names (the environment's names, never its values),
|
|
16
|
+
# error ({code, message} or nil), created_at, updated_at and deleted_at.
|
|
17
|
+
class Containers < ResourceProduct
|
|
18
|
+
def initialize(transport)
|
|
19
|
+
super(transport, "/v1/containers", %w[failed deleted])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Runs one copy of +image:+ (an OCI reference; the answer pins its digest)
|
|
23
|
+
# with +vcpu:+ (1 to 16) and +memory_mib:+ (128 to 65536), all required.
|
|
24
|
+
# +entrypoint:+ replaces the image's Entrypoint and drops its Cmd;
|
|
25
|
+
# +command:+ replaces its Cmd. +env:+ is set over the image's own and never
|
|
26
|
+
# read back, only its names. +port:+ (1 to 65535) is required when the
|
|
27
|
+
# image exposes several TCP ports. +on_exit:+ is "restart" or "stop": by
|
|
28
|
+
# default it restarts when it serves a port, else stops. It never sleeps
|
|
29
|
+
# unless +sleep: true+. Also +name:+, +labels:+, +working_dir:+ and +user:+.
|
|
30
|
+
def create(idempotency_key: nil, **fields) = super
|
|
31
|
+
|
|
32
|
+
# Only +name:+ and +labels:+ change; +name: :remove+ clears the name.
|
|
33
|
+
def update(id, idempotency_key: nil, **changes) = super
|
|
34
|
+
|
|
35
|
+
# Follows its state to +target+ ("running", say); reaching failed or
|
|
36
|
+
# deleted first raises with its error.
|
|
37
|
+
def wait(id, target, timeout: 300, poll: 0.25) = super
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/withruntime/fields.rb
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module WithRuntime
|
|
4
4
|
# Ruby's snake_case keywords as the API's camelCase fields: +timeout_seconds:+
|
|
5
5
|
# is timeoutSeconds, +memory_mib:+ is memoryMiB. User data (labels, env,
|
|
6
|
-
# build_args, headers and the like) is sent as it is.
|
|
6
|
+
# build_args, build_env, headers and the like) is sent as it is.
|
|
7
7
|
module Fields
|
|
8
|
-
OPAQUE = %w[labels env buildArgs headers data context detail].freeze
|
|
8
|
+
OPAQUE = %w[labels env buildArgs buildEnv headers data context detail].freeze
|
|
9
9
|
|
|
10
10
|
module_function
|
|
11
11
|
|
data/lib/withruntime/products.rb
CHANGED
|
@@ -13,6 +13,14 @@ module WithRuntime
|
|
|
13
13
|
|
|
14
14
|
def seg(value) = Transport.segment(value)
|
|
15
15
|
|
|
16
|
+
# A change's body from keyword arguments: nil is left out, +:remove+ sends
|
|
17
|
+
# null, which clears the field.
|
|
18
|
+
def patch(changes)
|
|
19
|
+
body = Fields.body(changes.reject { |_key, value| value == :remove })
|
|
20
|
+
changes.each { |key, value| body[key.is_a?(Symbol) ? Fields.camel(key) : key.to_s] = nil if value == :remove }
|
|
21
|
+
body
|
|
22
|
+
end
|
|
23
|
+
|
|
16
24
|
def page(path, query)
|
|
17
25
|
body = @t.json("GET", path, query: query)
|
|
18
26
|
Page.new(body["data"].map { |item| Record.new(item) }, body["nextCursor"]) do |cursor|
|
data/lib/withruntime/sandbox.rb
CHANGED
|
@@ -25,6 +25,12 @@ module WithRuntime
|
|
|
25
25
|
# room, the create raises +no_capacity+ and nothing is made or charged.
|
|
26
26
|
# +sbx.info.placement+ is an opaque tag for its server: the same tag means
|
|
27
27
|
# the same server.
|
|
28
|
+
#
|
|
29
|
+
# +secrets: [name, ...]+ is the egress secrets this sandbox may use, by
|
|
30
|
+
# name: only those of your egress secrets that would otherwise reach it.
|
|
31
|
+
# +[]+ is none; leave it out for every secret that reaches it. Fixed for
|
|
32
|
+
# the sandbox's life; a copy of a snapshot may only narrow its source's
|
|
33
|
+
# list. +sbx.info.secrets+ reads it back, nil for every secret.
|
|
28
34
|
def create(wait: true, idempotency_key: nil, wait_for_capacity: nil, **fields)
|
|
29
35
|
room = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
|
|
30
36
|
info = @t.json("POST", "/v1/sandboxes", body: Fields.body(fields), wait: wait ? 60 : 0,
|
|
@@ -208,8 +214,14 @@ module WithRuntime
|
|
|
208
214
|
# run. One copy without +count+; an array with it. If a copy fails, the
|
|
209
215
|
# error's +details["startedSandboxIds"]+ names the copies that did start.
|
|
210
216
|
# +funding+ is accepted and ignored: copies run on free usage first, then credit.
|
|
211
|
-
|
|
212
|
-
|
|
217
|
+
# +secrets: [name, ...]+ is the egress secrets the copies may use, by name:
|
|
218
|
+
# only those of your egress secrets that would otherwise reach them. +[]+
|
|
219
|
+
# is none; leave it out to keep this sandbox's list. Fixed for the copies'
|
|
220
|
+
# life; it may only narrow this sandbox's list.
|
|
221
|
+
def fork(count: nil, name: nil, labels: nil, keep_snapshot: nil, funding: nil, secrets: nil,
|
|
222
|
+
idempotency_key: nil)
|
|
223
|
+
body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot,
|
|
224
|
+
secrets: secrets&.to_a)
|
|
213
225
|
reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
|
|
214
226
|
copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
|
|
215
227
|
count.nil? ? copies.first : copies
|
data/lib/withruntime/version.rb
CHANGED
data/lib/withruntime.rb
CHANGED
|
@@ -28,6 +28,9 @@ require_relative "withruntime/sandbox"
|
|
|
28
28
|
require_relative "withruntime/products"
|
|
29
29
|
require_relative "withruntime/agents"
|
|
30
30
|
require_relative "withruntime/images"
|
|
31
|
+
require_relative "withruntime/apps"
|
|
32
|
+
require_relative "withruntime/builds"
|
|
33
|
+
require_relative "withruntime/containers"
|
|
31
34
|
require_relative "withruntime/network_products"
|
|
32
35
|
require_relative "withruntime/parity_products"
|
|
33
36
|
require_relative "withruntime/file_watch"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: withruntime
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Runtime
|
|
@@ -22,7 +22,10 @@ files:
|
|
|
22
22
|
- lib/withruntime.rb
|
|
23
23
|
- lib/withruntime/agents.rb
|
|
24
24
|
- lib/withruntime/api_defaults.rb
|
|
25
|
+
- lib/withruntime/apps.rb
|
|
26
|
+
- lib/withruntime/builds.rb
|
|
25
27
|
- lib/withruntime/client.rb
|
|
28
|
+
- lib/withruntime/containers.rb
|
|
26
29
|
- lib/withruntime/credentials.rb
|
|
27
30
|
- lib/withruntime/errors.rb
|
|
28
31
|
- lib/withruntime/fields.rb
|