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 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
@@ -0,0 +1,9 @@
1
+ # Reference code from another project, must not ship
2
+ /tmp/
3
+ /pkg/
4
+ /vendor/bundle/
5
+ /.bundle/
6
+ /Gemfile.lock
7
+ .DS_Store
8
+ /.yardoc/
9
+ /doc/
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ ruby_version: 3.2
2
+ ignore:
3
+ - 'tmp/**/*'
data/.yardopts ADDED
@@ -0,0 +1,8 @@
1
+ --markup markdown
2
+ --markup-provider redcarpet
3
+ --no-private
4
+ lib/**/*.rb
5
+ -
6
+ README.md
7
+ CHANGELOG.md
8
+ LICENSE
data/CHANGELOG.md ADDED
@@ -0,0 +1,4 @@
1
+ ## 0.1.0
2
+
3
+ - Initial release: `ByteChunker`, `ResumableUpload` with `with_gcs_file`, `with_signed_post_url` and `with_session_url`, also aliased on `GCSPut`, and the IAM-backed signer for workload identity
4
+ - Pluggable transports, with `Net::HTTP` and Faraday included
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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