gcs_put 0.1.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 +7 -0
- data/.gitignore +9 -0
- data/.standard.yml +3 -0
- data/.yardopts +8 -0
- data/CHANGELOG.md +4 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/Rakefile +27 -0
- data/gcs_put.gemspec +41 -0
- data/lib/gcs_put/byte_chunker.rb +59 -0
- data/lib/gcs_put/resumable_upload.rb +227 -0
- data/lib/gcs_put/signer.rb +36 -0
- data/lib/gcs_put/transport/faraday.rb +56 -0
- data/lib/gcs_put/transport/net_http.rb +64 -0
- data/lib/gcs_put/transport.rb +38 -0
- data/lib/gcs_put/version.rb +6 -0
- data/lib/gcs_put.rb +62 -0
- data/rbi/gcs_put.rbi +399 -0
- data/sig/gcs_put.rbs +345 -0
- metadata +206 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9d4c77b40e96536400be6f6bcff74265d7e175252afc19a6bea7d59dea9bd880
|
|
4
|
+
data.tar.gz: e983657f638cad02d7657293e4dd051b5e16667c4b1e1d480da656a3b7700240
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3b48f9d1fba5c38dab1f428c28065765579fb639c57af52c285f4e6d314e67d392dfa0e4bb04ec7ac48d0428e34e310bd77b4289e3ae54edaac09dd689b584bc
|
|
7
|
+
data.tar.gz: 45577085211d0da00ab42da1416a88c90baf02273b8b01427dc2da42932d63586cf75ef75a6d841ea19c4faf55d80fa6d3f139498cc37f7186cee3a90fe78b06
|
data/.gitignore
ADDED
data/.standard.yml
ADDED
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Julik Tarkhanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# gcs_put
|
|
2
|
+
|
|
3
|
+
Streaming uploads to Google Cloud Storage when you do not know the size in advance.
|
|
4
|
+
|
|
5
|
+
The official `google-cloud-storage` gem can only upload things it can measure up front. It sends the total size when it opens the resumable session, and builds a `Content-Range` with that total on every chunk. Hand it a pipe or a socket and it has nothing to measure. GCS itself has supported resumable uploads of unknown size for years. This gem uses that: it gives you a writable object, chops what you write into 256 KiB-aligned chunks, and PUTs them into a resumable upload session as you go. Generating a zip, a CSV export or a transcode straight into a bucket works without a temp file.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "gcs_put"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "gcs_put"
|
|
17
|
+
|
|
18
|
+
storage = Google::Cloud::Storage.new
|
|
19
|
+
bucket = storage.bucket("my-bucket")
|
|
20
|
+
gcs_file = bucket.file("exports/report.csv.gz", skip_lookup: true) # does not need to exist
|
|
21
|
+
|
|
22
|
+
GCSPut.with_gcs_file(gcs_file, content_type: "application/gzip") do |io|
|
|
23
|
+
gz = Zlib::GzipWriter.new(io)
|
|
24
|
+
rows.each { |row| gz.write(row.to_csv) }
|
|
25
|
+
gz.finish
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The object yielded to the block responds to `write`, `<<` and `close`, so anything that writes to an IO can write to it, including `IO.copy_stream`, `Zlib::GzipWriter` and `ZipKit::Streamer`. The last chunk is sent when the block returns, or earlier if something closes the object, and the block form returns the total number of bytes uploaded. If the block raises, nothing is finalized and the session simply expires after a week.
|
|
30
|
+
|
|
31
|
+
The factories live on `GCSPut::ResumableUpload` and are aliased on `GCSPut` for brevity. Everything except the file is optional. The content type defaults to `binary/octet-stream`, HTTP goes through `Net::HTTP`, and chunks are 5 MB.
|
|
32
|
+
|
|
33
|
+
Without a block you get the upload back to drive by hand:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
upload = GCSPut.with_gcs_file(gcs_file)
|
|
37
|
+
upload.write(bytes)
|
|
38
|
+
upload.finish # => total bytes
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Chunk size
|
|
42
|
+
|
|
43
|
+
Every chunk except the last is held in memory and must be a multiple of 256 KiB. The default is 5 MB. Larger chunks mean fewer requests:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
GCSPut.with_gcs_file(gcs_file, chunk_size: 32 * 1024 * 1024)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Starting from a session URL
|
|
50
|
+
|
|
51
|
+
The session URL is just a string, and once you have it uploading needs no Google credentials at all. So a web process can sign and start the session while a worker does the upload:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
upload = GCSPut.with_gcs_file(gcs_file)
|
|
55
|
+
session_url = upload.session_url
|
|
56
|
+
|
|
57
|
+
# Elsewhere, no SDK needed
|
|
58
|
+
GCSPut.with_session_url(session_url) do |io|
|
|
59
|
+
io.write(bytes)
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
If you have a signed POST URL from somewhere else, one signed for `POST` with the `x-goog-resumable: start` header, start from that instead:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
GCSPut.with_signed_post_url(signed_post_url) do |io|
|
|
67
|
+
io.write(bytes)
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If the URL was signed, or the session started, with a content type other than the default, pass the same `content_type:`.
|
|
72
|
+
|
|
73
|
+
The chunker is also usable on its own, for anything that needs evenly sized pieces:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
chunker = GCSPut::ByteChunker.new(chunk_size: 1024) { |bytes, is_last| ... }
|
|
77
|
+
chunker << data
|
|
78
|
+
chunker.finish
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Transports
|
|
82
|
+
|
|
83
|
+
HTTP goes through a small transport object. The default uses `Net::HTTP` with one persistent connection and needs nothing installed. To use Faraday instead, pass a transport wrapping your own connection, so you get to pick the adapter, timeouts and instrumentation:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
conn = Faraday.new { |f| f.options.timeout = 120 }
|
|
87
|
+
transport = GCSPut::Transport::Faraday.new(conn)
|
|
88
|
+
|
|
89
|
+
GCSPut.with_gcs_file(gcs_file, transport: transport) { |io| ... }
|
|
90
|
+
GCSPut.with_session_url(session_url, transport: transport)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Without an argument the Faraday transport makes a default connection. The `raise_error` middleware is tolerated. Timeouts and connection errors for `Net::HTTP` can be set on its transport too:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
GCSPut::Transport::NetHTTP.new(open_timeout: 10, read_timeout: 120)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Anything responding to `put(uri, body, headers)`, `post(uri, body, headers)` and `close` works as a transport. The verbs must return something with `status`, `body` and a case-insensitive `[]` for headers, and raise `GCSPut::TransientError` for failures worth retrying. Every chunk carries a `Content-MD5`, so an adapter that mangles request bodies fails loudly at upload time rather than quietly at download time. [httpx 1.4.0 did exactly that](https://gitlab.com/os85/httpx/-/issues/338).
|
|
100
|
+
|
|
101
|
+
### Retries
|
|
102
|
+
|
|
103
|
+
A chunk which fails with a connection error or a 5xx is not resent from the start. The session is asked how many bytes it has, and only the remainder goes out again. The same happens when GCS answers a PUT with a `Range` header showing it took fewer bytes than were sent. A chunk is given up on after 5 attempts, configurable via `max_attempts:`. A 4xx fails immediately.
|
|
104
|
+
|
|
105
|
+
## Permissions
|
|
106
|
+
|
|
107
|
+
Starting a session needs a signed POST URL, and signing needs a private key.
|
|
108
|
+
|
|
109
|
+
**With a service account JSON key** (`GOOGLE_APPLICATION_CREDENTIALS` or `credentials:` on `Google::Cloud::Storage.new`) the key is local, the SDK signs with it, and the only permission needed is `storage.objects.create` on the bucket. Nothing else to do.
|
|
110
|
+
|
|
111
|
+
**Under workload identity** on GCE, GKE, Cloud Run or Cloud Functions there is no private key on the machine. The gem detects this and asks the IAM Credentials API to sign on the service account's behalf via `signBlob`. For that call to be allowed, the service account must hold the `roles/iam.serviceAccountTokenCreator` role **on itself**:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
gcloud iam service-accounts add-iam-policy-binding SA_EMAIL \
|
|
115
|
+
--member="serviceAccount:SA_EMAIL" \
|
|
116
|
+
--role="roles/iam.serviceAccountTokenCreator"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Without it, `with_gcs_file` fails with a permission error from the IAM API, not from Cloud Storage, which is confusing the first time. The background is in [google-cloud-ruby#13307](https://github.com/googleapis/google-cloud-ruby/issues/13307). The IAM Credentials API must also be enabled on the project.
|
|
120
|
+
|
|
121
|
+
Extra options such as `expires:`, or an `issuer:` and `signer:` of your own, go in `signed_url_options:` and are passed through to `gcs_file.signed_url`.
|
|
122
|
+
|
|
123
|
+
## Running the tests
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bundle exec rake
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Unit tests stub HTTP and need nothing. The live tests upload to a real bucket called `gcs_put_test_bucket`, or whatever `GCS_PUT_TEST_BUCKET` names, using the credentials and project the SDK finds on its own: `GOOGLE_APPLICATION_CREDENTIALS` and `GOOGLE_CLOUD_PROJECT`, or `gcloud auth application-default login`. Without a usable configuration or bucket they skip. Objects they create are deleted afterwards.
|
|
130
|
+
|
|
131
|
+
## Resources
|
|
132
|
+
|
|
133
|
+
- [Performing resumable uploads](https://cloud.google.com/storage/docs/performing-resumable-uploads)
|
|
134
|
+
- [Signed URLs with resumable uploads](https://cloud.google.com/storage/docs/access-control/signed-urls#signing-resumable)
|
|
135
|
+
- Google's own [stream_upload.rb gist](https://gist.github.com/frankyn/9a5344d1b19ed50ebbf9f15f0ff92032), which this gem started from
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
data/Rakefile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
require "standard/rake"
|
|
6
|
+
|
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
|
8
|
+
t.libs << "test"
|
|
9
|
+
t.libs << "lib"
|
|
10
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
task :format do
|
|
14
|
+
`bundle exec standardrb --fix`
|
|
15
|
+
`bundle exec magic_frozen_string_literal .`
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
task :generate_typedefs do
|
|
19
|
+
# Types from other gems (URI, Faraday, Google::Cloud::Storage) are not resolvable for sord, hence the untyped fallback
|
|
20
|
+
`bundle exec sord --replace-errors-with-untyped rbi/gcs_put.rbi`
|
|
21
|
+
`bundle exec sord --replace-errors-with-untyped sig/gcs_put.rbs`
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# When building the gem, generate typedefs beforehand so that they get included
|
|
25
|
+
Rake::Task["build"].enhance(["generate_typedefs"])
|
|
26
|
+
|
|
27
|
+
task default: [:test, :standard, :generate_typedefs]
|
data/gcs_put.gemspec
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/gcs_put/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "gcs_put"
|
|
7
|
+
spec.version = GCSPut::VERSION
|
|
8
|
+
spec.authors = ["Julik Tarkhanov"]
|
|
9
|
+
spec.email = ["me@julik.nl"]
|
|
10
|
+
spec.license = "MIT"
|
|
11
|
+
spec.summary = "Streaming resumable uploads to Google Cloud Storage without knowing the size in advance."
|
|
12
|
+
spec.description = "Gives you a writable IO-like object which streams what you write into it to a GCS object using " \
|
|
13
|
+
"the resumable upload protocol over a signed URL. The size of the upload does not need to be known beforehand."
|
|
14
|
+
|
|
15
|
+
spec.homepage = "https://github.com/julik/gcs_put"
|
|
16
|
+
# The homepage link on rubygems.org only appears if you add homepage_uri. Just spec.homepage is not enough.
|
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
|
|
21
|
+
spec.required_ruby_version = ">= 3.2.0" # google-cloud-storage wants 3.2
|
|
22
|
+
|
|
23
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
24
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |path|
|
|
25
|
+
path.start_with?("tmp/", "test/", ".github/") || File.basename(path) == "Gemfile.lock"
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ["lib"]
|
|
28
|
+
|
|
29
|
+
spec.add_dependency "google-cloud-storage", "~> 1.40"
|
|
30
|
+
|
|
31
|
+
spec.add_development_dependency "minitest"
|
|
32
|
+
spec.add_development_dependency "rake"
|
|
33
|
+
spec.add_development_dependency "webmock"
|
|
34
|
+
spec.add_development_dependency "faraday"
|
|
35
|
+
spec.add_development_dependency "magic_frozen_string_literal"
|
|
36
|
+
spec.add_development_dependency "standard", ">= 1.35.1"
|
|
37
|
+
spec.add_development_dependency "yard", "~> 0.9"
|
|
38
|
+
spec.add_development_dependency "sord"
|
|
39
|
+
# redcarpet is needed for yard to render Github Flavored Markdown
|
|
40
|
+
spec.add_development_dependency "redcarpet"
|
|
41
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Chops an arbitrary stream of writes into evenly sized chunks. Every chunk except the last
|
|
4
|
+
# will be exactly `chunk_size` bytes, and the last one can be anything from 0 bytes up to and
|
|
5
|
+
# including `chunk_size`. A chunk which fills up exactly is held back until `finish` so that
|
|
6
|
+
# a stream ending on a chunk boundary still delivers that chunk flagged as the last one.
|
|
7
|
+
#
|
|
8
|
+
# chunker = ByteChunker.new(chunk_size: 3) { |bytes, is_last| puts [bytes, is_last].inspect }
|
|
9
|
+
# chunker << "ab" << "cdefg" # => ["abc", false], ["def", false]
|
|
10
|
+
# chunker.finish # => ["g", true]
|
|
11
|
+
class GCSPut::ByteChunker
|
|
12
|
+
# @param chunk_size[Integer] the size that every chunk except the last must have
|
|
13
|
+
# @yield [bytes, is_last] a binary String and whether this is the final chunk
|
|
14
|
+
def initialize(chunk_size:, &delivery_proc)
|
|
15
|
+
raise ArgumentError, "chunk_size must be positive" unless chunk_size.to_i > 0
|
|
16
|
+
@chunk_size = chunk_size.to_i
|
|
17
|
+
# A mutable String with preallocated capacity beats a StringIO here - the buffer
|
|
18
|
+
# gets reused for the whole life of the chunker and never reallocates
|
|
19
|
+
@buf = String.new(encoding: Encoding::BINARY, capacity: @chunk_size * 2)
|
|
20
|
+
@delivery_proc = delivery_proc.to_proc
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param bin_str[String] the bytes to append
|
|
24
|
+
# @return [self]
|
|
25
|
+
def <<(bin_str)
|
|
26
|
+
@buf << bin_str.b
|
|
27
|
+
deliver_full_chunks
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param bin_str[String] the bytes to append
|
|
32
|
+
# @return [Integer] the number of bytes appended, like `IO#write`
|
|
33
|
+
def write(bin_str)
|
|
34
|
+
self << bin_str
|
|
35
|
+
bin_str.bytesize
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Delivers whatever is left in the buffer as the last chunk. The last chunk
|
|
39
|
+
# gets delivered even when empty - it is what closes the upload
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
def finish
|
|
43
|
+
deliver_full_chunks
|
|
44
|
+
# Hand out a copy - the receiver may hold on to it, and we reuse the buffer
|
|
45
|
+
@delivery_proc.call(@buf.byteslice(0, @buf.bytesize), _is_last = true)
|
|
46
|
+
@buf.clear
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# @return [void]
|
|
53
|
+
def deliver_full_chunks
|
|
54
|
+
while @buf.bytesize > @chunk_size
|
|
55
|
+
@delivery_proc.call(@buf.byteslice(0, @chunk_size), _is_last = false)
|
|
56
|
+
@buf.replace(@buf.byteslice(@chunk_size..))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A writable object which streams what you write into a GCS resumable upload session using
|
|
4
|
+
# ranged PUTs, see https://cloud.google.com/storage/docs/performing-resumable-uploads#chunked-upload
|
|
5
|
+
# You do not need to know the size of the output in advance. You do need to `finish` the upload,
|
|
6
|
+
# since it is the last PUT (with the total size filled in) which closes the GCS object.
|
|
7
|
+
#
|
|
8
|
+
# gcs_file = bucket.file("upload.bin", skip_lookup: true)
|
|
9
|
+
# GCSPut.with_gcs_file(gcs_file) do |io|
|
|
10
|
+
# io.write("Hello resumable")
|
|
11
|
+
# 20.times { io.write(Random.bytes(1024 * 1024)) }
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Every chunk is retried from the byte offset GCS reports as persisted, so a chunk which
|
|
15
|
+
# only partially made it over the wire gets topped up rather than resent from the start.
|
|
16
|
+
#
|
|
17
|
+
# Starting the session needs a signed POST URL, and signing needs a private key. Under
|
|
18
|
+
# workload identity there is no key, so we fall back to the IAM signBlob API - which means
|
|
19
|
+
# the service account must hold `roles/iam.serviceAccountTokenCreator` on itself. See
|
|
20
|
+
# https://github.com/googleapis/google-cloud-ruby/issues/13307
|
|
21
|
+
class GCSPut::ResumableUpload
|
|
22
|
+
extend Forwardable
|
|
23
|
+
|
|
24
|
+
# @!method write(bin_str)
|
|
25
|
+
# Appends bytes to the upload, sending out a chunk whenever one fills up
|
|
26
|
+
# @param bin_str[String]
|
|
27
|
+
# @return [Integer] the number of bytes appended, like `IO#write`
|
|
28
|
+
# @!method <<(bin_str)
|
|
29
|
+
# Appends bytes to the upload, sending out a chunk whenever one fills up
|
|
30
|
+
# @param bin_str[String]
|
|
31
|
+
# @return [self]
|
|
32
|
+
def_delegators :@chunker, :write, :<<
|
|
33
|
+
|
|
34
|
+
# @return [String] the session URL, valid for a week and usable from any process
|
|
35
|
+
attr_reader :session_url
|
|
36
|
+
|
|
37
|
+
# @return [Integer] the number of bytes GCS has confirmed as persisted so far
|
|
38
|
+
attr_reader :bytes_persisted
|
|
39
|
+
|
|
40
|
+
# Signs a session start URL for the object, starts the session and returns the upload.
|
|
41
|
+
# With a block, yields the upload, finishes it once the block returns and returns the total size.
|
|
42
|
+
#
|
|
43
|
+
# @param gcs_file[Google::Cloud::Storage::File] the object to upload into, does not need to exist yet
|
|
44
|
+
# @param content_type[String] the content type of the resulting object
|
|
45
|
+
# @param transport[#put, #post, #close] see `GCSPut::Transport`
|
|
46
|
+
# @param signed_url_options[Hash] passed to `gcs_file.signed_url`, see `Signer.url_issuer_and_signer`
|
|
47
|
+
# @param options[Hash] see {#initialize}
|
|
48
|
+
# @yield [GCSPut::ResumableUpload] the upload to write into
|
|
49
|
+
# @return [GCSPut::ResumableUpload, Integer] the upload, or the total size when given a block
|
|
50
|
+
def self.with_gcs_file(gcs_file, content_type: "binary/octet-stream", transport: GCSPut::Transport::NetHTTP.new, signed_url_options: {}, **options, &blk)
|
|
51
|
+
signed_url_options = GCSPut::Signer.url_issuer_and_signer.merge(signed_url_options)
|
|
52
|
+
signed_post_url = gcs_file.signed_url(method: "POST", content_type: content_type, headers: {"x-goog-resumable" => "start"}, **signed_url_options)
|
|
53
|
+
with_signed_post_url(signed_post_url, content_type: content_type, transport: transport, **options, &blk)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Starts a session from a signed POST URL (one with `x-goog-resumable: start` among its signed headers)
|
|
57
|
+
# and returns the upload, see https://cloud.google.com/storage/docs/performing-resumable-uploads#initiate-session
|
|
58
|
+
# With a block, yields the upload, finishes it once the block returns and returns the total size.
|
|
59
|
+
#
|
|
60
|
+
# @param signed_post_url[String]
|
|
61
|
+
# @param content_type[String] must match the content type the URL was signed with
|
|
62
|
+
# @param transport[#put, #post, #close] see `GCSPut::Transport`
|
|
63
|
+
# @param options[Hash] see {#initialize}
|
|
64
|
+
# @yield [GCSPut::ResumableUpload] the upload to write into
|
|
65
|
+
# @return [GCSPut::ResumableUpload, Integer] the upload, or the total size when given a block
|
|
66
|
+
def self.with_signed_post_url(signed_post_url, content_type: "binary/octet-stream", transport: GCSPut::Transport::NetHTTP.new, **options, &blk)
|
|
67
|
+
response = transport.post(URI(signed_post_url), "", {"Content-Type" => content_type, "x-goog-resumable" => "start"})
|
|
68
|
+
unless response.status == 201
|
|
69
|
+
raise GCSPut::UploadFailed.new("Session start responded with HTTP #{response.status}: #{response.body}", response: response)
|
|
70
|
+
end
|
|
71
|
+
session_url = response["Location"] or raise GCSPut::UploadFailed.new("Session start did not return a Location header", response: response)
|
|
72
|
+
with_session_url(session_url, content_type: content_type, transport: transport, **options, &blk)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Wraps an already started session. With a block, yields the upload, finishes it once
|
|
76
|
+
# the block returns and returns the total size.
|
|
77
|
+
#
|
|
78
|
+
# @param session_url[String] the `Location` returned by the session start
|
|
79
|
+
# @param options[Hash] see {#initialize}
|
|
80
|
+
# @yield [GCSPut::ResumableUpload] the upload to write into
|
|
81
|
+
# @return [GCSPut::ResumableUpload, Integer] the upload, or the total size when given a block
|
|
82
|
+
def self.with_session_url(session_url, **options)
|
|
83
|
+
upload = new(session_url, **options)
|
|
84
|
+
return upload unless block_given?
|
|
85
|
+
yield(upload)
|
|
86
|
+
upload.finish
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Prefer the `with_*` factories. This does no HTTP by itself, the first request goes out
|
|
90
|
+
# once a chunk fills up or `finish` gets called
|
|
91
|
+
#
|
|
92
|
+
# @param session_url[String] the `Location` returned by the session start
|
|
93
|
+
# @param chunk_size[Integer] must be a multiple of 256 KiB
|
|
94
|
+
# @param content_type[String] must match the content type the session was started with
|
|
95
|
+
# @param max_attempts[Integer] how many times a single chunk may be sent before giving up
|
|
96
|
+
# @param transport[#put, #post, #close] see `GCSPut::Transport`
|
|
97
|
+
def initialize(session_url, chunk_size: GCSPut::DEFAULT_CHUNK_SIZE, content_type: "binary/octet-stream", max_attempts: 5, transport: GCSPut::Transport::NetHTTP.new)
|
|
98
|
+
unless (chunk_size % GCSPut::CHUNK_SIZE_UNIT).zero?
|
|
99
|
+
raise ArgumentError, "chunk_size of #{chunk_size} is not a multiple of #{GCSPut::CHUNK_SIZE_UNIT}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@session_url = session_url
|
|
103
|
+
@session_uri = URI(session_url)
|
|
104
|
+
@content_type = content_type
|
|
105
|
+
@max_attempts = max_attempts
|
|
106
|
+
@transport = transport
|
|
107
|
+
@bytes_persisted = 0
|
|
108
|
+
@finished = false
|
|
109
|
+
@chunker = GCSPut::ByteChunker.new(chunk_size: chunk_size) { |bytes, is_last| upload_chunk(bytes, is_last) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Sends the remaining buffered bytes as the final chunk and closes the GCS object.
|
|
113
|
+
# Also available as `close` so that writers which close their underlying IO, like
|
|
114
|
+
# `Zlib::GzipWriter`, finish the upload for you
|
|
115
|
+
#
|
|
116
|
+
# @return [Integer] the total number of bytes uploaded
|
|
117
|
+
def finish
|
|
118
|
+
return @bytes_persisted if @finished
|
|
119
|
+
@chunker.finish
|
|
120
|
+
@finished = true
|
|
121
|
+
@bytes_persisted
|
|
122
|
+
ensure
|
|
123
|
+
@transport.close
|
|
124
|
+
end
|
|
125
|
+
alias_method :close, :finish
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# @param chunk[String]
|
|
130
|
+
# @param is_last[Boolean]
|
|
131
|
+
# @return [void]
|
|
132
|
+
def upload_chunk(chunk, is_last)
|
|
133
|
+
chunk_start = @bytes_persisted
|
|
134
|
+
chunk_end = chunk_start + chunk.bytesize
|
|
135
|
+
total = is_last ? chunk_end : "*"
|
|
136
|
+
attempts = 0
|
|
137
|
+
|
|
138
|
+
loop do
|
|
139
|
+
attempts += 1
|
|
140
|
+
if @bytes_persisted < chunk_start
|
|
141
|
+
raise GCSPut::UploadFailed, "GCS reports #{@bytes_persisted} bytes persisted but we already discarded everything before #{chunk_start}"
|
|
142
|
+
end
|
|
143
|
+
body = chunk.byteslice(@bytes_persisted - chunk_start, chunk.bytesize)
|
|
144
|
+
|
|
145
|
+
begin
|
|
146
|
+
response = put_bytes(body, from: @bytes_persisted, total: total)
|
|
147
|
+
failure = "HTTP #{response.status}"
|
|
148
|
+
rescue GCSPut::TransientError => e
|
|
149
|
+
response = nil
|
|
150
|
+
failure = e.message
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
case response&.status.to_i
|
|
154
|
+
when 200, 201
|
|
155
|
+
@bytes_persisted = chunk_end
|
|
156
|
+
return
|
|
157
|
+
when 308
|
|
158
|
+
@bytes_persisted = persisted_offset_from(response)
|
|
159
|
+
return if @bytes_persisted == chunk_end && !is_last
|
|
160
|
+
# GCS took fewer bytes than we sent, or wants the sized finalizing PUT - send the rest
|
|
161
|
+
when 500..599, 408, 429, 0
|
|
162
|
+
if sync_with_session_finds_it_finalized?
|
|
163
|
+
@bytes_persisted = chunk_end
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
else
|
|
167
|
+
raise GCSPut::UploadFailed.new("Chunk PUT responded with HTTP #{response.status}: #{response.body}", response: response)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if attempts >= @max_attempts
|
|
171
|
+
raise GCSPut::UploadFailed, "Gave up on chunk at offset #{chunk_start} after #{attempts} attempts, last failure: #{failure}"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# @param body[String]
|
|
177
|
+
# @param from[Integer]
|
|
178
|
+
# @param total[Integer, String] the total size, or "*" while still unknown
|
|
179
|
+
# @return [GCSPut::Transport::Response]
|
|
180
|
+
def put_bytes(body, from:, total:)
|
|
181
|
+
content_range = if body.empty?
|
|
182
|
+
"bytes */#{total}"
|
|
183
|
+
else
|
|
184
|
+
"bytes #{from}-#{from + body.bytesize - 1}/#{total}"
|
|
185
|
+
end
|
|
186
|
+
headers = {
|
|
187
|
+
"Content-Length" => body.bytesize.to_s,
|
|
188
|
+
"Content-Range" => content_range,
|
|
189
|
+
"Content-Type" => @content_type,
|
|
190
|
+
# Lets GCS reject a chunk mangled in transit instead of us discovering it at download time
|
|
191
|
+
"Content-MD5" => Digest::MD5.base64digest(body)
|
|
192
|
+
}
|
|
193
|
+
@transport.put(@session_uri, body, headers)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Asks GCS how much of the upload it has and updates `bytes_persisted` accordingly, see
|
|
197
|
+
# https://cloud.google.com/storage/docs/performing-resumable-uploads#status-check
|
|
198
|
+
# A status check which fails itself just leaves us with what we already knew
|
|
199
|
+
#
|
|
200
|
+
# @return [Boolean] whether the session turned out to be finalized already
|
|
201
|
+
def sync_with_session_finds_it_finalized?
|
|
202
|
+
response = @transport.put(@session_uri, "", {"Content-Length" => "0", "Content-Range" => "bytes */*", "Content-Type" => @content_type})
|
|
203
|
+
case response.status
|
|
204
|
+
when 308
|
|
205
|
+
@bytes_persisted = persisted_offset_from(response)
|
|
206
|
+
false
|
|
207
|
+
when 200, 201
|
|
208
|
+
true
|
|
209
|
+
when 500..599, 408, 429
|
|
210
|
+
false
|
|
211
|
+
else
|
|
212
|
+
raise GCSPut::UploadFailed.new("Session status check responded with HTTP #{response.status}: #{response.body}", response: response)
|
|
213
|
+
end
|
|
214
|
+
rescue GCSPut::TransientError
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The `Range` header is "bytes=0-N" with N being the last persisted byte, and absent if nothing persisted yet
|
|
219
|
+
#
|
|
220
|
+
# @param response[GCSPut::Transport::Response]
|
|
221
|
+
# @return [Integer]
|
|
222
|
+
def persisted_offset_from(response)
|
|
223
|
+
range = response["Range"]
|
|
224
|
+
return 0 unless range
|
|
225
|
+
range[/\d+\z/].to_i + 1
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Supplies the `issuer` and `signer` that `Google::Cloud::Storage::File#signed_url` needs
|
|
4
|
+
# when there is no private key around to sign with
|
|
5
|
+
module GCSPut::Signer
|
|
6
|
+
# When running on GCE, GKE, Cloud Run etc. under a service account there is no private key
|
|
7
|
+
# on the box to sign the URL with. The SDK then needs an `issuer` (the account email) and a
|
|
8
|
+
# `signer` lambda which asks the IAM credentials API to sign for us. For that to be allowed the
|
|
9
|
+
# service account must have `roles/iam.serviceAccountTokenCreator` on itself.
|
|
10
|
+
# Lifted from https://github.com/googleapis/google-cloud-ruby/issues/13307#issuecomment-1894546343
|
|
11
|
+
#
|
|
12
|
+
# @return [Hash] either `{issuer:, signer:}` or an empty hash when not on compute engine
|
|
13
|
+
def self.url_issuer_and_signer
|
|
14
|
+
require "google/cloud/env"
|
|
15
|
+
env = Google::Cloud.env
|
|
16
|
+
return {} unless env.compute_engine?
|
|
17
|
+
|
|
18
|
+
issuer = env.lookup_metadata("instance", "service-accounts/default/email")
|
|
19
|
+
{issuer: issuer, signer: iam_signer_for(issuer)}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param service_account_email[String]
|
|
23
|
+
# @return [Proc] a lambda which takes the string to sign and returns the signature
|
|
24
|
+
def self.iam_signer_for(service_account_email)
|
|
25
|
+
lambda do |string_to_sign|
|
|
26
|
+
require "google/apis/iamcredentials_v1"
|
|
27
|
+
require "googleauth"
|
|
28
|
+
|
|
29
|
+
iam_client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new
|
|
30
|
+
iam_client.authorization = Google::Auth.get_application_default(["https://www.googleapis.com/auth/iam"])
|
|
31
|
+
request = Google::Apis::IamcredentialsV1::SignBlobRequest.new(payload: string_to_sign)
|
|
32
|
+
response = iam_client.sign_service_account_blob("projects/-/serviceAccounts/#{service_account_email}", request)
|
|
33
|
+
response.signed_blob
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
5
|
+
# Uses a Faraday connection, so you can pick the adapter, timeouts and instrumentation yourself.
|
|
6
|
+
# Check the bytes arrive intact with whatever adapter you choose - we send a `Content-MD5` with
|
|
7
|
+
# every chunk precisely because httpx 1.4.0 used to mangle request bodies,
|
|
8
|
+
# see https://gitlab.com/os85/httpx/-/issues/338
|
|
9
|
+
class GCSPut::Transport::Faraday
|
|
10
|
+
# What Faraday raises when the connection, rather than the request, is at fault
|
|
11
|
+
TRANSIENT_ERRORS = [::Faraday::ConnectionFailed, ::Faraday::TimeoutError, ::Faraday::SSLError].freeze
|
|
12
|
+
|
|
13
|
+
# @param connection[Faraday::Connection, nil] a connection of your own, or `nil` to get a default one
|
|
14
|
+
def initialize(connection = nil)
|
|
15
|
+
@owns_connection = connection.nil?
|
|
16
|
+
@connection = connection || ::Faraday.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param uri[URI::Generic]
|
|
20
|
+
# @param body[String]
|
|
21
|
+
# @param headers[Hash{String => String}]
|
|
22
|
+
# @return [GCSPut::Transport::Response]
|
|
23
|
+
# @raise [GCSPut::TransientError] on connection errors and timeouts
|
|
24
|
+
def put(uri, body, headers)
|
|
25
|
+
request(:put, uri, body, headers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param uri[URI::Generic]
|
|
29
|
+
# @param body[String]
|
|
30
|
+
# @param headers[Hash{String => String}]
|
|
31
|
+
# @return [GCSPut::Transport::Response]
|
|
32
|
+
# @raise [GCSPut::TransientError] on connection errors and timeouts
|
|
33
|
+
def post(uri, body, headers)
|
|
34
|
+
request(:post, uri, body, headers)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A connection you passed in is yours to close, we only close the one we made
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def close
|
|
41
|
+
@connection.close if @owns_connection && @connection.respond_to?(:close)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def request(verb, uri, body, headers)
|
|
47
|
+
response = @connection.run_request(verb, uri.to_s, body, headers)
|
|
48
|
+
GCSPut::Transport::Response.new(response.status, response.headers, response.body)
|
|
49
|
+
rescue *TRANSIENT_ERRORS => e
|
|
50
|
+
raise GCSPut::TransientError, "#{e.class}: #{e.message}"
|
|
51
|
+
rescue ::Faraday::Error => e
|
|
52
|
+
# The `raise_error` middleware turns 4xx and 5xx into exceptions, we want the response back
|
|
53
|
+
raise unless e.response
|
|
54
|
+
GCSPut::Transport::Response.new(e.response_status, e.response_headers, e.response_body)
|
|
55
|
+
end
|
|
56
|
+
end
|