pinesandbox 0.3.18 → 0.3.20
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/LICENSE +201 -0
- data/README.md +222 -161
- data/lib/pinesandbox/adapters/coordinator_adapter.rb +90 -114
- data/lib/pinesandbox/agent_events.rb +2 -2
- data/lib/pinesandbox/agent_turn.rb +25 -2
- data/lib/pinesandbox/artifacts.rb +776 -0
- data/lib/pinesandbox/attach_credentials_source.rb +58 -16
- data/lib/pinesandbox/bind_hpke.rb +4 -4
- data/lib/pinesandbox/client.rb +75 -37
- data/lib/pinesandbox/computer.rb +146 -116
- data/lib/pinesandbox/computer_binding.rb +14 -10
- data/lib/pinesandbox/control_plane.rb +10 -10
- data/lib/pinesandbox/error_taxonomy.rb +3 -0
- data/lib/pinesandbox/errors.rb +83 -76
- data/lib/pinesandbox/models/artifact.rb +1 -1
- data/lib/pinesandbox/models/artifact_file.rb +40 -0
- data/lib/pinesandbox/models/computer_session.rb +7 -3
- data/lib/pinesandbox/operations.rb +2 -3
- data/lib/pinesandbox/{runtime_details.rb → sandbox_details.rb} +4 -4
- data/lib/pinesandbox/{runtime_handle.rb → sandbox_handle.rb} +16 -20
- data/lib/pinesandbox/session.rb +39 -4
- data/lib/pinesandbox/spec_version_middleware.rb +1 -1
- data/lib/pinesandbox/streamed_read.rb +80 -0
- data/lib/pinesandbox/version.rb +3 -1
- data/lib/pinesandbox/zone.rb +8 -11
- data/lib/pinesandbox.rb +4 -2
- data/pinesandbox.gemspec +5 -3
- metadata +11 -6
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "json"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "adapters/coordinator_adapter"
|
|
9
|
+
require_relative "clock"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "response_handler"
|
|
12
|
+
require_relative "streamed_read"
|
|
13
|
+
require_relative "version"
|
|
14
|
+
require_relative "models/artifact_file"
|
|
15
|
+
|
|
16
|
+
module PineSandbox
|
|
17
|
+
# @api private — what a path in a Computer's artifacts may be, checked the
|
|
18
|
+
# same way by every SDK, pine-artifact and Pine before any request (a copy
|
|
19
|
+
# of contract/artifact-paths.json, which the test suite runs).
|
|
20
|
+
module ArtifactPath
|
|
21
|
+
MAX_BYTES = 900
|
|
22
|
+
# The C0 and C1 control characters.
|
|
23
|
+
CONTROL = /[\u0000-\u001F\u007F-\u009F]/
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Returns `path` as UTF-8 when it is 1 to MAX_BYTES bytes of valid UTF-8
|
|
28
|
+
# with no control character and no empty, "." or ".." segment (so no
|
|
29
|
+
# leading, trailing or doubled "/"); raises ArgumentError otherwise.
|
|
30
|
+
# Anything else is allowed. `name` is the argument, for the message.
|
|
31
|
+
def check!(path, name: "artifact path")
|
|
32
|
+
raise ArgumentError, "#{name} must be a String" unless path.is_a?(String)
|
|
33
|
+
raise ArgumentError, "#{name} must be 1 to #{MAX_BYTES} bytes" unless path.bytesize.between?(1, MAX_BYTES)
|
|
34
|
+
|
|
35
|
+
utf8 = path.dup.force_encoding(Encoding::UTF_8)
|
|
36
|
+
raise ArgumentError, "#{name} must be valid UTF-8" unless utf8.valid_encoding?
|
|
37
|
+
raise ArgumentError, "#{name} must not contain a control character" if utf8.match?(CONTROL)
|
|
38
|
+
if utf8.split("/", -1).any? { |segment| segment.empty? || segment == "." || segment == ".." }
|
|
39
|
+
raise ArgumentError, "#{name} must not have an empty, \".\" or \"..\" segment"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
utf8
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @api private — the content type an upload gets when it names none, the
|
|
47
|
+
# same in every SDK whatever the host's MIME database holds (a copy of
|
|
48
|
+
# contract/artifact-content-types.json, which the test suite checks this
|
|
49
|
+
# table against).
|
|
50
|
+
module ArtifactContentType
|
|
51
|
+
DEFAULT = "application/octet-stream"
|
|
52
|
+
# By extension, in lower case.
|
|
53
|
+
TYPES = {
|
|
54
|
+
"css" => "text/css",
|
|
55
|
+
"csv" => "text/csv",
|
|
56
|
+
"doc" => "application/msword",
|
|
57
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
58
|
+
"gif" => "image/gif",
|
|
59
|
+
"gz" => "application/gzip",
|
|
60
|
+
"htm" => "text/html",
|
|
61
|
+
"html" => "text/html",
|
|
62
|
+
"jpeg" => "image/jpeg",
|
|
63
|
+
"jpg" => "image/jpeg",
|
|
64
|
+
"js" => "text/javascript",
|
|
65
|
+
"json" => "application/json",
|
|
66
|
+
"md" => "text/markdown",
|
|
67
|
+
"mp3" => "audio/mpeg",
|
|
68
|
+
"mp4" => "video/mp4",
|
|
69
|
+
"pdf" => "application/pdf",
|
|
70
|
+
"png" => "image/png",
|
|
71
|
+
"ppt" => "application/vnd.ms-powerpoint",
|
|
72
|
+
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
73
|
+
"svg" => "image/svg+xml",
|
|
74
|
+
"tar" => "application/x-tar",
|
|
75
|
+
"tsv" => "text/tab-separated-values",
|
|
76
|
+
"txt" => "text/plain",
|
|
77
|
+
"wav" => "audio/wav",
|
|
78
|
+
"webm" => "video/webm",
|
|
79
|
+
"webp" => "image/webp",
|
|
80
|
+
"xls" => "application/vnd.ms-excel",
|
|
81
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
82
|
+
"xml" => "application/xml",
|
|
83
|
+
"yaml" => "application/yaml",
|
|
84
|
+
"yml" => "application/yaml",
|
|
85
|
+
"zip" => "application/zip"
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
module_function
|
|
89
|
+
|
|
90
|
+
# The type for the extension of `path`'s last segment: what follows its
|
|
91
|
+
# last ".", unless that "." starts the segment, in lower case. DEFAULT
|
|
92
|
+
# for any other path.
|
|
93
|
+
def for_path(path)
|
|
94
|
+
segment = path.rpartition("/").last
|
|
95
|
+
dot = segment.rindex(".")
|
|
96
|
+
return DEFAULT unless dot&.positive?
|
|
97
|
+
|
|
98
|
+
TYPES.fetch(segment[(dot + 1)..].downcase, DEFAULT)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# client.artifacts(computer_id) — a Computer's artifacts: the files it keeps
|
|
103
|
+
# beyond its sessions, reachable with the project key whether the Computer
|
|
104
|
+
# is running or stopped.
|
|
105
|
+
#
|
|
106
|
+
# artifacts = client.artifacts(computer_id)
|
|
107
|
+
# page = artifacts.list(prefix: "research/") # page.files, page.folders, page.next_page_token
|
|
108
|
+
# artifacts.upload("inputs/brief.txt", Pathname("brief.txt"), note: "the brief")
|
|
109
|
+
# artifacts.download("reports/q3.pdf", "q3.pdf") # => bytes written
|
|
110
|
+
# artifacts.open("reports/q3.csv") { |chunk| io.write(chunk) }
|
|
111
|
+
# artifacts.copy(other_computer_id, "templates/form.pdf", "inputs/form.pdf")
|
|
112
|
+
# artifacts.delete("inputs/brief.txt")
|
|
113
|
+
#
|
|
114
|
+
# A path is relative to the Computer's artifacts, such as "reports/q3.pdf":
|
|
115
|
+
# 1 to 900 bytes of UTF-8 with no control character and no empty, "." or
|
|
116
|
+
# ".." segment, or the call raises ArgumentError before any request.
|
|
117
|
+
# The handle gets one short-lived storage token from Pine, then sends every
|
|
118
|
+
# request to storage itself: no file passes through Pine. It keeps the
|
|
119
|
+
# token and gets a new one before it lapses, or once when storage refuses
|
|
120
|
+
# it. A missing file is ArtifactNotFoundError; any other refusal from
|
|
121
|
+
# storage is the status tier's ApiError, with storage's message, and an
|
|
122
|
+
# answer the storage protocol doesn't allow, such as a success whose body
|
|
123
|
+
# isn't a JSON object, is PineSandbox::Error. An error's #retryable? follows
|
|
124
|
+
# the storage rule every SDK shares (contract/storage-retry.json): true for
|
|
125
|
+
# a request timeout, a 429 and any 5xx but 501. An error's #op names the
|
|
126
|
+
# route with "{object}" for the file's name; the path is only in typed
|
|
127
|
+
# fields such as ArtifactNotFoundError#path. Public links are the running
|
|
128
|
+
# Computer's: Computer#artifact_link.
|
|
129
|
+
class Artifacts
|
|
130
|
+
include PineSandbox::ResponseHandler
|
|
131
|
+
|
|
132
|
+
# A token is replaced this many seconds before it expires.
|
|
133
|
+
TOKEN_RENEW_BEFORE = 300
|
|
134
|
+
|
|
135
|
+
# The storage token and where the artifacts are. The access token never
|
|
136
|
+
# appears in #inspect.
|
|
137
|
+
Token = Struct.new(:access_token, :expires_at, :endpoint, :bucket, :prefix, :writable,
|
|
138
|
+
:used_bytes, :limit_bytes, :copy_from, keyword_init: true) do
|
|
139
|
+
def self.from_payload(payload)
|
|
140
|
+
new(access_token: payload["access_token"], expires_at: Time.iso8601(payload["expires_at"]),
|
|
141
|
+
endpoint: payload["endpoint"].delete_suffix("/"), bucket: payload["bucket"],
|
|
142
|
+
prefix: payload["prefix"], writable: payload["writable"], used_bytes: payload["used_bytes"],
|
|
143
|
+
limit_bytes: payload["limit_bytes"], copy_from: payload["copy_from"] || {})
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def inspect = "#<PineSandbox::Artifacts::Token bucket=#{bucket.inspect} prefix=#{prefix.inspect} [access_token redacted]>"
|
|
147
|
+
alias_method :to_s, :inspect
|
|
148
|
+
|
|
149
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
150
|
+
end
|
|
151
|
+
private_constant :Token
|
|
152
|
+
|
|
153
|
+
# An upload's bytes, readable from any offset: a String, a local file (a
|
|
154
|
+
# Pathname), or an IO whose size is known, read from its current position.
|
|
155
|
+
class Upload
|
|
156
|
+
attr_reader :size
|
|
157
|
+
|
|
158
|
+
def initialize(source)
|
|
159
|
+
@start = 0
|
|
160
|
+
case source
|
|
161
|
+
when String
|
|
162
|
+
@io = StringIO.new(source)
|
|
163
|
+
@size = source.bytesize
|
|
164
|
+
when Pathname
|
|
165
|
+
@path = source
|
|
166
|
+
@size = File.size(source)
|
|
167
|
+
else
|
|
168
|
+
unless %i[read size seek pos].all? { |name| source.respond_to?(name) }
|
|
169
|
+
raise ArgumentError, "source must be a String of bytes, a Pathname, or an IO whose size is known"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
@io = source
|
|
173
|
+
@start = source.pos
|
|
174
|
+
@size = source.size - @start
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Yields the bytes from `offset` to the end, as an IO.
|
|
179
|
+
def from(offset)
|
|
180
|
+
io = @path ? File.open(@path, "rb") : @io
|
|
181
|
+
io.seek(@start + offset)
|
|
182
|
+
yield io
|
|
183
|
+
ensure
|
|
184
|
+
io&.close if @path
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
private_constant :Upload
|
|
188
|
+
|
|
189
|
+
BlockError = StreamedRead::BlockError
|
|
190
|
+
private_constant :BlockError
|
|
191
|
+
|
|
192
|
+
attr_reader :computer_id
|
|
193
|
+
|
|
194
|
+
# @api private — Client#artifacts builds the handle. `token_source` is
|
|
195
|
+
# the project-key Portal client (#artifact_token).
|
|
196
|
+
def initialize(computer_id:, token_source:, clock: PineSandbox::SYSTEM_CLOCK)
|
|
197
|
+
@computer_id = computer_id
|
|
198
|
+
@token_source = token_source
|
|
199
|
+
@clock = clock
|
|
200
|
+
@tokens = {}
|
|
201
|
+
@connections = {}
|
|
202
|
+
@mutex = Mutex.new
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# One page of the files under `prefix` ("" for all of them, "research/"
|
|
206
|
+
# for one folder). Unless `recursive`, a file in a deeper folder is
|
|
207
|
+
# summarized by its folder in the page's #folders. Pass a page's
|
|
208
|
+
# #next_page_token as `page_token` for the next one; `page_size` caps how
|
|
209
|
+
# many files and folders a page holds.
|
|
210
|
+
#
|
|
211
|
+
# @return [PineSandbox::Models::ArtifactPage]
|
|
212
|
+
def list(prefix: "", recursive: false, page_token: nil, page_size: nil)
|
|
213
|
+
unless page_size.nil? || (page_size.is_a?(Integer) && page_size.positive?)
|
|
214
|
+
raise ArgumentError, "page_size must be a positive integer"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
with_token do |token|
|
|
218
|
+
params = { "prefix" => "#{token.prefix}#{prefix}" }
|
|
219
|
+
params["delimiter"] = "/" unless recursive
|
|
220
|
+
params["pageToken"] = page_token if page_token
|
|
221
|
+
params["maxResults"] = page_size if page_size
|
|
222
|
+
route = objects_route(token)
|
|
223
|
+
op = "GET /#{route}"
|
|
224
|
+
response = resending { storage_request(token, :get, route, op: op, params: params) }
|
|
225
|
+
raise_storage_error!(response, op: op) unless response.success?
|
|
226
|
+
|
|
227
|
+
body = parse_answer(response, op: op)
|
|
228
|
+
Models::ArtifactPage.new(
|
|
229
|
+
files: Array(body["items"]).map { |object| Models::ArtifactFile.from_object(object, prefix: token.prefix) },
|
|
230
|
+
folders: Array(body["prefixes"]).map { |folder| folder.delete_prefix(token.prefix) },
|
|
231
|
+
next_page_token: body["nextPageToken"]
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Saves `source` as the file at `path`, replacing any file there, and
|
|
237
|
+
# returns the saved file. `source` is the bytes (a String), a local file
|
|
238
|
+
# (a Pathname), or an IO whose size is known, such as a File, StringIO or
|
|
239
|
+
# Tempfile, read from its current position. `content_type` defaults to
|
|
240
|
+
# the one every SDK gives the path's extension, else
|
|
241
|
+
# application/octet-stream; `note` is kept with the file and listed with
|
|
242
|
+
# it.
|
|
243
|
+
#
|
|
244
|
+
# The bytes go to storage in one resumable transfer, whose start is sent
|
|
245
|
+
# again when its answer is lost. After a dropped connection or a storage
|
|
246
|
+
# answer worth retrying mid-transfer (a request timeout, a 429 or a 5xx but
|
|
247
|
+
# 501), the SDK waits (RetryPolicy's stream backoff), asks storage how much
|
|
248
|
+
# it kept, and sends the rest; it gives up after
|
|
249
|
+
# RetryPolicy::STREAM_RECONNECT_BUDGET tries in a row that moved nothing.
|
|
250
|
+
# Raises ArtifactStorageFullError, sending nothing, while the Computer's
|
|
251
|
+
# artifacts are over their limit; after one, the next upload or copy asks
|
|
252
|
+
# Pine again, so room made by deleting files is used.
|
|
253
|
+
#
|
|
254
|
+
# @return [PineSandbox::Models::ArtifactFile]
|
|
255
|
+
def upload(path, source, content_type: nil, note: nil)
|
|
256
|
+
path = ArtifactPath.check!(path)
|
|
257
|
+
upload = Upload.new(source)
|
|
258
|
+
content_type ||= ArtifactContentType.for_path(path)
|
|
259
|
+
token, session = with_token(write: true) do |current|
|
|
260
|
+
[current, start_upload(current, "#{current.prefix}#{path}", upload.size, content_type, note)]
|
|
261
|
+
end
|
|
262
|
+
Models::ArtifactFile.from_object(send_upload(session, upload, content_type), prefix: token.prefix)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Saves the file at `path` to the local file `destination` and returns
|
|
266
|
+
# the number of bytes written. The bytes land in a temporary file beside
|
|
267
|
+
# `destination` that is renamed over it once complete, so `destination`
|
|
268
|
+
# is never left half-written. It resumes as #open does.
|
|
269
|
+
def download(path, destination)
|
|
270
|
+
path = ArtifactPath.check!(path)
|
|
271
|
+
destination = File.expand_path(destination.to_s)
|
|
272
|
+
part = File.join(File.dirname(destination), ".#{File.basename(destination)}.#{SecureRandom.hex(6)}.part")
|
|
273
|
+
written = File.open(part, File::WRONLY | File::CREAT | File::EXCL | File::BINARY) do |file|
|
|
274
|
+
receive(path) { |chunk| file.write(chunk) }
|
|
275
|
+
end
|
|
276
|
+
File.rename(part, destination)
|
|
277
|
+
written
|
|
278
|
+
ensure
|
|
279
|
+
File.delete(part) if part && File.exist?(part)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The file at `path` as a stream, for copying onward without holding it
|
|
283
|
+
# in memory: yields binary chunks as they arrive. Without a block,
|
|
284
|
+
# returns an Enumerator of the chunks that sends the request only when
|
|
285
|
+
# iterated (`artifacts.open(path).each { |chunk| io.write(chunk) }`).
|
|
286
|
+
#
|
|
287
|
+
# After a dropped connection, a request timeout, a 429 or a 5xx but 501,
|
|
288
|
+
# the SDK waits (RetryPolicy's stream backoff) and asks again from the
|
|
289
|
+
# next byte, of
|
|
290
|
+
# the same version of the file: one replaced meanwhile raises storage's
|
|
291
|
+
# PreconditionFailedError rather than joining two versions. It gives up
|
|
292
|
+
# after RetryPolicy::STREAM_RECONNECT_BUDGET tries in a row that received
|
|
293
|
+
# nothing. A missing file raises ArtifactNotFoundError before any chunk.
|
|
294
|
+
#
|
|
295
|
+
# @yieldparam chunk [String] the next bytes, binary encoding.
|
|
296
|
+
# @return [nil, Enumerator]
|
|
297
|
+
def open(path, &block)
|
|
298
|
+
path = ArtifactPath.check!(path)
|
|
299
|
+
return enum_for(:open, path) unless block
|
|
300
|
+
|
|
301
|
+
receive(path, &block)
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Copies the file at `from_path` in `from_computer_id` (this Computer or
|
|
306
|
+
# another of the project's) to `path` in this one, replacing any file
|
|
307
|
+
# there, and returns the copy. Storage copies the bytes itself; nothing is
|
|
308
|
+
# downloaded, and a step whose answer was lost is sent again. Raises
|
|
309
|
+
# ArtifactNotFoundError for `from_path` when that file isn't there, and
|
|
310
|
+
# ArtifactStorageFullError, copying nothing, while this Computer's
|
|
311
|
+
# artifacts are over their limit.
|
|
312
|
+
#
|
|
313
|
+
# @return [PineSandbox::Models::ArtifactFile]
|
|
314
|
+
def copy(from_computer_id, from_path, path)
|
|
315
|
+
unless from_computer_id.to_s.match?(Computer::UUID_V7)
|
|
316
|
+
raise ArgumentError, "from_computer_id must be a lowercase UUIDv7"
|
|
317
|
+
end
|
|
318
|
+
from_path = ArtifactPath.check!(from_path, name: "from_path")
|
|
319
|
+
path = ArtifactPath.check!(path)
|
|
320
|
+
|
|
321
|
+
# A copy within this Computer reads with its own token, under its own
|
|
322
|
+
# prefix; another Computer's files need a token that may read them.
|
|
323
|
+
source_id = from_computer_id == @computer_id ? nil : from_computer_id
|
|
324
|
+
# A large file takes several rewrite calls; each answer says how to go on.
|
|
325
|
+
rewrite_token = nil
|
|
326
|
+
op = nil
|
|
327
|
+
loop do
|
|
328
|
+
token, body = with_token(copy_from: source_id, write: true) do |current|
|
|
329
|
+
source_prefix = source_id ? current.copy_from.fetch(source_id) : current.prefix
|
|
330
|
+
source, source_label = object_route(current, "#{source_prefix}#{from_path}")
|
|
331
|
+
target = "/rewriteTo/b/#{escape(current.bucket)}/o/"
|
|
332
|
+
route = "#{source}#{target}#{escape("#{current.prefix}#{path}")}"
|
|
333
|
+
op = "POST /#{source_label}#{target}{object}"
|
|
334
|
+
params = rewrite_token ? { "rewriteToken" => rewrite_token } : {}
|
|
335
|
+
# Rewriting the same object to the same name again changes nothing,
|
|
336
|
+
# so a rewrite whose answer was lost is sent again.
|
|
337
|
+
response = resending { storage_request(current, :post, route, op: op, params: params, json: {}) }
|
|
338
|
+
raise_storage_error!(response, op: op, path: from_path) unless response.success?
|
|
339
|
+
|
|
340
|
+
[current, parse_answer(response, op: op)]
|
|
341
|
+
end
|
|
342
|
+
if body["done"] == true
|
|
343
|
+
unless body["resource"].is_a?(Hash)
|
|
344
|
+
raise PineSandbox::Error, PineSandbox.with_context("storage ended the copy without the copy", op: op)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
return Models::ArtifactFile.from_object(body["resource"], prefix: token.prefix)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
rewrite_token = body["rewriteToken"]
|
|
351
|
+
if rewrite_token.to_s.empty?
|
|
352
|
+
raise PineSandbox::Error, PineSandbox.with_context("storage stopped the copy without finishing it", op: op)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Deletes the file at `path`. Raises ArtifactNotFoundError when it isn't
|
|
358
|
+
# there. A delete whose answer was lost is sent again; when that finds no
|
|
359
|
+
# file, the first one removed it, and the delete succeeds. Returns true.
|
|
360
|
+
def delete(path)
|
|
361
|
+
path = ArtifactPath.check!(path)
|
|
362
|
+
with_token do |token|
|
|
363
|
+
route, label = object_route(token, "#{token.prefix}#{path}")
|
|
364
|
+
op = "DELETE /#{label}"
|
|
365
|
+
resent = false
|
|
366
|
+
response = resending do |resend|
|
|
367
|
+
resent = resend
|
|
368
|
+
storage_request(token, :delete, route, op: op)
|
|
369
|
+
end
|
|
370
|
+
next if response.status == 404 && resent
|
|
371
|
+
|
|
372
|
+
raise_storage_error!(response, op: op, path: path) unless response.success?
|
|
373
|
+
end
|
|
374
|
+
true
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
# Runs the block with a current storage token: this Computer's or, with
|
|
380
|
+
# `copy_from`, one that may also read that Computer's artifacts. When
|
|
381
|
+
# storage refuses the token (401), runs it once more with a new one.
|
|
382
|
+
# `write` needs a writable token: a kept one that isn't is replaced, and
|
|
383
|
+
# a new one that isn't either means the artifacts are over their limit.
|
|
384
|
+
def with_token(copy_from: nil, write: false)
|
|
385
|
+
current = token(copy_from, write: write)
|
|
386
|
+
renewed = false
|
|
387
|
+
begin
|
|
388
|
+
if write && !current.writable
|
|
389
|
+
raise ArtifactStorageFullError.new(used_bytes: current.used_bytes, limit_bytes: current.limit_bytes)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
yield current
|
|
393
|
+
rescue UnauthorizedError
|
|
394
|
+
raise if renewed
|
|
395
|
+
|
|
396
|
+
renewed = true
|
|
397
|
+
current = token(copy_from, refused: current, write: write)
|
|
398
|
+
retry
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# The kept token for `copy_from`, or a new one when there is none, when it
|
|
403
|
+
# expires within TOKEN_RENEW_BEFORE, when it is the one storage `refused`,
|
|
404
|
+
# or when a `write` finds it read-only (files may have been deleted since).
|
|
405
|
+
def token(copy_from, refused: nil, write: false)
|
|
406
|
+
@mutex.synchronize do
|
|
407
|
+
kept = @tokens[copy_from]
|
|
408
|
+
usable = kept && !kept.equal?(refused) && (kept.writable || !write) &&
|
|
409
|
+
@clock.now < kept.expires_at - TOKEN_RENEW_BEFORE
|
|
410
|
+
return kept if usable
|
|
411
|
+
|
|
412
|
+
payload = @token_source.artifact_token(@computer_id, copy_from: copy_from)
|
|
413
|
+
@tokens[copy_from] = Token.from_payload(payload)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# A connection to a storage origin. No credential rides on it: each
|
|
418
|
+
# request carries its own.
|
|
419
|
+
def connection(origin)
|
|
420
|
+
@mutex.synchronize do
|
|
421
|
+
@connections[origin] ||= Faraday.new(url: origin) do |conn|
|
|
422
|
+
conn.headers["User-Agent"] = PineSandbox::USER_AGENT
|
|
423
|
+
conn.options.open_timeout = Adapters::CoordinatorAdapter::DEFAULT_OPEN_TIMEOUT
|
|
424
|
+
conn.options.timeout = Adapters::CoordinatorAdapter::DEFAULT_HTTP_TIMEOUT
|
|
425
|
+
conn.adapter Faraday.default_adapter
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Sends one storage request with the token as its bearer. `op` names it
|
|
431
|
+
# in errors.
|
|
432
|
+
def storage_request(token, method, route, op:, params: {}, json: nil, headers: {})
|
|
433
|
+
transport(op) do
|
|
434
|
+
connection(token.endpoint).run_request(method, route, nil, nil) do |req|
|
|
435
|
+
req.params.update(params)
|
|
436
|
+
req.headers["Authorization"] = "Bearer #{token.access_token}"
|
|
437
|
+
req.headers["Accept"] = "application/json"
|
|
438
|
+
headers.each { |name, value| req.headers[name] = value }
|
|
439
|
+
if json
|
|
440
|
+
req.headers["Content-Type"] = "application/json"
|
|
441
|
+
req.body = JSON.generate(json)
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Runs the block, a request safe to send again, once more after each
|
|
448
|
+
# transport fault or answer the storage retry rule allows, with
|
|
449
|
+
# RetryPolicy's transient backoff, up to RetryPolicy::TRANSIENT_ATTEMPTS
|
|
450
|
+
# sends, and returns the last answer. Yields whether this send is a
|
|
451
|
+
# resend, which may find the first one's work already done.
|
|
452
|
+
def resending
|
|
453
|
+
attempt = 0
|
|
454
|
+
loop do
|
|
455
|
+
attempt += 1
|
|
456
|
+
begin
|
|
457
|
+
response = yield attempt > 1
|
|
458
|
+
rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError
|
|
459
|
+
raise if attempt >= RetryPolicy::TRANSIENT_ATTEMPTS
|
|
460
|
+
else
|
|
461
|
+
return response unless retry_status?(response.status) && attempt < RetryPolicy::TRANSIENT_ATTEMPTS
|
|
462
|
+
end
|
|
463
|
+
@clock.sleep(RetryPolicy.transient_backoff(attempt, @clock))
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Opens a resumable upload of `size` bytes to the object `name` and
|
|
468
|
+
# returns its session URI.
|
|
469
|
+
def start_upload(token, name, size, content_type, note)
|
|
470
|
+
body = { name: name, contentType: content_type }
|
|
471
|
+
body[:metadata] = { "pine-note" => note } unless note.nil?
|
|
472
|
+
route = "upload/#{objects_route(token)}"
|
|
473
|
+
op = "POST /#{route}"
|
|
474
|
+
# Starting a session writes nothing, and a session a lost answer left
|
|
475
|
+
# unused expires on its own, so a start is sent again.
|
|
476
|
+
response = resending do
|
|
477
|
+
storage_request(
|
|
478
|
+
token, :post, route, op: op,
|
|
479
|
+
params: { "uploadType" => "resumable", "name" => name }, json: body,
|
|
480
|
+
headers: { "X-Upload-Content-Type" => content_type, "X-Upload-Content-Length" => size.to_s }
|
|
481
|
+
)
|
|
482
|
+
end
|
|
483
|
+
raise_storage_error!(response, op: op) unless response.success?
|
|
484
|
+
|
|
485
|
+
session = response.headers["location"].to_s
|
|
486
|
+
raise PineSandbox::Error, PineSandbox.with_context("storage opened no upload session", op: op) if session.empty?
|
|
487
|
+
|
|
488
|
+
session
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Sends the upload's bytes to its session and returns the saved object.
|
|
492
|
+
# A PUT that ends without the whole file (a dropped connection, an answer
|
|
493
|
+
# worth retrying, or a 308 that kept only part) is followed, after a
|
|
494
|
+
# wait, by asking storage how much it kept; the rest goes in the next PUT.
|
|
495
|
+
# The budget counts tries in a row that moved nothing.
|
|
496
|
+
def send_upload(session, upload, content_type)
|
|
497
|
+
op = "PUT #{URI(session).path}"
|
|
498
|
+
offset = 0
|
|
499
|
+
failures = 0
|
|
500
|
+
loop do
|
|
501
|
+
outcome = upload_call(op) { put_bytes(session, upload, offset, content_type) }
|
|
502
|
+
until finished?(outcome)
|
|
503
|
+
raise_upload_failure!(outcome, op) unless interrupted?(outcome)
|
|
504
|
+
|
|
505
|
+
failures += 1
|
|
506
|
+
raise_upload_failure!(outcome, op) if failures > RetryPolicy::STREAM_RECONNECT_BUDGET
|
|
507
|
+
@clock.sleep(RetryPolicy.stream_backoff(failures, @clock))
|
|
508
|
+
outcome = upload_call(op) { query_upload(session, upload.size, content_type) }
|
|
509
|
+
break if outcome.is_a?(Faraday::Response) && outcome.status == 308
|
|
510
|
+
end
|
|
511
|
+
return parse_answer(outcome, op: op) if finished?(outcome)
|
|
512
|
+
|
|
513
|
+
kept = kept_bytes(outcome)
|
|
514
|
+
failures = 0 if kept > offset
|
|
515
|
+
offset = kept
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# The session URI is the upload's credential, so these PUTs carry no
|
|
520
|
+
# Authorization. Once storage keeps every byte, the PUT asks for the
|
|
521
|
+
# finished upload instead.
|
|
522
|
+
def put_bytes(session, upload, offset, content_type)
|
|
523
|
+
return query_upload(session, upload.size, content_type) if upload.size.positive? && offset >= upload.size
|
|
524
|
+
|
|
525
|
+
upload.from(offset) do |body|
|
|
526
|
+
connection(origin(session)).put(session) do |req|
|
|
527
|
+
req.headers["Content-Type"] = content_type
|
|
528
|
+
req.headers["Content-Length"] = (upload.size - offset).to_s
|
|
529
|
+
req.headers["Content-Range"] = "bytes #{offset}-#{upload.size - 1}/#{upload.size}" if upload.size.positive?
|
|
530
|
+
req.body = body
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def query_upload(session, size, content_type)
|
|
536
|
+
connection(origin(session)).put(session) do |req|
|
|
537
|
+
req.headers["Content-Type"] = content_type
|
|
538
|
+
req.headers["Content-Length"] = "0"
|
|
539
|
+
req.headers["Content-Range"] = "bytes */#{size}"
|
|
540
|
+
req.body = ""
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# An upload request's response, or the transport fault that ended it.
|
|
545
|
+
def upload_call(op)
|
|
546
|
+
transport(op) { yield }
|
|
547
|
+
rescue PineSandbox::ConnectionError, PineSandbox::TimeoutError => e
|
|
548
|
+
e
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def finished?(outcome) = outcome.is_a?(Faraday::Response) && [200, 201].include?(outcome.status)
|
|
552
|
+
|
|
553
|
+
def interrupted?(outcome)
|
|
554
|
+
!outcome.is_a?(Faraday::Response) || outcome.status == 308 || retry_status?(outcome.status)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Raises why an upload stopped: its transport fault, storage's refusal,
|
|
558
|
+
# or, after a 308, that storage stopped taking bytes.
|
|
559
|
+
def raise_upload_failure!(outcome, op)
|
|
560
|
+
raise outcome unless outcome.is_a?(Faraday::Response)
|
|
561
|
+
if outcome.status == 308
|
|
562
|
+
raise PineSandbox::Error, PineSandbox.with_context("storage stopped the upload without finishing it", op: op)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
raise_storage_error!(outcome, op: op)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# The bytes storage holds, from a 308's `Range: bytes=0-N` (none without it).
|
|
569
|
+
def kept_bytes(response)
|
|
570
|
+
last = response.headers["range"].to_s[/\Abytes=0-(\d+)\z/, 1]
|
|
571
|
+
last ? Integer(last, 10) + 1 : 0
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Passes the file at `path` to the block in chunks, each byte once, and
|
|
575
|
+
# returns how many it passed. After a dropped connection or an answer
|
|
576
|
+
# worth retrying it waits and asks again from the next byte with a Range request pinned
|
|
577
|
+
# to the version the first answer named, so a file replaced meanwhile
|
|
578
|
+
# fails (storage's 412) instead of joining two versions. The budget
|
|
579
|
+
# counts tries in a row that received nothing. The block's own error
|
|
580
|
+
# ends the read as it is.
|
|
581
|
+
def receive(path, &sink)
|
|
582
|
+
received = 0
|
|
583
|
+
failures = 0
|
|
584
|
+
generation = nil
|
|
585
|
+
loop do
|
|
586
|
+
before = received
|
|
587
|
+
begin
|
|
588
|
+
read_object(path, from: received, generation: generation) do |chunk, headers|
|
|
589
|
+
generation ||= headers["x-goog-generation"]
|
|
590
|
+
sink.call(chunk)
|
|
591
|
+
received += chunk.bytesize
|
|
592
|
+
end
|
|
593
|
+
return received
|
|
594
|
+
rescue BlockError => e
|
|
595
|
+
raise e.error
|
|
596
|
+
rescue PineSandbox::Error => e
|
|
597
|
+
raise unless resumable?(e)
|
|
598
|
+
|
|
599
|
+
failures = received > before ? 1 : failures + 1
|
|
600
|
+
raise if failures > RetryPolicy::STREAM_RECONNECT_BUDGET
|
|
601
|
+
|
|
602
|
+
@clock.sleep(RetryPolicy.stream_backoff(failures, @clock))
|
|
603
|
+
end
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Whether a failed read is worth resuming: a dropped connection, a
|
|
608
|
+
# timeout, or an answer worth retrying.
|
|
609
|
+
def resumable?(error)
|
|
610
|
+
case error
|
|
611
|
+
when PineSandbox::ConnectionError, PineSandbox::TimeoutError then true
|
|
612
|
+
when ApiError then retry_status?(error.status.to_i)
|
|
613
|
+
else false
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Whether a storage answer of `status` is worth sending again, by the rule
|
|
618
|
+
# every SDK shares (contract/storage-retry.json): a request timeout, a 429
|
|
619
|
+
# and any 5xx but 501. A 412 means the file changed under a read pinned to
|
|
620
|
+
# its version, so it never is.
|
|
621
|
+
def retry_status?(status) = status == 408 || status == 429 || (status >= 500 && status != 501)
|
|
622
|
+
|
|
623
|
+
# GETs the file's bytes from byte `from` on, passing each new chunk of a
|
|
624
|
+
# successful answer, with its headers, to the block. A read from a later
|
|
625
|
+
# byte asks for `generation` only. Any other answer raises before a
|
|
626
|
+
# chunk: ArtifactNotFoundError for a missing file.
|
|
627
|
+
def read_object(path, from: 0, generation: nil, &sink)
|
|
628
|
+
with_token do |token|
|
|
629
|
+
route, label = object_route(token, "#{token.prefix}#{path}")
|
|
630
|
+
stream_media(token, route, "GET /#{label}", path, from, generation, sink)
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def stream_media(token, route, op, path, from, generation, sink)
|
|
635
|
+
delivered = 0
|
|
636
|
+
# The bytes at the answer's start that were passed on before, known
|
|
637
|
+
# once the first chunk shows the answer.
|
|
638
|
+
repeated = nil
|
|
639
|
+
deliver = lambda do |chunk, answer|
|
|
640
|
+
repeated ||= repeated_bytes(answer, from, op)
|
|
641
|
+
delivered += chunk.bytesize
|
|
642
|
+
fresh = chunk.byteslice([repeated, chunk.bytesize].min..)
|
|
643
|
+
repeated -= chunk.bytesize - fresh.bytesize
|
|
644
|
+
pass_on(sink, fresh, answer) unless fresh.empty?
|
|
645
|
+
end
|
|
646
|
+
response = StreamedRead.read(deliver) do |on_data|
|
|
647
|
+
transport(op) do
|
|
648
|
+
connection(token.endpoint).get(route) do |req|
|
|
649
|
+
req.params["alt"] = "media"
|
|
650
|
+
req.headers["Authorization"] = "Bearer #{token.access_token}"
|
|
651
|
+
if from.positive?
|
|
652
|
+
req.headers["Range"] = "bytes=#{from}-"
|
|
653
|
+
req.params["ifGenerationMatch"] = generation if generation
|
|
654
|
+
end
|
|
655
|
+
req.options.on_data = on_data
|
|
656
|
+
end
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
raise_storage_error!(response, op: op, path: path) unless response.success?
|
|
660
|
+
# Net::HTTP ends a body that stops short of its length like a complete
|
|
661
|
+
# one, so a connection dropped mid-file is caught here.
|
|
662
|
+
declared = declared_bytes(response)
|
|
663
|
+
if declared && delivered < declared
|
|
664
|
+
raise PineSandbox::ConnectionError,
|
|
665
|
+
PineSandbox.with_context("storage connection ended after #{delivered} of #{declared} bytes", op: op)
|
|
666
|
+
end
|
|
667
|
+
response
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# How many bytes at the start of a media answer to a read from byte
|
|
671
|
+
# `from` were passed on before: `from` when storage sends the whole file,
|
|
672
|
+
# none when it sends the range asked for. A range from anywhere else is
|
|
673
|
+
# PineSandbox::Error. `answer` is the Faraday env or response.
|
|
674
|
+
def repeated_bytes(answer, from, op)
|
|
675
|
+
return 0 unless from.positive?
|
|
676
|
+
return from unless answer.status == 206
|
|
677
|
+
|
|
678
|
+
range = answer_headers(answer)["content-range"].to_s
|
|
679
|
+
start = range[/\Abytes (\d+)-/, 1]
|
|
680
|
+
return 0 if start && Integer(start, 10) == from
|
|
681
|
+
|
|
682
|
+
raise PineSandbox::Error,
|
|
683
|
+
PineSandbox.with_context("storage resumed the artifact at the wrong place (Content-Range #{range.inspect})", op: op)
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# The bytes a media answer says it carries, when it says so plainly (not
|
|
687
|
+
# for a compressed one, whose length is of the compressed bytes).
|
|
688
|
+
def declared_bytes(response)
|
|
689
|
+
encoding = response.headers["content-encoding"].to_s
|
|
690
|
+
return unless encoding.empty? || encoding.casecmp?("identity")
|
|
691
|
+
|
|
692
|
+
range = response.headers["content-range"].to_s.match(%r{\Abytes (\d+)-(\d+)/})
|
|
693
|
+
return Integer(range[2], 10) - Integer(range[1], 10) + 1 if response.status == 206 && range
|
|
694
|
+
|
|
695
|
+
length = response.headers["content-length"].to_s
|
|
696
|
+
Integer(length, 10) if length.match?(/\A\d+\z/)
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Passes a chunk, with its answer's headers, to the block.
|
|
700
|
+
def pass_on(sink, chunk, answer) = StreamedRead.pass(sink, chunk, answer_headers(answer))
|
|
701
|
+
|
|
702
|
+
# The headers of `answer`, a Faraday env or response.
|
|
703
|
+
def answer_headers(answer) = answer.respond_to?(:response_headers) ? answer.response_headers : answer.headers
|
|
704
|
+
|
|
705
|
+
# Maps a transport fault to the SDK's typed errors. `op` carries no query,
|
|
706
|
+
# so an upload session's id never reaches a message.
|
|
707
|
+
def transport(op)
|
|
708
|
+
yield
|
|
709
|
+
rescue Faraday::TimeoutError => e
|
|
710
|
+
raise PineSandbox::TimeoutError, PineSandbox.with_context("storage request timed out: #{e.message}", op: op)
|
|
711
|
+
rescue Faraday::ConnectionFailed, Faraday::SSLError => e
|
|
712
|
+
raise PineSandbox::ConnectionError, PineSandbox.with_context("storage connection failed: #{e.message}", op: op)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
# Raises the error for a storage answer that isn't a success, of the
|
|
716
|
+
# operation `op`. For a call on one file (`path`), a 404 is
|
|
717
|
+
# ArtifactNotFoundError; anything else is the status tier's ApiError with
|
|
718
|
+
# storage's own message. Its #retryable? is the storage rule's.
|
|
719
|
+
def raise_storage_error!(response, op:, path: nil)
|
|
720
|
+
body = parse_error_answer(response.body)
|
|
721
|
+
detail = body["error"].is_a?(Hash) ? body["error"]["message"].to_s : ""
|
|
722
|
+
fields = { status: response.status, response_body: response.body.to_s,
|
|
723
|
+
problem_extensions: { "op" => op, "retryable" => retry_status?(response.status) } }
|
|
724
|
+
error =
|
|
725
|
+
if response.status == 404 && path
|
|
726
|
+
ArtifactNotFoundError.new(path: path, error_message: "no artifact at #{path}", **fields)
|
|
727
|
+
else
|
|
728
|
+
message = detail.empty? ? "storage request failed (HTTP #{response.status})" : detail
|
|
729
|
+
status_tier_class_for(response.status).new(error_message: message, **fields)
|
|
730
|
+
end
|
|
731
|
+
seconds = Integer(response.headers["retry-after"].to_s.strip, 10, exception: false)
|
|
732
|
+
error.retry_after = seconds if seconds&.positive?
|
|
733
|
+
raise error
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# A success answer's JSON object. Any other body is an answer the storage
|
|
737
|
+
# protocol doesn't allow.
|
|
738
|
+
def parse_answer(response, op:)
|
|
739
|
+
parsed = JSON.parse(response.body.to_s)
|
|
740
|
+
return parsed if parsed.is_a?(Hash)
|
|
741
|
+
|
|
742
|
+
raise PineSandbox::Error, PineSandbox.with_context("storage answered with JSON that isn't an object", op: op)
|
|
743
|
+
rescue JSON::ParserError
|
|
744
|
+
raise PineSandbox::Error, PineSandbox.with_context("storage answered with a body that isn't JSON", op: op)
|
|
745
|
+
end
|
|
746
|
+
|
|
747
|
+
# An error answer's JSON object, or {} when its body is none: the error
|
|
748
|
+
# keeps the raw body either way.
|
|
749
|
+
def parse_error_answer(body)
|
|
750
|
+
parsed = JSON.parse(body.to_s)
|
|
751
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
752
|
+
rescue JSON::ParserError
|
|
753
|
+
{}
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# The JSON API route of the objects in the token's bucket.
|
|
757
|
+
def objects_route(token) = "storage/v1/b/#{escape(token.bucket)}/o"
|
|
758
|
+
|
|
759
|
+
# The JSON API route of the object `name`, and the same route with
|
|
760
|
+
# "{object}" for the name, for errors.
|
|
761
|
+
def object_route(token, name)
|
|
762
|
+
objects = objects_route(token)
|
|
763
|
+
["#{objects}/#{escape(name)}", "#{objects}/{object}"]
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
# Percent-encodes every byte but the unreserved ones, "/" and " " included.
|
|
767
|
+
def escape(segment)
|
|
768
|
+
segment.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |byte| format("%%%02X", byte.ord) }
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def origin(url)
|
|
772
|
+
uri = URI(url)
|
|
773
|
+
"#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
end
|