Skip to content

Add support to storage_blob_host configuration #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Nov 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ devenv.local.nix
terraform.tfstate
terraform.tfstate.backup
.terraform.tfstate.lock.info
*.tfvars
*.tfvars

__azurite_db*
__blobstorage__/
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## [Unreleased]

- Allow creating public container
- Add Azurite support

## [0.5.3] 2024-10-31

Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@ prod:
principal_id: 71b34410-4c50-451d-b456-95ead1b18cce
```

### Azurite

To use Azurite, pass the `storage_blob_host` config key with the Azurite URL (`http://127.0.0.1:10000/devstoreaccount1` by default)
and the Azurite credentials (`devstoreaccount1` and `Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==` by default).

Example:

```
dev:
service: AzureBlob
container: container_name
storage_account_name: devstoreaccount1
storage_access_key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
storage_blob_host: http://127.0.0.1:10000/devstoreaccount1
```

You'll have to create the container before you can start uploading files.
You can do so using Azure CLI, Azure Storage Explorer, or by running:

`bin/rails runner "ActiveStorage::Blob.service.client.tap{|client| client.create_container unless client.get_container_properties.present?}.tap { |client| puts 'done!' if client.get_container_properties.present?}"`

Make sure that `config.active_storage.service = :dev` is set to your azurite configuration.
Container names can't have any special characters, or you'll get an error.

## Standalone

Instantiate a client with your account name, an access key and the container name:
Expand Down
23 changes: 23 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ require "minitest/test_task"
require "azure_blob"
require_relative "test/support/app_service_vpn"
require_relative "test/support/azure_vm_vpn"
require_relative "test/support/azurite"

Minitest::TestTask.create(:test_rails) do
self.test_globs = [ "test/rails/**/test_*.rb",
Expand Down Expand Up @@ -39,6 +40,28 @@ ensure
vpn.kill
end

task :test_azurite do |t|
azurite = Azurite.new
# Azurite well-known credentials
# https://learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite?tabs=visual-studio%2Cblob-storage#well-known-storage-account-and-key
account_name = ENV["AZURE_ACCOUNT_NAME"] = "devstoreaccount1"
access_key = ENV["AZURE_ACCESS_KEY"] = "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=="
host = ENV["STORAGE_BLOB_HOST"] = "http://127.0.0.1:10000/devstoreaccount1"
ENV["TESTING_AZURITE"] = "true"

# Create containers
private_container = AzureBlob::Client.new(account_name:, access_key:, host:, container: ENV["AZURE_PRIVATE_CONTAINER"])
public_container = AzureBlob::Client.new(account_name:, access_key:, host:, container: ENV["AZURE_PUBLIC_CONTAINER"])
# public_container.delete_container
private_container.create_container unless private_container.get_container_properties.present?
public_container.create_container(public_access: true) unless public_container.get_container_properties.present?

Rake::Task["test_client"].execute
Rake::Task["test_rails"].execute
ensure
azurite.kill
end

task :test_entra_id do |t|
ENV["AZURE_ACCESS_KEY"] = nil
Rake::Task["test"].execute
Expand Down
1 change: 1 addition & 0 deletions devenv.nix
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
sshuttle
sshpass
rsync
azurite
];

languages.ruby.enable = true;
Expand Down
3 changes: 2 additions & 1 deletion lib/active_storage/service/azure_blob_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,14 @@ module ActiveStorage
class Service::AzureBlobService < Service
attr_reader :client, :container, :signer

def initialize(storage_account_name:, storage_access_key: nil, container:, public: false, **options)
def initialize(storage_account_name:, storage_access_key: nil, container:, storage_blob_host: nil, public: false, **options)
@container = container
@public = public
@client = AzureBlob::Client.new(
account_name: storage_account_name,
access_key: storage_access_key,
container: container,
host: storage_blob_host,
**options)
end

Expand Down
13 changes: 9 additions & 4 deletions lib/azure_blob/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ module AzureBlob
# AzureBlob Client class. You interact with the Azure Blob api
# through an instance of this class.
class Client
def initialize(account_name:, access_key: nil, principal_id: nil, container:, **options)
def initialize(account_name:, access_key: nil, principal_id: nil, container:, host: nil, **options)
@account_name = account_name
@container = container
@host = host
@cloud_regions = options[:cloud_regions]&.to_sym || :global

