azure-blob 0.4.1 → 0.5.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.
@@ -1,21 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "signer"
4
3
  require_relative "block_list"
5
4
  require_relative "blob_list"
6
5
  require_relative "blob"
7
6
  require_relative "http"
7
+ require_relative "shared_key_signer"
8
+ require_relative "entra_id_signer"
8
9
  require "time"
9
10
  require "base64"
10
11
 
11
12
  module AzureBlob
13
+ # AzureBlob Client class. You interact with the Azure Blob api
14
+ # through an instance of this class.
12
15
  class Client
13
- def initialize(account_name:, access_key:, container:)
16
+ def initialize(account_name:, access_key:, container:, **options)
14
17
  @account_name = account_name
15
18
  @container = container
16
- @signer = Signer.new(account_name:, access_key:)
19
+
20
+ @signer = !access_key.nil? && !access_key.empty? ?
21
+ AzureBlob::SharedKeySigner.new(account_name:, access_key:) :
22
+ AzureBlob::EntraIdSigner.new(account_name:, **options.slice(:principal_id))
17
23
  end
18
24
 
25
+ # Create a blob of type block. Will automatically split the the blob in multiple block and send the blob in pieces (blocks) if the blob is too big.
26
+ #
27
+ # When the blob is small enough this method will send the blob through {Put Blob}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-blob]
28
+ #
29
+ # If the blob is too big, the blob is split in blocks sent through a series of {Put Block}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block] requests
30
+ # followed by a {Put Block List}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list] to commit the block list.
31
+ #
32
+ # Takes a key (path), the content (String or IO object), and options.
33
+ #
34
+ # Options:
35
+ #
36
+ # [+:content_type+]
37
+ # Will be saved on the blob in Azure.
38
+ # [+:content_disposition+]
39
+ # Will be saved on the blob in Azure.
40
+ # [+:content_md5+]
41
+ # Will ensure integrity of the upload. The checksum must be a base64 digest. Can be produced with +OpenSSL::Digest::MD5.base64digest+.
42
+ # The checksum is only checked on a single upload! To verify checksum when uploading multiple blocks, call directly put_blob_block with
43
+ # a checksum for each block, then commit the blocks with commit_blob_blocks.
44
+ # [+:block_size+]
45
+ # Block size in bytes, can be used to force the method to split the upload in smaller chunk. Defaults to +AzureBlob::DEFAULT_BLOCK_SIZE+ and cannot be bigger than +AzureBlob::MAX_UPLOAD_SIZE+
19
46
  def create_block_blob(key, content, options = {})
20
47
  if content.size > (options[:block_size] || DEFAULT_BLOCK_SIZE)
21
48
  put_blob_multiple(key, content, **options)
@@ -24,6 +51,18 @@ module AzureBlob
24
51
  end
25
52
  end
26
53
 
54
+ # Returns the full or partial content of the blob
55
+ #
56
+ # Calls to the {Get Blob}[https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob] endpoint.
57
+ #
58
+ # Takes a key (path) and options.
59
+ #
60
+ # Options:
61
+ #
62
+ # [+:start+]
63
+ # Starting point in bytes
64
+ # [+:end+]
65
+ # Ending point in bytes
27
66
  def get_blob(key, options = {})
28
67
  uri = generate_uri("#{container}/#{key}")
29
68
 
@@ -34,6 +73,15 @@ module AzureBlob
34
73
  Http.new(uri, headers, signer:).get
35
74
  end
36
75
 
76
+ # Delete a blob
77
+ #
78
+ # Calls to {Delete Blob}[https://learn.microsoft.com/en-us/rest/api/storageservices/delete-blob]
79
+ #
80
+ # Takes a key (path) and options.
81
+ #
82
+ # Options:
83
+ # [+:delete_snapshots+]
84
+ # Sets the value of the x-ms-delete-snapshots header. Default to +include+
37
85
  def delete_blob(key, options = {})
