azure-blob 0.7.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fd3246ce35e01647d7aaa6e5819320799d347a96d0b95b9bc4d4d89939ce9a1
4
- data.tar.gz: b6ace232dddeed3415294e866115420a91935b7332c79494e5b17c9a2e91eb52
3
+ metadata.gz: 1e0ce3229ce4281799f9d78b8f5b95343ac3fced270f902f07215630568bec93
4
+ data.tar.gz: ce76f13f260cdc59684ccfe0f655730c6fa03859bfa98264678d111b49999cfb
5
5
  SHA512:
6
- metadata.gz: f375dca9e699671f5fc165b8463b313f1cc823d5cb6f3f0666cffc22665fa7becb7d075f61dd0cbb131cf85ddc14c47b37b236d6d28b276b2d13b89cc7626660
7
- data.tar.gz: 06bfe2d031d6deb2a58da359258df5923ffab3c23d6edf0a36ee4adb93ee4ac69a82d50d571e97b4cfdff10913f2e6c4a834f5338258c33f0b0a0336de1cde17
6
+ metadata.gz: d663e5fb344c55fc2a8efb9247f6207866c9f5676e9e64e028a1bde2c6762fa5bc5dd5ecf9864b41a44176eb7d7782524424a4dc29df1bf3bf716abb47e56d2b
7
+ data.tar.gz: 58663d45654f161db667d2bc5def5a36c7d5202038cf107322805b7f15b12cc03a7b455b058ee11385233a3c1dff70037ffe43118a848e2427e14e177d84920f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.7.0] 2025-01-05
3
+ ## [0.9.0] 2026-10-11
4
+
5
+ - Drop support for Ruby 3.1 and 3.2. Ruby 3.3 or newer is now required
6
+ - Drop support for Rails 7.x. The Active Storage service now supports Rails 8.0 or newer
7
+ - Fix `copy_blob` failing on append blobs and on blobs larger than 256 MiB
8
+ - Fix `compose` failing when its source is a composed blob (#48)
9
+ - Add `put_blob_block_from_url`
10
+ - Add `AzureBlob::Blob#blob_type` and `AzureBlob::Blob#block_blob?`
11
+ - Fix the content type being dropped when a blob is uploaded in multiple blocks
12
+ - Add optional `delegation_key_expiration` to `AzureBlob::Client.new` and the ActiveStorage config to set the minimum lifetime of the Entra ID user delegation key in seconds (default 7 hours, maximum 7 days)
13
+ - Fix Entra ID signed URLs that stopped working before their expiry because the user delegation key expired first
14
+ - Raise `ArgumentError` when signing an Entra ID URL that expires more than 7 days from now
15
+ - Include the HTTP status and Azure error code in `AzureBlob::Http::Error` messages (e.g. `500 InternalError`) and add `AzureBlob::Http::Error#code`
16
+ - Fix error responses without an XML body raising `NoMethodError` instead of `AzureBlob::Http::Error`, which skipped the managed identity token retries
17
+
18
+ ## [0.8.0] 2026-01-18
19
+
20
+ - Add cgi gem dependency for Ruby 4.0 compatibility
21
+
22
+ ## [0.7.0] 2026-01-05
4
23
 
5
24
  - Add optional `timeout` parameter to all API methods
6
25
  - Add `AzureBlob::Http::TimeoutError` for handling Azure `OperationTimedOut` errors
data/README.md CHANGED
@@ -4,6 +4,8 @@ Azure Blob client and Active Storage adapter to replace the now abandoned azure-
4
4
 
5
5
  An Active Storage is supplied, but the gem is Rails agnostic and can be used in any Ruby project.
6
6
 
7
+ Requires Ruby 3.3 or newer. The Active Storage service supports Rails 8.0 or newer.
8
+
7
9
  ## Active Storage
8
10
 
9
11
  ### Migration
@@ -45,6 +47,35 @@ prod:
45
47
  principal_id: 71b34410-4c50-451d-b456-95ead1b18cce
46
48
  ```
47
49
 
50
+ #### User delegation key expiration
51
+
52
+ When authenticating through Entra ID, signed URLs are signed with a user delegation key
53
+ that AzureBlob requests and renews as needed. `delegation_key_expiration` (in seconds)
54
+ sets how long each key lasts: 7 hours by default, up to Azure's maximum of 7 days. Set it
55
+ above the longest URL lifetime your app issues.
56
+
57
+ ```ruby
58
+ client = AzureBlob::Client.new(
59
+ account_name: "account_name",
60
+ container: "container_name",
61
+ principal_id: "71b34410-4c50-451d-b456-95ead1b18cce",
62
+ delegation_key_expiration: 86400, # 1 day
63
+ )
64
+ ```
65
+
66
+ ActiveStorage config example:
67
+
68
+ ```
69
+ prod:
70
+ service: AzureBlob
71
+ container: container_name
72
+ storage_account_name: account_name
73
+ principal_id: 71b34410-4c50-451d-b456-95ead1b18cce
74
+ delegation_key_expiration: 86400 # 1 day
75
+ ```
76
+
77
+ The option is ignored when authenticating with a shared key.
78
+
48
79
  #### AKS with Workload Identity
49
80
 
50
81
  ActiveStorage config example:
@@ -28,6 +28,15 @@ module AzureBlob
28
28
  response.content_length
29
29
  end
30
30
 
31
+ # Returns the blob type: +"BlockBlob"+, +"AppendBlob"+ or +"PageBlob"+.
32
+ def blob_type
33
+ response["x-ms-blob-type"]
34
+ end
35
+
36
+ def block_blob?
37
+ blob_type == "BlockBlob"
38
+ end
39
+
31
40
  def present?
32
41
  response.code == "200"
33
42
  end
@@ -24,6 +24,7 @@ module AzureBlob
24
24
  @access_key = access_key
25
25
  @principal_id = principal_id
26
26
  @use_managed_identities = options[:use_managed_identities]
27
+ @delegation_key_expiration = options[:delegation_key_expiration]
27
28
  signer unless options[:lazy]
28
29
  end
29
30
 
@@ -81,7 +82,13 @@ module AzureBlob
81
82
 
82
83
  # Copy a blob between containers or within the same container
83
84
  #
84
- # Calls to {Copy Blob From URL}[https://learn.microsoft.com/en-us/rest/api/storageservices/copy-blob-from-url]
85
+ # Calls to {Copy Blob From URL}[https://learn.microsoft.com/en-us/rest/api/storageservices/copy-blob-from-url],
86
+ # which copies a block blob of at most 256 MiB in a single request. When Azure refuses the source
87
+ # (an append blob, or a bigger block blob) the blob is copied block by block with
88
+ # {Put Block From URL}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-from-url] instead.
89
+ #
90
+ # Either way the copy is a block blob that keeps the content type, content disposition and checksum
91
+ # of the source, and its metadata unless +:metadata+ is given.
85
92
  #
86
93
  # Parameters:
87
94
  # - key: destination blob path
@@ -89,9 +96,11 @@ module AzureBlob
89
96
  # - options: additional options
90
97
  # - source_client: AzureBlob::Client instance for the source container (optional)
91
98
  # If not provided, copies from within the same container
99
+ # - metadata: metadata of the copy (optional). Defaults to the metadata of the source
100
+ # - block_size: block size of a block by block copy. Defaults to +AzureBlob::DEFAULT_BLOCK_SIZE+
92
101
  #
93
102
  def copy_blob(key, source_key, options = {})
94
- source_client = options.delete(:source_client) || self
103
+ source_client = options[:source_client] || self
95
104
  uri = generate_uri("#{container}/#{key}")
96
105
  uri.query = URI.encode_www_form(timeout: options[:timeout]) if options[:timeout]
97
106
 
@@ -103,6 +112,9 @@ module AzureBlob
103
112
  }.merge(additional_headers(options))
104
113
 
105
114
  Http.new(uri, headers, signer:, **options.slice(:metadata, :tags)).put
115
+ rescue Http::Error => error
116
+ raise unless error.status == 409
117
+ copy_blob_blocks(key, source_key, source_client, options)
106
118
  end
107
119
 
108
120
  # Delete a blob
@@ -361,6 +373,35 @@ module AzureBlob
361
373
  block_id
362
374
  end
363
375
 
376
+ # Copies a block from a URL into a blob.
377
+ #
378
+ # Calls to {Put Block From URL}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-from-url]
379
+ #
380
+ # Returns the id of the block. Required to commit the list of blocks to a blob.
381
+ #
382
+ # Options:
383
+ #
384
+ # [+:source_range+]
385
+ # Range of bytes to copy from the source, for example +0..(block_size - 1)+. Defaults to the whole source.
386
+ def put_blob_block_from_url(key, index, source_uri, options = {})
387
+ block_id = generate_block_id(index)
388
+ uri = generate_uri("#{container}/#{key}")
389
+ query = { comp: "block", blockid: block_id }
390
+ query[:timeout] = options[:timeout] if options[:timeout]
391
+ uri.query = URI.encode_www_form(**query)
392
+
393
+ source_range = options[:source_range]
394
+ headers = {
395
+ "Content-Length": 0,
396
+ "x-ms-copy-source": source_uri.to_s,
397
+ "x-ms-source-range": source_range && "bytes=#{source_range.min}-#{source_range.max}",
398
+ }.merge(additional_headers(options))
399
+
400
+ Http.new(uri, headers, signer:).put
401
+
402
+ block_id
403
+ end
404
+
364
405
  # Commits the list of blocks to a blob.
365
406
  #
366
407
  # Calls to {Put Block List}[https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list]
@@ -382,7 +423,7 @@ module AzureBlob
382
423
 
383
424
  headers = {
384
425
  "Content-Length": content_size(content),
385
- "Content-Type": options[:content_type],
426
+ "x-ms-blob-content-type": options[:content_type],
386
427
  "x-ms-blob-content-md5": options[:content_md5],
387
428
  "x-ms-blob-content-disposition": options[:content_disposition],
388
429
  }.merge(additional_headers(options))
@@ -428,6 +469,34 @@ module AzureBlob
428
469
  Http.new(uri, headers, signer:, **options.slice(:metadata, :tags)).put(content.read)
429
470
  end
430
471
 
472
+ # One Put Block From URL request per block, then one Put Block List to commit them.
473
+ # Works with any source blob type and size, and carries the properties and metadata over itself.
474
+ def copy_blob_blocks(key, source_key, source_client, options)
475
+ source = source_client.get_blob_properties(source_key, options.slice(:timeout))
476
+ block_size = options[:block_size] || DEFAULT_BLOCK_SIZE
477
+ # Every block reads the source through this URI, so it has to outlive the whole copy.
478
+ source_uri = source_client.signed_uri(source_key, permissions: "r", expiry: Time.at(Time.now.to_i + 3600).utc.iso8601)
479
+
480
+ block_ids = (source.size.to_f / block_size).ceil.times.map do |index|
481
+ first = index * block_size
482
+ last = [ first + block_size, source.size ].min - 1
483
+ put_blob_block_from_url(key, index, source_uri, source_range: first..last, **options.slice(:timeout, :headers))
484
+ end
485
+
486
+ metadata = options[:metadata]
487
+ metadata = source.metadata if metadata.nil? || metadata.empty?
488
+
489
+ commit_blob_blocks(
490
+ key,
491
+ block_ids,
492
+ content_type: source.content_type,
493
+ content_md5: source.checksum,
494
+ content_disposition: source.content_disposition,
495
+ metadata: metadata,
496
+ **options.slice(:tags, :timeout, :headers),
497
+ )
498
+ end
499
+
431
500
  def content_size(content)
432
501
  if content.respond_to?(:bytesize)
433
502
  content.bytesize
@@ -453,11 +522,11 @@ module AzureBlob
453
522
  end
454
523
 
455
524
  using_managed_identities ?
456
- AzureBlob::EntraIdSigner.new(account_name:, host:, principal_id:) :
525
+ AzureBlob::EntraIdSigner.new(account_name:, host:, principal_id:, delegation_key_expiration:) :
457
526
  AzureBlob::SharedKeySigner.new(account_name:, access_key:, host:)
458
527
  end
459
528
  end
460
529
 
461
- attr_reader :account_name, :container, :http, :cloud_regions, :access_key, :principal_id, :use_managed_identities
530
+ attr_reader :account_name, :container, :http, :cloud_regions, :access_key, :principal_id, :use_managed_identities, :delegation_key_expiration
462
531
  end
463
532
  end
@@ -14,7 +14,8 @@ module AzureBlob
14
14
  attr_reader :account_name
15
15
  attr_reader :host
16
16
 
17
- def initialize(account_name:, host:, principal_id: nil)
17
+ def initialize(account_name:, host:, principal_id: nil, delegation_key_expiration: nil)
18
+ @delegation_key_expiration = parse_delegation_key_expiration(delegation_key_expiration)
18
19
  @token = AzureBlob::IdentityToken.new(principal_id:)
19
20
  @account_name = account_name
20
21
  @host = host
@@ -25,7 +26,7 @@ module AzureBlob
25
26
  end
26
27
 
27
28
  def sas_token(uri, options = {})
28
- delegation_key.refresh
29
+ delegation_key.refresh(valid_until: options[:expiry])
29
30
  to_sign = [
30
31
  options[:permissions],
31
32
  options[:start],
@@ -82,8 +83,25 @@ module AzureBlob
82
83
 
83
84
  private
84
85
 
86
+ attr_reader :delegation_key_expiration
87
+
88
+ def parse_delegation_key_expiration(value)
89
+ return if value.nil?
90
+
91
+ seconds = Integer(value, exception: false)
92
+ unless seconds&.positive?
93
+ raise ArgumentError, "delegation_key_expiration must be a positive number of seconds, got #{value.inspect}"
94
+ end
95
+
96
+ if seconds > UserDelegationKey::MAX_EXPIRATION
97
+ raise ArgumentError, "delegation_key_expiration cannot be greater than #{UserDelegationKey::MAX_EXPIRATION} seconds (7 days), got #{value.inspect}"
98
+ end
99
+
100
+ seconds
101
+ end
102
+
85
103
  def delegation_key
86
- @delegation_key ||= UserDelegationKey.new(account_name:, signer: self)
104
+ @delegation_key ||= UserDelegationKey.new(account_name:, signer: self, expiration: delegation_key_expiration || UserDelegationKey::EXPIRATION)
87
105
  end
88
106
 
89
107
  def sign(body, key:)
@@ -8,10 +8,12 @@ require "rexml"
8
8
  module AzureBlob
9
9
  class Http # :nodoc:
10
10
  class Error < AzureBlob::Error
11
- attr_reader :body, :status
12
- def initialize(body: nil, status: nil)
11
+ attr_reader :body, :status, :code
12
+ def initialize(body: nil, status: nil, code: nil)
13
13
  @body = body
14
14
  @status = status
15
+ @code = code
16
+ super(status && [ status, code ].compact.join(" "))
15
17
  end
16
18
 
17
19
  def inspect
@@ -118,7 +120,7 @@ module AzureBlob
118
120
 
119
121
  def raise_error
120
122
  return unless raise_on_error
121
- raise error_from_response.new(body: @response.body, status: @response.code&.to_i)
123
+ raise error_from_response.new(body: @response.body, status: @response.code&.to_i, code: azure_error_code)
122
124
  end
123
125
 
124
126
  def status
@@ -126,7 +128,9 @@ module AzureBlob
126
128
  end
127
129
 
128
130
  def azure_error_code
129
- Document.new(response.body).get_elements("//Error/Code").first.get_text.to_s if response.body
131
+ response["x-ms-error-code"] || Document.new(response.body.to_s).get_elements("//Error/Code").first&.text
132
+ rescue ParseException
133
+ nil
130
134
  end
131
135
 
132
136
  def error_from_response
@@ -1,15 +1,20 @@
1
+ require "time"
2
+
1
3
  require_relative "http"
2
4
 
3
5
  module AzureBlob
4
6
  class UserDelegationKey # :nodoc:
5
7
  EXPIRATION = 25200 # 7 hours
8
+ MAX_EXPIRATION = 604800 # 7 days
6
9
  EXPIRATION_BUFFER = 3600 # 1 hours
7
- def initialize(account_name:, signer:)
10
+
11
+ def initialize(account_name:, signer:, expiration: EXPIRATION)
8
12
  @uri = URI.parse(
9
13
  "#{signer.host}/?restype=service&comp=userdelegationkey"
10
14
  )
11
15
 
12
16
  @signer = signer
17
+ @expiration_duration = expiration
13
18
 
14
19
  refresh
15
20
  end
@@ -19,14 +24,22 @@ module AzureBlob
19
24
  user_delegation_key
20
25
  end
21
26
 
22
- def refresh
23
- return unless expired?
27
+ # Requests a new key when this one is about to expire, or when it expires
28
+ # before +valid_until+ (the expiry of the SAS about to be signed with it).
29
+ def refresh(valid_until: nil)
30
+ valid_until = Time.parse(valid_until) if valid_until.is_a?(String)
24
31
  now = Time.now.utc
25
32
 
33
+ if valid_until && valid_until > now + MAX_EXPIRATION
34
+ raise ArgumentError, "signed URL expiry #{valid_until.getutc.iso8601} is more than #{MAX_EXPIRATION} seconds (7 days) away, " \
35
+ "Azure cannot sign a user delegation SAS that lasts longer than 7 days"
36
+ end
37
+
38
+ return unless expired? || (valid_until && valid_until > expiration)
26
39
 
27
40
  start = now.iso8601
28
- @expiration = (now + EXPIRATION)
29
- expiry = @expiration.iso8601
41
+ new_expiration = [ now + expiration_duration, valid_until ].compact.max
42
+ expiry = new_expiration.getutc.iso8601
30
43
 
31
44
  content = <<-XML.gsub!(/[[:space:]]+/, " ").strip!
32
45
  <?xml version="1.0" encoding="utf-8"?>
@@ -47,6 +60,7 @@ module AzureBlob
47
60
  @signed_service = doc.get_elements("/UserDelegationKey/SignedService").first.get_text.to_s
48
61
  @signed_version = doc.get_elements("/UserDelegationKey/SignedVersion").first.get_text.to_s
49
62
  @user_delegation_key = Base64.decode64(doc.get_elements("/UserDelegationKey/Value").first.get_text.to_s)
63
+ @expiration = new_expiration
50
64
  end
51
65
 
52
66
  attr_reader :signed_oid,
@@ -60,9 +74,13 @@ module AzureBlob
60
74
  private
61
75
 
62
76
  def expired?
63
- expiration.nil? || Time.now >= (expiration - EXPIRATION_BUFFER)
77
+ expiration.nil? || Time.now >= (expiration - refresh_buffer)
78
+ end
79
+
80
+ def refresh_buffer
81
+ [ EXPIRATION_BUFFER, expiration_duration / 2 ].min
64
82
  end
65
83
 
66
- attr_reader :uri, :user_delegation_key, :signer, :expiration
84
+ attr_reader :uri, :user_delegation_key, :signer, :expiration, :expiration_duration
67
85
  end
68
86
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AzureBlob
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: azure-blob
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joé Dupuis
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 1980-01-01 00:00:00.000000000 Z
11
+ date: 2026-10-11 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: cgi
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rexml
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -72,14 +86,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
72
86
  requirements:
73
87
  - - ">="
74
88
  - !ruby/object:Gem::Version
75
- version: '3.1'
89
+ version: '3.3'
76
90
  required_rubygems_version: !ruby/object:Gem::Requirement
77
91
  requirements:
78
92
  - - ">="
79
93
  - !ruby/object:Gem::Version
80
94
  version: '0'
81
95
  requirements: []
82
- rubygems_version: 3.4.19
96
+ rubygems_version: 3.5.22
83
97
  signing_key:
84
98
  specification_version: 4
85
99
  summary: Azure Blob client and Active Storage adapter