no_access_key = access_key.nil? || access_key&.empty?
Expand All @@ -29,8 +30,8 @@ def initialize(account_name:, access_key: nil, principal_id: nil, container:, **
)
end
@signer = using_managed_identities ?
AzureBlob::EntraIdSigner.new(account_name:, host:, principal_id:) :
AzureBlob::SharedKeySigner.new(account_name:, access_key:)
AzureBlob::EntraIdSigner.new(account_name:, host: self.host, principal_id:) :
AzureBlob::SharedKeySigner.new(account_name:, access_key:, host: self.host)
end

# 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.
Expand Down Expand Up @@ -190,8 +191,12 @@ def get_container_properties(options = {})
# Calls to {Create Container}[https://learn.microsoft.com/en-us/rest/api/storageservices/create-container]
def create_container(options = {})
uri = generate_uri(container)
headers = {}
headers[:"x-ms-blob-public-access"] = "blob" if options[:public_access]
headers[:"x-ms-blob-public-access"] = options[:public_access] if ["container","blob"].include?(options[:public_access])

uri.query = URI.encode_www_form(restype: "container")
response = Http.new(uri, signer:).put
response = Http.new(uri, headers, signer:).put
end

# Delete the container
Expand Down
11 changes: 9 additions & 2 deletions lib/azure_blob/shared_key_signer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@

module AzureBlob
class SharedKeySigner # :nodoc:
def initialize(account_name:, access_key:)
def initialize(account_name:, access_key:, host:)
@account_name = account_name
@access_key = Base64.decode64(access_key)
@host = host
@remove_prefix = @host.include?("/#{@account_name}")
end

def authorization_header(uri:, verb:, headers: {})
Expand Down Expand Up @@ -39,6 +41,11 @@ def authorization_header(uri:, verb:, headers: {})
end

def sas_token(uri, options = {})
if remove_prefix
uri = uri.clone
uri.path = uri.path.delete_prefix("/#{account_name}")
end

to_sign = [
options[:permissions],
options[:start],
Expand Down Expand Up @@ -99,6 +106,6 @@ module Resources # :nodoc:
end
end

attr_reader :access_key, :account_name
attr_reader :access_key, :account_name, :remove_prefix
end
end
5 changes: 5 additions & 0 deletions test/client/test_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ def setup
@access_key = ENV["AZURE_ACCESS_KEY"]
@container = ENV["AZURE_PRIVATE_CONTAINER"]
@principal_id = ENV["AZURE_PRINCIPAL_ID"]
@host = ENV["STORAGE_BLOB_HOST"]
@client = AzureBlob::Client.new(
account_name: @account_name,
access_key: @access_key,
container: @container,
principal_id: @principal_id,
host: @host,
)
@key = "test client##{name}"
@content = "Some random content #{Random.rand(200)}"
Expand Down Expand Up @@ -104,11 +106,13 @@ def test_upload_integrity_block
end

def test_upload_raise_on_invalid_checksum_blob
skip if ENV["TESTING_AZURITE"]
checksum = OpenSSL::Digest::MD5.base64digest(content + "a")
assert_raises(AzureBlob::Http::IntegrityError) { client.create_block_blob(key, content, content_md5: checksum) }
end

def test_upload_raise_on_invalid_checksum_block
skip if ENV["TESTING_AZURITE"]
checksum = OpenSSL::Digest::MD5.base64digest(content + "a")
assert_raises(AzureBlob::Http::IntegrityError) { client.put_blob_block(key, 0, content, content_md5: checksum) }
end
Expand Down Expand Up @@ -338,6 +342,7 @@ def test_create_container
access_key: @access_key,
container: Random.alphanumeric(20).tr("0-9", "").downcase,
principal_id: @principal_id,
host: @host,
)
container = client.get_container_properties
refute container.present?
Expand Down
4 changes: 3 additions & 1 deletion test/rails/controllers/direct_uploads_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,16 @@ class ActiveStorage::AzureBlobDirectUploadsControllerTest < ActionDispatch::Inte
post rails_direct_uploads_url, params: { blob: {
filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata, } }

