withruntime 0.5.0 → 0.6.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: 32515cd23a35b91a06324a7af01623f6dd75015c116e041c499e7a9b9548609c
4
- data.tar.gz: 67454492f534d850643392417477f3f3d2782e7564bebb43555f76c6a1c9adfa
3
+ metadata.gz: c855551b3670978d87aab0540d48736b4f9f0935cb8109cbb9a6bdbf85b05d96
4
+ data.tar.gz: c2e545740a78cc37fdcdec6de993c4b12402906f68a0bd3692d07f357bd17c4e
5
5
  SHA512:
6
- metadata.gz: 0ce1fea1e0289719efc5d1ece2a48181e8238a400c4abd25d3ed79c12661b6c28465acb33c43681f32dad71428c86a2bd94c11eae6692fbcc2d97e29d31c95c3
7
- data.tar.gz: 07a2ef09506dd306e8843f1add75482ec75be67e696dcb3b9c66edec9d6b1540208914be83b2f165f89e92f9a54d3c4f27554d435b3ab74c8b5e7f2d67e0d866
6
+ metadata.gz: 9ada037d0a4b3ddc819067452b0688e027df4ffdc2378de1dcc96a509e1aa0243ce8f58e2d00b458861d6b15afc27d1e708a0f03181fbbfb2a016e5f4691c1be
7
+ data.tar.gz: 316571ca7aae8d6714b2998e8a78b15a76ba2de7eb6c6a638efcb7652db98ff59435a9b05146e74e406900c7639176557ee1912a36578eedc7b0109668ea086d
@@ -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
@@ -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
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,10 @@ 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)
75
80
  end
76
81
 
77
82
  # Renamed on 6 October 2026, one plural word per product: each old name is the product it became, with a warning,
@@ -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
 
@@ -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|
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WithRuntime
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/withruntime.rb CHANGED
@@ -28,6 +28,8 @@ 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"
31
33
  require_relative "withruntime/network_products"
32
34
  require_relative "withruntime/parity_products"
33
35
  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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Runtime
@@ -22,6 +22,8 @@ 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
26
28
  - lib/withruntime/credentials.rb
27
29
  - lib/withruntime/errors.rb