azure-blob 0.4.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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