host = @config[:host] || "https://#{@config[:storage_account_name]}.blob.core.windows.net"

response.parsed_body.tap do |details|
assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"])
assert_equal "hello.txt", details["filename"]
assert_equal 6, details["byte_size"]
assert_equal checksum, details["checksum"]
assert_equal metadata, details["metadata"]
assert_equal "text/plain", details["content_type"]
assert_match %r{#{@config[:storage_account_name]}\.blob\.core\.windows\.net/#{@config[:container]}}, details["direct_upload"]["url"]
assert details["direct_upload"]["url"].start_with?("#{host}/#{@config[:container]}")
assert_equal({ "Content-Type" => "text/plain", "Content-MD5" => checksum, "x-ms-blob-content-disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", "x-ms-blob-type" => "BlockBlob" }, details["direct_upload"]["headers"])
end
end
Expand Down
9 changes: 7 additions & 2 deletions test/rails/service/azure_blob_public_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ class ActiveStorage::Service::AzureBlobPublicServiceTest < ActiveSupport::TestCa

include ActiveStorage::Service::SharedServiceTests

setup do
@config = SERVICE_CONFIGURATIONS[:azure_public]
end

test "public URL generation" do
url = @service.url(@key, filename: ActiveStorage::Filename.new("avatar.png"))
host = @config[:host] || "https://#{@config[:storage_account_name]}.blob.core.windows.net"

assert_match(/.*\.blob\.core\.windows\.net\/.*\/#{@key}/, url)
assert url.start_with?("#{host}/#{@config[:container]}/#{@key}")

response = Net::HTTP.get_response(URI(url))
assert_equal "200", response.code
Expand All @@ -30,7 +35,7 @@ class ActiveStorage::Service::AzureBlobPublicServiceTest < ActiveSupport::TestCa
@service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v|
request.add_field k, v
end
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.port == 443) do |http|
http.request request
end

Expand Down
5 changes: 3 additions & 2 deletions test/rails/service/azure_blob_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ class ActiveStorage::Service::AzureBlobServiceTest < ActiveSupport::TestCase
@service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v|
request.add_field k, v
end
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|

Net::HTTP.start(uri.host, uri.port, use_ssl: uri.port == 443) do |http|
http.request request
end

Expand All @@ -42,7 +43,7 @@ class ActiveStorage::Service::AzureBlobServiceTest < ActiveSupport::TestCase
@service.headers_for_direct_upload(key, checksum: checksum, content_type: "text/plain", filename: ActiveStorage::Filename.new("test.txt"), disposition: :attachment).each do |k, v|
request.add_field k, v
end
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.port == 443) do |http|
http.request request
end

Expand Down
1 change: 1 addition & 0 deletions test/rails/service/configurations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ DEFAULT: &default
storage_account_name: <%= ENV["AZURE_ACCOUNT_NAME"] %>
storage_access_key: <%= ENV["AZURE_ACCESS_KEY"] %>
principal_id: <%= ENV["AZURE_PRINCIPAL_ID"]%>
storage_blob_host: <%= ENV["STORAGE_BLOB_HOST"] %>

azure:
<<: *default
Expand Down
3 changes: 2 additions & 1 deletion test/rails/service/shared_service_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ module ActiveStorage::Service::SharedServiceTests
end

test "uploading without integrity" do
skip if ENV["TESTING_AZURITE"]
key = SecureRandom.base58(24)
data = "Something else entirely!"

Expand All @@ -39,7 +40,7 @@ module ActiveStorage::Service::SharedServiceTests

assert_not @service.exist?(key)
ensure
@service.delete key
@service.delete key unless ENV["TESTING_AZURITE"]
end

test "uploading with integrity and multiple keys" do
Expand Down
20 changes: 20 additions & 0 deletions test/support/azurite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
require "open3"
require "shellwords"

class Azurite
def initialize(verbose: false)
@verbose = verbose
stdin, stdout, @wait_thread = Open3.popen2e("azurite")
stdout.each do |line|
break if line.include?("Azurite Blob service is successfully listening at http://127.0.0.1:10000")
end
end

def kill
Process.kill("INT", wait_thread.pid)
end

private

attr_reader :wait_thread
end