38
86
  uri = generate_uri("#{container}/#{key}")
39
87
 
@@ -44,11 +92,28 @@ module AzureBlob
44
92
  Http.new(uri, headers, signer:).delete
45
93
  end
46
94
 
95
+ # Delete all blobs prefixed by the given prefix.
96
+ #
97
+ # Calls to {List blobs}[https://learn.microsoft.com/en-us/rest/api/storageservices/list-blobs]
98
+ # followed to a series of calls to {Delete Blob}[https://learn.microsoft.com/en-us/rest/api/storageservices/delete-blob]
99
+ #
100
+ # Takes a prefix and options
101
+ #
102
+ # Look delete_blob for the list of options.
47
103
  def delete_prefix(prefix, options = {})
48
104
  results = list_blobs(prefix:)
49
105
  results.each { |key| delete_blob(key) }
50
106
  end
51
107
 
108
+ # Returns a BlobList containing a list of keys (paths)
109
+ #
110
+ # Calls to {List blobs}[https://learn.microsoft.com/en-us/rest/api/storageservices/list-blobs]
111
+ #
112
+ # Options:
113
+ # [+:prefix+]
114
+ # Prefix of the blobs to be listed. Defaults to listing everything in the container.
115
+ # [:+max_results+]
116
+ # Maximum number of results to return per page.
52
117
  def list_blobs(options = {})
53
118
  uri = generate_uri(container)
54
119
  query = {
@@ -69,6 +134,11 @@ module AzureBlob
69
134
  BlobList.new(fetcher)
70
135
  end
71
136
 
137
+ # Returns a Blob object without the content.
138
+ #
139
+ # Calls to {Get Blob Properties}[https://learn.microsoft.com/en-us/rest/api/storageservices/get-blob-properties]
140
+ #
141
+ # This can be used to see if the blob exist or obtain metada such as content type, disposition, checksum or Azure custom metadata.
72
142
  def get_blob_properties(key, options = {})
73
143
  uri = generate_uri("#{container}/#{key}")
74
144
 
@@ -77,16 +147,37 @@ module AzureBlob
77
147
  Blob.new(response)
78
148
  end
79
149
 
150
+ # Return a URI object to a resource in the container. Takes a path.
151
+ #
152
+ # Example: +generate_uri("#{container}/#{key}")+
80
153
  def generate_uri(path)
81
154
  URI.parse(URI::DEFAULT_PARSER.escape(File.join(host, path)))
82
155
  end
83
156
 
157
+ # Returns an SAS signed URI
158
+ #
159
+ # Takes a
160
+ # - key (path)
161
+ # - A permission string (+"r"+, +"rw"+)
162
+ # - expiry as a UTC iso8601 time string
163
+ # - options
84
164
  def signed_uri(key, permissions:, expiry:, **options)
85
165
  uri = generate_uri("#{container}/#{key}")
86
166
  uri.query = signer.sas_token(uri, permissions:, expiry:, **options)
87
167
  uri
88
168
  end
89
169
 
170
+ # Creates a Blob of type append.
171
+ #
172
+ # Calls to {Put Blob}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-blob]
173
+ #
174
+ # You are expected to append blocks to the blob with append_blob_block after creating the blob.
175
+ # Options:
176
+ #
177
+ # [+:content_type+]
178
+ # Will be saved on the blob in Azure.
179
+ # [+:content_disposition+]
180
+ # Will be saved on the blob in Azure.
90
181
  def create_append_blob(key, options = {})
91
182
  uri = generate_uri("#{container}/#{key}")
92
183
 
@@ -101,6 +192,15 @@ module AzureBlob
101
192
  Http.new(uri, headers, metadata: options[:metadata], signer:).put(nil)
102
193
  end
103
194
 
195
+ # Append a block to an Append Blob
196
+ #
197
+ # Calls to {Append Block}[https://learn.microsoft.com/en-us/rest/api/storageservices/append-block]
198
+ #
199
+ # Options:
200
+ #
201
+ # [+:content_md5+]
202
+ # Will ensure integrity of the upload. The checksum must be a base64 digest. Can be produced with +OpenSSL::Digest::MD5.base64digest+.
203
+ # The checksum must be the checksum of the block not the blob.
104
204
  def append_blob_block(key, content, options = {})
105
205
  uri = generate_uri("#{container}/#{key}")
106
206
  uri.query = URI.encode_www_form(comp: "appendblock")
@@ -114,6 +214,16 @@ module AzureBlob
114
214
  Http.new(uri, headers, signer:).put(content)
115
215
  end
116
216
 
217
+ # Uploads a block to a blob.
218
+ #
219
+ # Calls to {Put Block}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block]
220
+ #
221
+ # Returns the id of the block. Required to commit the list of blocks to a blob.
222
+ #
223
+ # Options:
224
+ #
225
+ # [+:content_md5+]
226
+ # Must be the checksum for the block not the blob. The checksum must be a base64 digest. Can be produced with +OpenSSL::Digest::MD5.base64digest+.
117
227
  def put_blob_block(key, index, content, options = {})
118
228
  block_id = generate_block_id(index)
119
229
  uri = generate_uri("#{container}/#{key}")
@@ -130,6 +240,17 @@ module AzureBlob
130
240
  block_id
131
241
  end
132
242
 
243
+ # Commits the list of blocks to a blob.
244
+ #
245
+ # Calls to {Put Block List}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list]
246
+ #
247
+ # Takes a key (path) and an array of block ids
248
+ #
249
+ # Options:
250
+ #
251
+ # [+:content_md5+]
252
+ # This is the checksum for the whole blob. The checksum is saved on the blob, but it is not validated!
253
+ # Add a checksum for each block if you want Azure to validate integrity.
133
254
  def commit_blob_blocks(key, block_ids, options = {})
134
255
  block_list = BlockList.new(block_ids)
135
256
  content = block_list.to_s
@@ -139,7 +260,7 @@ module AzureBlob
139
260
  headers = {
140
261
  "Content-Length": content.size,
141
262
  "Content-Type": options[:content_type],
142
- "Content-MD5": options[:content_md5],
263
+ "x-ms-blob-content-md5": options[:content_md5],
143
264
  "x-ms-blob-content-disposition": options[:content_disposition],
144
265
  }
145
266
 
@@ -171,7 +292,7 @@ module AzureBlob
171
292
  "x-ms-blob-type": "BlockBlob",
172
293
  "Content-Length": content.size,
173
294
  "Content-Type": options[:content_type],
174
- "Content-MD5": options[:content_md5],
295
+ "x-ms-blob-content-md5": options[:content_md5],
175
296
  "x-ms-blob-content-disposition": options[:content_disposition],
176
297
  }
177
298
 
