Skip to content
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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Start Azurite
run: docker run -d -p 10000:10000 mcr.microsoft.com/azure-storage/azurite:3.34.0 azurite-blob --blobHost 0.0.0.0 --skipApiVersionCheck

- name: Set up Python
uses: actions/setup-python@v5
with:
Expand All @@ -56,7 +59,7 @@ jobs:
run: python -m pytest

- name: Run integration tests
run: python -m pytest tests/integration --run-integration --deselect tests/integration/storage
run: python -m pytest tests/integration --run-integration --deselect tests/integration/storage/test_supabase_storage_integration.py

- name: Run offline claim evaluation
run: |
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ services:
volumes:
- pgdata:/var/lib/postgresql/data

azurite:
image: mcr.microsoft.com/azure-storage/azurite:3.34.0
command: azurite-blob --blobHost 0.0.0.0 --skipApiVersionCheck
ports:
- "10000:10000"

backend:
build:
context: .
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ dependencies = [
"boto3-stubs~=1.43.54",
"sqlalchemy>=2.0.54",
"alembic>=1.20.0",
"azure-storage-blob>=12.31.1",
"azure-identity>=1.27.0",
]

[project.optional-dependencies]
Expand Down
32 changes: 32 additions & 0 deletions src/backend/supportagent/storage/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from urllib.parse import quote

import requests
from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient, ContentSettings

try:
import boto3
Expand Down Expand Up @@ -141,10 +143,40 @@ def get_object(self, bucket: str, key: str) -> bytes:
return response["Body"].read()


class AzureBlobObjectStorage(ObjectStorage):
provider = "azure"

def __init__(self) -> None:
self.bucket = os.environ.get("AZURE_STORAGE_CONTAINER", "uploads")
connection_string = os.environ.get("AZURE_STORAGE_CONNECTION_STRING")
if connection_string:
# Local development and tests use Azurite.
self.client = BlobServiceClient.from_connection_string(connection_string)
else:
# In Azure, sign in as the Container App's managed identity, so there is no key to leak.
self.client = BlobServiceClient(
account_url=os.environ["AZURE_STORAGE_ACCOUNT_URL"],
credential=DefaultAzureCredential(),
)

def put_object(self, key: str, content: bytes, content_type: str) -> StoredObject:
self.client.get_blob_client(self.bucket, key).upload_blob(
content,
overwrite=True,
content_settings=ContentSettings(content_type=content_type),
)
return StoredObject(provider=self.provider, bucket=self.bucket, key=key)

def get_object(self, bucket: str, key: str) -> bytes:
return self.client.get_blob_client(bucket, key).download_blob().readall()


def get_storage(provider: str | None = None) -> ObjectStorage:
provider = (provider or os.environ.get("FILE_STORAGE_PROVIDER", "local")).lower()
if provider == "supabase":
return SupabaseObjectStorage()
if provider == "azure":
return AzureBlobObjectStorage()
if provider in {"s3", "minio"}:
return S3ObjectStorage()
if provider == "local":
Expand Down
71 changes: 71 additions & 0 deletions tests/integration/storage/test_azure_blob_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from uuid import uuid4

import pytest
from azure.core.exceptions import ResourceNotFoundError
from azure.storage.blob import BlobServiceClient

from supportagent.storage.service import AzureBlobObjectStorage, get_storage

# Azurite's well-known development account. It's public and documented by Microsoft, not a secret.
AZURITE_CONNECTION_STRING = (
"DefaultEndpointsProtocol=http;"
"AccountName=devstoreaccount1;"
"AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;"
"BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
)


@pytest.fixture
def azure_container(monkeypatch):
"""Create a fresh container in Azurite for each test and delete it afterwards."""
name = f"test-{uuid4().hex}"
client = BlobServiceClient.from_connection_string(AZURITE_CONNECTION_STRING)
client.create_container(name)
monkeypatch.setenv("AZURE_STORAGE_CONNECTION_STRING", AZURITE_CONNECTION_STRING)
monkeypatch.setenv("AZURE_STORAGE_CONTAINER", name)
yield client.get_container_client(name)
client.delete_container(name)


def test_put_then_get_returns_same_bytes(azure_container):
storage = AzureBlobObjectStorage()

stored = storage.put_object(
key="users/u1/a.png", content=b"png-bytes", content_type="image/png"
)

assert stored.provider == "azure"
assert stored.bucket == azure_container.container_name
assert stored.key == "users/u1/a.png"
assert storage.get_object(bucket=stored.bucket, key=stored.key) == b"png-bytes"


def test_put_sets_content_type(azure_container):
storage = AzureBlobObjectStorage()

storage.put_object(key="a.png", content=b"x", content_type="image/png")

props = azure_container.get_blob_client("a.png").get_blob_properties()
assert props.content_settings.content_type == "image/png"


def test_put_same_key_overwrites(azure_container):
storage = AzureBlobObjectStorage()

storage.put_object(key="a.png", content=b"old", content_type="image/png")
stored = storage.put_object(key="a.png", content=b"new", content_type="image/png")

assert storage.get_object(bucket=stored.bucket, key="a.png") == b"new"


def test_get_missing_key_raises(azure_container):
storage = AzureBlobObjectStorage()

with pytest.raises(ResourceNotFoundError):
storage.get_object(
bucket=azure_container.container_name, key="does-not-exist.png"
)


def test_get_storage_returns_azure_backend(azure_container):
assert isinstance(get_storage("azure"), AzureBlobObjectStorage)
83 changes: 83 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading