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 +4 -4
- data/lib/withruntime/api_defaults.rb +17 -0
- data/lib/withruntime/client.rb +44 -10
- data/lib/withruntime/credentials.rb +31 -5
- data/lib/withruntime/errors.rb +9 -4
- data/lib/withruntime/network_products.rb +9 -9
- data/lib/withruntime/parity_products.rb +1 -1
- data/lib/withruntime/products.rb +9 -2
- data/lib/withruntime/sandbox.rb +179 -80
- data/lib/withruntime/tar.rb +218 -33
- data/lib/withruntime/transport.rb +189 -16
- data/lib/withruntime/version.rb +1 -1
- data/lib/withruntime/wallet.rb +68 -0
- data/lib/withruntime.rb +5 -2
- metadata +9 -4
- data/GUIDE.md +0 -406
data/lib/withruntime/sandbox.rb
CHANGED
|
@@ -13,22 +13,34 @@ module WithRuntime
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# Creates a sandbox and, unless +wait: false+, waits until it is running.
|
|
16
|
-
# Every field is optional: with none you get the free
|
|
16
|
+
# Every field is optional: with none you get the free usage while it lasts,
|
|
17
17
|
# the default region and a 2 vCPU / 4 GiB machine for up to 30 minutes.
|
|
18
|
-
# When every
|
|
18
|
+
# When every slot without credit or the account's quota is taken, it waits for one to
|
|
19
19
|
# free, up to +wait_for_capacity+ seconds (the client's, two minutes by default).
|
|
20
20
|
#
|
|
21
|
-
# runtime.sandboxes.create(
|
|
21
|
+
# runtime.sandboxes.create(labels: { team: "search" }, timeout_seconds: 900)
|
|
22
|
+
#
|
|
23
|
+
# +spread_from: [id, ...]+ (one to 16 ids of your own sandboxes or snapshots)
|
|
24
|
+
# starts it on a server none of them is on. With no other server that has
|
|
25
|
+
# room, the create raises +no_capacity+ and nothing is made or charged.
|
|
26
|
+
# +sbx.info.placement+ is an opaque tag for its server: the same tag means
|
|
27
|
+
# the same server.
|
|
22
28
|
def create(wait: true, idempotency_key: nil, wait_for_capacity: nil, **fields)
|
|
23
29
|
room = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
|
|
24
30
|
info = @t.json("POST", "/v1/sandboxes", body: Fields.body(fields), wait: wait ? 60 : 0,
|
|
25
31
|
key: idempotency_key, room: room)
|
|
26
32
|
sandbox = Sandbox.new(@t, info)
|
|
27
33
|
if wait && sandbox.state != "running"
|
|
28
|
-
sandbox.wait_for("running", timeout:
|
|
34
|
+
sandbox.wait_for("running", timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
|
|
29
35
|
unless sandbox.state == "running"
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
message = if info["replayed"]
|
|
37
|
+
"Sandbox #{sandbox.id} was made by an earlier call with this idempotency key and is #{sandbox.state} now. " \
|
|
38
|
+
"It did not fail to start; create again with a new key."
|
|
39
|
+
else
|
|
40
|
+
"Sandbox #{sandbox.id} is #{sandbox.state}, not running."
|
|
41
|
+
end
|
|
42
|
+
raise Error.new(message, code: "start_failed",
|
|
43
|
+
hint: "Read it with runtime.sandboxes.get(id); stop_reason says why.")
|
|
32
44
|
end
|
|
33
45
|
end
|
|
34
46
|
sandbox
|
|
@@ -94,7 +106,7 @@ module WithRuntime
|
|
|
94
106
|
|
|
95
107
|
# Waits, on the server with no polling, until the sandbox reaches +state+
|
|
96
108
|
# (running, paused or stopped) or +timeout+ seconds pass.
|
|
97
|
-
def wait_for(state, timeout:
|
|
109
|
+
def wait_for(state, timeout: ApiDefaults::WAIT_FOR_TIMEOUT_SECONDS)
|
|
98
110
|
seconds = [1, timeout.to_i].max
|
|
99
111
|
@info = Record.new(@t.json("GET", path, query: { "waitFor" => state, "timeoutSeconds" => seconds },
|
|
100
112
|
timeout: seconds + 60))
|
|
@@ -110,19 +122,28 @@ module WithRuntime
|
|
|
110
122
|
# Saves the sandbox's memory and files; compute billing stops.
|
|
111
123
|
def pause(wait: true, idempotency_key: nil) = lifecycle("pause", {}, wait, idempotency_key)
|
|
112
124
|
|
|
113
|
-
# Carries on a paused sandbox, with its memory and processes;
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
# Carries on a paused sandbox, with its memory and processes;
|
|
126
|
+
# +timeout_seconds+ is its new time limit, for a sandbox that has one.
|
|
127
|
+
# When its server has no room (+no_capacity+) it waits for room, as +create+
|
|
128
|
+
# does, up to +wait_for_capacity+ seconds (the client's, two minutes by
|
|
129
|
+
# default; 0 fails at once), sending the same idempotency key each time.
|
|
130
|
+
def wake(wait: true, timeout_seconds: nil, idempotency_key: nil, wait_for_capacity: nil)
|
|
131
|
+
lifecycle("wake", timeout_seconds ? { "timeoutSeconds" => timeout_seconds } : {}, wait, idempotency_key,
|
|
132
|
+
room: room_for(wait_for_capacity))
|
|
116
133
|
end
|
|
117
134
|
|
|
118
|
-
#
|
|
135
|
+
# Moves a sandbox's time limit on, at most an hour ahead of now. A sandbox
|
|
136
|
+
# with no time limit answers at once and nothing changes.
|
|
119
137
|
def extend_lease(seconds, idempotency_key: nil) = lifecycle("extend", { "seconds" => seconds }, false, idempotency_key)
|
|
120
138
|
|
|
121
139
|
# Days (1 to 365) a paused sandbox is kept before it is deleted.
|
|
122
140
|
def set_retention(days, idempotency_key: nil) = lifecycle("retention", { "days" => days }, false, idempotency_key)
|
|
123
141
|
|
|
124
142
|
# Starts a stopped persistent sandbox again from its disk. Memory is not kept.
|
|
125
|
-
|
|
143
|
+
# Waits for room like +wake+ when its server is full.
|
|
144
|
+
def restart(wait: true, idempotency_key: nil, wait_for_capacity: nil)
|
|
145
|
+
lifecycle("restart", {}, wait, idempotency_key, room: room_for(wait_for_capacity))
|
|
146
|
+
end
|
|
126
147
|
|
|
127
148
|
# Changes name, labels, auto_wake, idle_pause_seconds, persistent or
|
|
128
149
|
# max_total_cost_micros; fields left out stay as they are, and
|
|
@@ -133,12 +154,13 @@ module WithRuntime
|
|
|
133
154
|
lifecycle("update", body, false, idempotency_key)
|
|
134
155
|
end
|
|
135
156
|
|
|
136
|
-
# Keeps a
|
|
137
|
-
# +stop+ or +stop_keep_alive+: every +every+ seconds it extends the
|
|
138
|
-
# that +margin+ seconds remain, never more than the hour ahead the
|
|
139
|
-
# allows.
|
|
140
|
-
#
|
|
141
|
-
|
|
157
|
+
# Keeps a sandbox with a time limit running past it on a background thread,
|
|
158
|
+
# until +stop+ or +stop_keep_alive+: every +every+ seconds it extends the
|
|
159
|
+
# limit so that +margin+ seconds remain, never more than the hour ahead the
|
|
160
|
+
# API allows. A sandbox with no time limit needs none and is only watched.
|
|
161
|
+
# Running time is billed as it is used. A paused sandbox is left paused; a
|
|
162
|
+
# stopped one ends the loop.
|
|
163
|
+
def keep_alive(every: 60, margin: ApiDefaults::KEEP_ALIVE_MARGIN_SECONDS, &on_error)
|
|
142
164
|
stop_keep_alive
|
|
143
165
|
every = [10, every].max
|
|
144
166
|
margin = margin.clamp(60, 3600)
|
|
@@ -148,7 +170,7 @@ module WithRuntime
|
|
|
148
170
|
refresh
|
|
149
171
|
break if %w[stopped stopping].include?(state)
|
|
150
172
|
|
|
151
|
-
expires = @info
|
|
173
|
+
expires = Sandbox.limit_end(@info)
|
|
152
174
|
if state == "running" && expires
|
|
153
175
|
need = (margin - (Time.parse(expires) - Time.now)).ceil
|
|
154
176
|
extend_lease([3600, need].min) if need >= 1
|
|
@@ -163,6 +185,17 @@ module WithRuntime
|
|
|
163
185
|
self
|
|
164
186
|
end
|
|
165
187
|
|
|
188
|
+
# Where a sandbox's time limit ends, or nil when it has none: it renews
|
|
189
|
+
# itself (timeoutSeconds 0, or persistent) unless endsAt says its renewal
|
|
190
|
+
# stopped. An older server sends no endsAt and a timeout of at least a
|
|
191
|
+
# minute.
|
|
192
|
+
def self.limit_end(info)
|
|
193
|
+
return info["endsAt"] if info["endsAt"]
|
|
194
|
+
return nil if info["timeoutSeconds"].to_i.zero? || info["persistent"]
|
|
195
|
+
|
|
196
|
+
info["expiresAt"]
|
|
197
|
+
end
|
|
198
|
+
|
|
166
199
|
# Ends a keep-alive, if one runs.
|
|
167
200
|
def stop_keep_alive
|
|
168
201
|
thread = @keep_alive
|
|
@@ -174,16 +207,19 @@ module WithRuntime
|
|
|
174
207
|
# processes), each its own sandbox, on the same server, answered once they
|
|
175
208
|
# run. One copy without +count+; an array with it. If a copy fails, the
|
|
176
209
|
# error's +details["startedSandboxIds"]+ names the copies that did start.
|
|
210
|
+
# +funding+ is accepted and ignored: copies run on free usage first, then credit.
|
|
177
211
|
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
|
|
212
|
+
body = Fields.body(count: count, name: name, labels: labels, keep_snapshot: keep_snapshot)
|
|
179
213
|
reply = @t.json("POST", path(":fork"), body: body, wait: 60, key: idempotency_key)
|
|
180
214
|
copies = reply["sandboxes"].map { |info| Sandbox.new(@t, info) }
|
|
181
215
|
count.nil? ? copies.first : copies
|
|
182
216
|
end
|
|
183
217
|
|
|
184
218
|
# Keeps this sandbox's whole machine as a snapshot to start new sandboxes
|
|
185
|
-
# from. A running sandbox is paused for the moment it takes
|
|
186
|
-
#
|
|
219
|
+
# from. A running sandbox is paused by the server for the moment it takes
|
|
220
|
+
# and woken once the capture is over (one still capturing when the call
|
|
221
|
+
# answers stays paused until it is ready); a paused one stays paused. A
|
|
222
|
+
# sandbox with volumes cannot be snapshotted.
|
|
187
223
|
def snapshot(name: nil, labels: nil, retention_days: nil, idempotency_key: nil)
|
|
188
224
|
refresh
|
|
189
225
|
# Straight after a fork or a wake the sandbox is still resuming, and
|
|
@@ -192,15 +228,11 @@ module WithRuntime
|
|
|
192
228
|
if %w[resuming starting].include?(state) then wait_for("running")
|
|
193
229
|
elsif state == "pausing" then wait_for("paused")
|
|
194
230
|
end
|
|
195
|
-
running
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
wait: 10, key: idempotency_key))
|
|
201
|
-
ensure
|
|
202
|
-
wake if running
|
|
203
|
-
end
|
|
231
|
+
# The server pauses a running sandbox itself: a client pause and wake
|
|
232
|
+
# would be two more round trips.
|
|
233
|
+
Record.new(@t.json("POST", path(":snapshot"),
|
|
234
|
+
body: Fields.body(name: name, labels: labels, retention_days: retention_days),
|
|
235
|
+
wait: 10, key: idempotency_key))
|
|
204
236
|
end
|
|
205
237
|
|
|
206
238
|
# CPU and memory over a range: 15m, 1h, 6h, 24h, 7d or 30d.
|
|
@@ -239,43 +271,89 @@ module WithRuntime
|
|
|
239
271
|
idempotency_key: idempotency_key)
|
|
240
272
|
end
|
|
241
273
|
|
|
242
|
-
|
|
274
|
+
longest = ApiDefaults::STREAMED_EXEC_TIMEOUT_SECONDS
|
|
275
|
+
body = command_body(command, cwd, env, stdin, timeout || longest).merge("stream" => true)
|
|
243
276
|
handed_over = nil
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
277
|
+
process_id = nil
|
|
278
|
+
cursor = 0
|
|
279
|
+
exited = false
|
|
280
|
+
begin
|
|
281
|
+
@t.events("POST", path(":exec"), body: body, key: idempotency_key,
|
|
282
|
+
timeout: timeout ? timeout + 60 : longest) do |event|
|
|
283
|
+
case event["type"]
|
|
284
|
+
when "continue" then handed_over = event
|
|
285
|
+
when "error" then raise stream_error(event)
|
|
286
|
+
else
|
|
287
|
+
process_id = event["processId"] if event["type"] == "start"
|
|
288
|
+
cursor = event["offset"] + event["data"].bytesize if %w[stdout stderr].include?(event["type"])
|
|
289
|
+
exited = true if event["type"] == "exit"
|
|
290
|
+
yield event
|
|
291
|
+
end
|
|
292
|
+
break if handed_over || exited
|
|
250
293
|
end
|
|
251
|
-
|
|
294
|
+
rescue ConnectionError => e
|
|
295
|
+
raise if e.code != "connection_error" || process_id.nil?
|
|
296
|
+
end
|
|
297
|
+
return follow(handed_over["processId"], handed_over["cursor"], &block) if handed_over
|
|
298
|
+
return if exited
|
|
299
|
+
|
|
300
|
+
# The stream closed before the command's exit. Its output is kept on the
|
|
301
|
+
# sandbox, so follow it from where this stream stopped; an end with no
|
|
302
|
+
# exit is never a result.
|
|
303
|
+
if process_id.nil?
|
|
304
|
+
raise ConnectionError.new("The exec stream closed before the command started.",
|
|
305
|
+
code: "connection_error", idempotency_key: idempotency_key,
|
|
306
|
+
hint: "Run it again; to be sure it runs once, pass the same idempotency_key.")
|
|
252
307
|
end
|
|
253
|
-
follow(
|
|
308
|
+
follow(process_id, cursor, &block)
|
|
254
309
|
end
|
|
255
310
|
|
|
256
|
-
# A process's output events from +cursor+ until it exits, across the
|
|
311
|
+
# A process's output events from +cursor+ until it exits, across the
|
|
312
|
+
# server's stream slices. A stream that closes or is cut before the exit
|
|
313
|
+
# is followed again from where it stopped; four in a row with nothing new
|
|
314
|
+
# are an error, never an end.
|
|
257
315
|
def follow(process_id, cursor = 0)
|
|
258
316
|
return enum_for(:follow, process_id, cursor) unless block_given?
|
|
259
317
|
|
|
318
|
+
idle = 0
|
|
260
319
|
loop do
|
|
261
320
|
resume = nil
|
|
262
321
|
exited = false
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
322
|
+
before = cursor
|
|
323
|
+
cut = false
|
|
324
|
+
begin
|
|
325
|
+
@t.events("GET", path("/processes/#{Transport.segment(process_id)}/output"),
|
|
326
|
+
query: { "cursor" => cursor, "follow" => "true" }, timeout: 180) do |event|
|
|
327
|
+
case event["type"]
|
|
328
|
+
when "continue"
|
|
329
|
+
resume = event["cursor"]
|
|
330
|
+
break
|
|
331
|
+
when "stdout", "stderr" then cursor = event["offset"] + event["data"].bytesize
|
|
332
|
+
when "error" then raise stream_error(event)
|
|
333
|
+
when "exit" then exited = true
|
|
334
|
+
end
|
|
335
|
+
yield event
|
|
336
|
+
break if exited
|
|
272
337
|
end
|
|
273
|
-
|
|
274
|
-
|
|
338
|
+
rescue ConnectionError => e
|
|
339
|
+
raise if e.code != "connection_error"
|
|
340
|
+
|
|
341
|
+
cut = true
|
|
275
342
|
end
|
|
276
|
-
break if exited
|
|
343
|
+
break if exited
|
|
277
344
|
|
|
278
|
-
cursor = resume
|
|
345
|
+
cursor = [cursor, resume].max if resume
|
|
346
|
+
if resume || cursor > before
|
|
347
|
+
idle = 0
|
|
348
|
+
next
|
|
349
|
+
end
|
|
350
|
+
idle += 1
|
|
351
|
+
if idle > 3
|
|
352
|
+
raise ConnectionError.new("The output stream of process #{process_id} keeps closing before it ends.",
|
|
353
|
+
code: "connection_error",
|
|
354
|
+
hint: "Read what it printed so far: runtime sandboxes logs #{id} #{process_id}")
|
|
355
|
+
end
|
|
356
|
+
sleep([0.05 * (2**idle), 2].min) if cut
|
|
279
357
|
end
|
|
280
358
|
end
|
|
281
359
|
|
|
@@ -300,11 +378,13 @@ module WithRuntime
|
|
|
300
378
|
|
|
301
379
|
private
|
|
302
380
|
|
|
303
|
-
def lifecycle(verb, body, wait, key)
|
|
304
|
-
@info = Record.new(@t.json("POST", path(":#{verb}"), body: body, wait: wait ? 60 : 0, key: key))
|
|
381
|
+
def lifecycle(verb, body, wait, key, room: 0)
|
|
382
|
+
@info = Record.new(@t.json("POST", path(":#{verb}"), body: body, wait: wait ? 60 : 0, key: key, room: room))
|
|
305
383
|
self
|
|
306
384
|
end
|
|
307
385
|
|
|
386
|
+
def room_for(wait_for_capacity) = wait_for_capacity.nil? ? @t.wait_for_capacity : [0, wait_for_capacity].max
|
|
387
|
+
|
|
308
388
|
def command_body(command, cwd, env, stdin, timeout)
|
|
309
389
|
body = command.is_a?(Array) ? { "argv" => command.map(&:to_s) } : { "command" => command.to_s }
|
|
310
390
|
body["cwd"] = cwd if cwd
|
|
@@ -421,7 +501,7 @@ module WithRuntime
|
|
|
421
501
|
end
|
|
422
502
|
|
|
423
503
|
# A file's bytes (a binary String), any size.
|
|
424
|
-
def read(path) = t.
|
|
504
|
+
def read(path) = t.file_bytes(@sandbox.path("/files/content"), query: { "path" => path })
|
|
425
505
|
|
|
426
506
|
def read_text(path) = read(path).force_encoding(Encoding::UTF_8)
|
|
427
507
|
|
|
@@ -536,20 +616,38 @@ module WithRuntime
|
|
|
536
616
|
nil
|
|
537
617
|
end
|
|
538
618
|
|
|
539
|
-
# Copies a local file or directory in. A directory travels as one gzipped
|
|
619
|
+
# Copies a local file or directory in. A directory travels as one gzipped
|
|
620
|
+
# tar to the API's folder routes, unpacked by the sandbox's own tar as it
|
|
621
|
+
# arrives.
|
|
540
622
|
def upload(local, remote)
|
|
541
623
|
return write(remote, File.binread(local)) unless File.directory?(local)
|
|
542
624
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
625
|
+
archive = Tar.pack_directory(local)
|
|
626
|
+
if archive.bytesize <= CHUNK
|
|
627
|
+
t.bytes("PUT", @sandbox.path("/files/archive"), query: { "path" => remote }, raw: archive)
|
|
628
|
+
return nil
|
|
629
|
+
end
|
|
630
|
+
# Larger: parts in order; a part resent after a lost answer is not written twice.
|
|
631
|
+
upload = t.json("POST", @sandbox.path("/files/archive/uploads"), body: { "path" => remote, "gzip" => true })
|
|
632
|
+
base = @sandbox.path("/files/archive/uploads/#{Transport.segment(upload["uploadId"])}")
|
|
633
|
+
begin
|
|
634
|
+
(0...archive.bytesize).step(upload["chunkBytes"].to_i) do |offset|
|
|
635
|
+
t.bytes("PUT", base, query: { "offset" => offset }, raw: archive.byteslice(offset, upload["chunkBytes"].to_i))
|
|
636
|
+
end
|
|
637
|
+
t.json("POST", "#{base}:commit", body: {})
|
|
638
|
+
rescue StandardError
|
|
639
|
+
begin
|
|
640
|
+
t.json("POST", "#{base}:abort", body: {}, timeout: 10)
|
|
641
|
+
rescue Error
|
|
642
|
+
nil
|
|
643
|
+
end
|
|
644
|
+
raise
|
|
645
|
+
end
|
|
549
646
|
nil
|
|
550
647
|
end
|
|
551
648
|
|
|
552
|
-
# Copies a file or directory out.
|
|
649
|
+
# Copies a file or directory out. A directory comes as one tar from the
|
|
650
|
+
# API's folder route.
|
|
553
651
|
def download(remote, local)
|
|
554
652
|
entry = stat(remote)
|
|
555
653
|
raise NotFoundError.new("#{remote} does not exist.", code: "file_not_found", status: 404) unless entry
|
|
@@ -559,18 +657,14 @@ module WithRuntime
|
|
|
559
657
|
File.binwrite(local, read(remote))
|
|
560
658
|
return nil
|
|
561
659
|
end
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
660
|
+
# The sandbox's own tar packs it as it streams, and it is unpacked as it
|
|
661
|
+
# arrives; one cut short is refused and nothing is put in place.
|
|
662
|
+
unpacker = Tar::Unpacker.new(local)
|
|
566
663
|
begin
|
|
567
|
-
|
|
664
|
+
t.chunks(@sandbox.path("/files/archive"), query: { "path" => remote, "gzip" => true }) { |chunk| unpacker.feed(chunk) }
|
|
665
|
+
unpacker.finish
|
|
568
666
|
ensure
|
|
569
|
-
|
|
570
|
-
remove(staging)
|
|
571
|
-
rescue Error
|
|
572
|
-
nil # /tmp is cleared with the sandbox.
|
|
573
|
-
end
|
|
667
|
+
unpacker.discard
|
|
574
668
|
end
|
|
575
669
|
nil
|
|
576
670
|
end
|
|
@@ -587,11 +681,16 @@ module WithRuntime
|
|
|
587
681
|
@sandbox = sandbox
|
|
588
682
|
end
|
|
589
683
|
|
|
590
|
-
# Shares +port+, or changes its visibility if it is
|
|
591
|
-
# +visibility+ is "private" (the default: a token is
|
|
592
|
-
# +ttl_seconds+ is how long the returned token lasts
|
|
593
|
-
|
|
594
|
-
|
|
684
|
+
# Shares +port+, or changes its visibility and embed origins if it is
|
|
685
|
+
# shared already. +visibility+ is "private" (the default: a token is
|
|
686
|
+
# needed) or "public"; +ttl_seconds+ is how long the returned token lasts
|
|
687
|
+
# (60 s to 7 days). +embed_origins+ names the sites that may show it in an
|
|
688
|
+
# iframe (["https://app.example.com", "https://*.example.com"]); any other
|
|
689
|
+
# site's iframe is refused. Omitted, a shared port keeps its list; [] is
|
|
690
|
+
# any site, the default.
|
|
691
|
+
def create(port, visibility: nil, ttl_seconds: nil, embed_origins: nil)
|
|
692
|
+
body = { "port" => port, "visibility" => visibility, "ttlSeconds" => ttl_seconds,
|
|
693
|
+
"embedOrigins" => embed_origins }.compact
|
|
595
694
|
Record.new(t.json("POST", path, body: body))
|
|
596
695
|
end
|
|
597
696
|
|