@@ -0,0 +1,115 @@
1
+ require "base64"
2
+ require "openssl"
3
+ require "net/http"
4
+ require "rexml/document"
5
+
6
+ require_relative "canonicalized_resource"
7
+ require_relative "identity_token"
8
+
9
+ require_relative "user_delegation_key"
10
+
11
+ module AzureBlob
12
+ class EntraIdSigner # :nodoc:
13
+ attr_reader :token
14
+ attr_reader :account_name
15
+
16
+ def initialize(account_name:, principal_id: nil)
17
+ @token = AzureBlob::IdentityToken.new(principal_id:)
18
+ @account_name = account_name
19
+ end
20
+
21
+ def authorization_header(uri:, verb:, headers: {})
22
+ "Bearer #{token}"
23
+ end
24
+
25
+ def sas_token(uri, options = {})
26
+ to_sign = [
27
+ options[:permissions],
28
+ options[:start],
29
+ options[:expiry],
30
+ CanonicalizedResource.new(uri, account_name, url_safe: false, service_name: :blob),
31
+ delegation_key.signed_oid,
32
+ delegation_key.signed_tid,
33
+ delegation_key.signed_start,
34
+ delegation_key.signed_expiry,
35
+ delegation_key.signed_service,
36
+ delegation_key.signed_version,
37
+ nil,
38
+ nil,
39
+ nil,
40
+ options[:ip],
41
+ options[:protocol],
42
+ SAS::Version,
43
+ SAS::Resources::Blob,
44
+ nil,
45
+ nil,
46
+ nil,
47
+ options[:content_disposition],
48
+ nil,
49
+ nil,
50
+ options[:content_type],
51
+ ].join("\n")
52
+
53
+ query = {
54
+ SAS::Fields::Permissions => options[:permissions],
55
+ SAS::Fields::Start => options[:start],
56
+ SAS::Fields::Expiry => options[:expiry],
57
+
58
+ SAS::Fields::SignedObjectId => delegation_key.signed_oid,
59
+ SAS::Fields::SignedTenantId => delegation_key.signed_tid,
60
+ SAS::Fields::SignedKeyStartTime => delegation_key.signed_start,
61
+ SAS::Fields::SignedKeyExpiryTime => delegation_key.signed_expiry,
62
+ SAS::Fields::SignedKeyService => delegation_key.signed_service,
63
+ SAS::Fields::Signedkeyversion => delegation_key.signed_version,
64
+
65
+
66
+ SAS::Fields::SignedIp => options[:ip],
67
+ SAS::Fields::SignedProtocol => options[:protocol],
68
+ SAS::Fields::Version => SAS::Version,
69
+ SAS::Fields::Resource => SAS::Resources::Blob,
70
+
71
+ SAS::Fields::Disposition => options[:content_disposition],
72
+ SAS::Fields::Type => options[:content_type],
73
+ SAS::Fields::Signature => sign(to_sign, key: delegation_key.to_s),
74
+
75
+ }.reject { |_, value| value.nil? }
76
+
77
+ URI.encode_www_form(**query)
78
+ end
79
+
80
+ private
81
+
82
+ def delegation_key
83
+ @delegation_key ||= UserDelegationKey.new(account_name:, signer: self)
84
+ end
85
+
86
+ def sign(body, key:)
87
+ Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", key, body))
88
+ end
89
+
90
+ module SAS # :nodoc:
91
+ Version = "2024-05-04"
92
+ module Fields # :nodoc:
93
+ Permissions = :sp
94
+ Version = :sv
95
+ Start = :st
96
+ Expiry = :se
97
+ Resource = :sr
98
+ Signature = :sig
99
+ Disposition = :rscd
100
+ Type = :rsct
101
+ SignedObjectId = :skoid
102
+ SignedTenantId = :sktid
103
+ SignedKeyStartTime = :skt
104
+ SignedKeyExpiryTime = :ske
105
+ SignedKeyService = :sks
106
+ Signedkeyversion = :skv
107
+ SignedIp = :sip
108
+ SignedProtocol = :spr
109
+ end
110
+ module Resources # :nodoc:
111
+ Blob = :b
112
+ end
113
+ end
114
+ end
115
+ end
@@ -6,8 +6,15 @@ require "net/http"
6
6
  require "rexml"
7
7
 
8
8
  module AzureBlob
9
- class Http
10
- class Error < AzureBlob::Error; end
9
+ class Http # :nodoc:
10
+ class Error < AzureBlob::Error
11
+ attr_reader :body, :status
12
+ def initialize(body: nil, status: nil)
13
+ @body = body
14
+ @status = status
15
+ super(body)
16
+ end
17
+ end
11
18
  class FileNotFoundError < Error; end
12
19
  class ForbidenError < Error; end
13
20
  class IntegrityError < Error; end
@@ -44,6 +51,15 @@ module AzureBlob
44
51
  true
45
52
  end
46
53
 
54
+ def post(content)
55
+ sign_request("POST") if signer
56
+ @response = http.start do |http|
57
+ http.post(uri, content, headers)
58
+ end
59
+ raise_error unless success?
60
+ response.body
61
+ end
62
+
47
63
  def head
48
64
  sign_request("HEAD") if signer
49
65
  @response = http.start do |http|
@@ -91,7 +107,7 @@ module AzureBlob
91
107
  end
92
108
 
93
109
  def raise_error
94
- raise error_from_response.new(@response.body)
110
+ raise error_from_response.new(body: @response.body, status: @response.code&.to_i)
95
111
  end
96
112
 
97
113
  def status
@@ -0,0 +1,65 @@
1
+ require "json"
2
+
3
+ module AzureBlob
4
+ class IdentityToken
5
+ RESOURCE_URI = "https://storage.azure.com/"
6
+ EXPIRATION_BUFFER = 600 # 10 minutes
7
+
8
+ IDENTITY_ENDPOINT = ENV["IDENTITY_ENDPOINT"] || "http://169.254.169.254/metadata/identity/oauth2/token"
9
+ API_VERSION = ENV["IDENTITY_ENDPOINT"] ? "2019-08-01" : "2018-02-01"
10
+
11
+ def initialize(principal_id: nil)
12
+ @identity_uri = URI.parse(IDENTITY_ENDPOINT)
13
+ params = {
14
+ 'api-version': API_VERSION,
15
+ resource: RESOURCE_URI,
16
+ }
17
+ params[:principal_id] = principal_id if principal_id
18
+ @identity_uri.query = URI.encode_www_form(params)
19
+ end
20
+
21
+ def to_s
22
+ refresh if expired?
23
+ token
24
+ end
25
+
26
+ private
27
+
28
+ def expired?
29
+ token.nil? || Time.now >= (expiration - EXPIRATION_BUFFER)
30
+ end
31
+
32
+ def refresh
33
+ headers = { "Metadata" => "true" }
34
+ headers["X-IDENTITY-HEADER"] = ENV["IDENTITY_HEADER"] if ENV["IDENTITY_HEADER"]
35
+
36
+ attempt = 0
37
+ begin
38
+ attempt += 1
39
+ response = JSON.parse(AzureBlob::Http.new(identity_uri, headers).get)
40
+ rescue AzureBlob::Http::Error => error
41
+ if should_retry?(error, attempt)
42
+ attempt = 1 if error.status == 410
43
+ delay = exponential_backoff(error, attempt)
44
+ Kernel.sleep(delay)
45
+ retry
46
+ end
47
+ raise
48
+ end
49
+ @token = response["access_token"]
50
+ @expiration = Time.at(response["expires_on"].to_i)
51
+ end
52
+
53
+ def should_retry?(error, attempt)
54
+ is_500 = error.status/500 == 1
55
+ (is_500 || [ 404, 408, 410, 429 ].include?(error.status)) && attempt < 5
56
+ end
57
+
58
+ def exponential_backoff(error, attempt)
59
+ EXPONENTIAL_BACKOFF[attempt -1] || raise(AzureBlob::Error.new("Exponential backoff out of bounds!"))
60
+ end
61
+ EXPONENTIAL_BACKOFF = [ 2, 6, 14, 30 ]
62
+
63
+ attr_reader :identity_uri, :expiration, :token
64
+ end
65
+ end
@@ -1,5 +1,5 @@
1
1
  module AzureBlob
2
- class Metadata
2
+ class Metadata # :nodoc:
3
3
  def initialize(metadata = nil)
4
4
  @metadata = metadata || {}
5
5
  @headers = @metadata.map do |key, value|
@@ -6,7 +6,7 @@ require_relative "canonicalized_headers"
6
6
  require_relative "canonicalized_resource"
7
7
 
8
8
  module AzureBlob
9
- class Signer
9
+ class SharedKeySigner # :nodoc:
10
10
  def initialize(account_name:, access_key:)
11
11
  @account_name = account_name
12
12
  @access_key = Base64.decode64(access_key)
@@ -71,21 +71,21 @@ module AzureBlob
71
71
  URI.encode_www_form(**query)
72
72
  end
73
73
 
74
+ private
75
+
74
76
  def sign(body)
75
77
  Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", access_key, body))
76
78
  end
77
79
 
78
- private
79
-
80
80
  def sanitize_headers(headers)
81
81
  headers = headers.dup
82
82
  headers[:"Content-Length"] = nil if headers[:"Content-Length"].to_i == 0
83
83
  headers
84
84
  end
85
85
 
86
- module SAS
86
+ module SAS # :nodoc:
87
87
  Version = "2024-05-04"
88
- module Fields
88
+ module Fields # :nodoc:
89
89
  Permissions = :sp
90
90
  Version = :sv
91
91
  Expiry = :se
@@ -94,7 +94,7 @@ module AzureBlob
94
94
  Disposition = :rscd
95
95
  Type = :rsct
96
96
  end
97
- module Resources
97
+ module Resources # :nodoc:
98
98
  Blob = :b
99
99
  end
100
100
  end
@@ -0,0 +1,67 @@
1
+ require_relative "http"
2
+
3
+ module AzureBlob
4
+ class UserDelegationKey # :nodoc:
5
+ EXPIRATION = 25200 # 7 hours
6
+ EXPIRATION_BUFFER = 3600 # 1 hours
7
+ def initialize(account_name:, signer:)
8
+ @uri = URI.parse(
9
+ "https://#{account_name}.blob.core.windows.net/?restype=service&comp=userdelegationkey"
10
+ )
11
+
12
+ @signer = signer
13
+
14
+ refresh
15
+ end
16
+
17
+ def to_s
18
+ user_delegation_key
19
+ end
20
+
21
+ def refresh
22
+ return unless expired?
23
+ now = Time.now.utc
24
+
25
+
26
+ start = now.iso8601
27
+ @expiration = (now + EXPIRATION)
28
+ expiry = @expiration.iso8601
29
+
30
+ content = <<-XML.gsub!(/[[:space:]]+/, " ").strip!
31
+ <?xml version="1.0" encoding="utf-8"?>
32
+ <KeyInfo>
33
+ <Start>#{start}</Start>
34
+ <Expiry>#{expiry}</Expiry>
35
+ </KeyInfo>
36
+ XML
37
+
38
+ response = Http.new(uri, signer:).post(content)
39
+
40
+ doc = REXML::Document.new(response)
41
+
42
+ @signed_oid = doc.get_elements("/UserDelegationKey/SignedOid").first.get_text.to_s
43
+ @signed_tid = doc.get_elements("/UserDelegationKey/SignedTid").first.get_text.to_s
44
+ @signed_start = doc.get_elements("/UserDelegationKey/SignedStart").first.get_text.to_s
45
+ @signed_expiry = doc.get_elements("/UserDelegationKey/SignedExpiry").first.get_text.to_s
46
+ @signed_service = doc.get_elements("/UserDelegationKey/SignedService").first.get_text.to_s
47
+ @signed_version = doc.get_elements("/UserDelegationKey/SignedVersion").first.get_text.to_s
48
+ @user_delegation_key = Base64.decode64(doc.get_elements("/UserDelegationKey/Value").first.get_text.to_s)
49
+ end
50
+
51
+ attr_reader :signed_oid,
52
+ :signed_tid,
53
+ :signed_start,
54
+ :signed_expiry,
55
+ :signed_service,
56
+ :signed_version,
57
+ :user_delegation_key
58
+
59
+ private
60
+
61
+ def expired?
62
+ expiration.nil? || Time.now >= (expiration - EXPIRATION_BUFFER)
63
+ end
64
+
65
+ attr_reader :uri, :user_delegation_key, :signer, :expiration
66
+ end
67
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AzureBlob
4
- VERSION = "0.4.1"
4
+ VERSION = "0.5.0"
5
5
  end