From 2497592359a8086213bba529281e4f1740167b7e Mon Sep 17 00:00:00 2001 From: momo Date: Fri, 30 Oct 2026 13:44:52 +0100 Subject: [PATCH 1/3] Add Azure Blob storage backend --- docker-compose.yml | 6 ++ pyproject.toml | 2 + src/backend/supportagent/storage/service.py | 32 +++++++ .../storage/test_azure_blob_storage.py | 71 ++++++++++++++++ uv.lock | 83 +++++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 tests/integration/storage/test_azure_blob_storage.py diff --git a/docker-compose.yml b/docker-compose.yml index 3d9e069..9e9544e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: . diff --git a/pyproject.toml b/pyproject.toml index b3e538c..ffad4bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/backend/supportagent/storage/service.py b/src/backend/supportagent/storage/service.py index a68a31e..1164b09 100644 --- a/src/backend/supportagent/storage/service.py +++ b/src/backend/supportagent/storage/service.py @@ -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 @@ -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": diff --git a/tests/integration/storage/test_azure_blob_storage.py b/tests/integration/storage/test_azure_blob_storage.py new file mode 100644 index 0000000..a92f09e --- /dev/null +++ b/tests/integration/storage/test_azure_blob_storage.py @@ -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) diff --git a/uv.lock b/uv.lock index 7a46914..2a67f09 100644 --- a/uv.lock +++ b/uv.lock @@ -81,6 +81,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/33/0b881d0093d64c965bb20fcf71b806c7097881b544a2a55d519cdcf3bab9/azure_identity-1.27.0.tar.gz", hash = "sha256:6a4c50e25e8e9a47b324a95ad024897e56cb3c6241e0094e93b126a2bf71edc5", size = 331360, upload-time = "2026-10-26T21:17:39.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/1e/6a2fe486576f41884a120ef7b2c3774f334f9143402fb73650c67db94ddd/azure_identity-1.27.0-py3-none-any.whl", hash = "sha256:92fd2a93c0d2f777dac6c04e5fd7d5734f6643582b983544ef66b4575844e46d", size = 203474, upload-time = "2026-10-26T21:17:40.92Z" }, +] + +[[package]] +name = "azure-storage-blob" +version = "12.31.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/18/eb2ade6eede419d33493016ec3243ca9860f67d0bc71aa0461cda2cad0c4/azure_storage_blob-12.31.1.tar.gz", hash = "sha256:0d6e5b860a1357efc602db04cfa7c9950ac86126edcf7db89320cde281555fe5", size = 672417, upload-time = "2026-10-29T23:34:05.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/93/b9f0dc477827887aef64012946bdba65e9aad67084ad38044661a72fe47d/azure_storage_blob-12.31.1-py3-none-any.whl", hash = "sha256:9e012d5f4ab658b7cb8a4581329d7a7aa2bf5f39b4d63baeefaae7f9e90fe6f0", size = 455904, upload-time = "2026-10-29T23:34:07.459Z" }, +] + [[package]] name = "backoff" version = "2.2.1" @@ -527,6 +571,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + [[package]] name = "jiter" version = "0.15.0" @@ -1016,6 +1069,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] +[[package]] +name = "msal" +version = "1.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/85/747d28986a44b715cc51cf57aa021c8fd94e9614064261ceec77320e8b0b/msal-1.39.0.tar.gz", hash = "sha256:6ab7de335e6d7f5717e2c7e1dbf86e4dda2f6acf3c56773b78dc53ebc6395b5f", size = 205360, upload-time = "2026-09-17T16:07:45.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/32/178457385925ce301c7b3232bd85665d4df57da39568b62dcfc74031873f/msal-1.39.0-py3-none-any.whl", hash = "sha256:2d2577886906cd7293850dffa2da29119966c213bfc6ec0cecf8bf7621e1ca77", size = 131629, upload-time = "2026-09-17T16:07:46.722Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -2158,6 +2237,8 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "anthropic" }, + { name = "azure-identity" }, + { name = "azure-storage-blob" }, { name = "boto3" }, { name = "boto3-stubs" }, { name = "fastapi" }, @@ -2189,6 +2270,8 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.20.0" }, { name = "anthropic" }, + { name = "azure-identity", specifier = ">=1.27.0" }, + { name = "azure-storage-blob", specifier = ">=12.31.1" }, { name = "boto3" }, { name = "boto3-stubs", specifier = "~=1.43.54" }, { name = "fastapi" }, From 4c7d8263dedec199a4a374ec5d5c3d93574d5f72 Mon Sep 17 00:00:00 2001 From: momo Date: Fri, 30 Oct 2026 13:44:56 +0100 Subject: [PATCH 2/3] Run storage tests against Azurite in CI --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11dc4fb..d482c3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,10 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 + azurite: + image: mcr.microsoft.com/azure-storage/azurite:3.34.0 + ports: + - 10000:10000 env: CHAT_MODEL: qwen-plus @@ -56,7 +60,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: | From 14bf9a31676e212a8ec454d5f2799bcf4c9784e2 Mon Sep 17 00:00:00 2001 From: momo Date: Fri, 30 Oct 2026 14:02:23 +0100 Subject: [PATCH 3/3] Start Azurite with API version check disabled in CI --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d482c3f..e904d36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,6 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 - azurite: - image: mcr.microsoft.com/azure-storage/azurite:3.34.0 - ports: - - 10000:10000 env: CHAT_MODEL: qwen-plus @@ -43,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: