Repository: meltano/meltano

Issue:
feature: Support authenticating to Azure state backend without a connection string
### Feature scope

State backend

### Description

https://learn.microsoft.com/en-us/azure/storage/blobs/storage-quickstart-blobs-python?tabs=managed-identity%2Croles-azure-portal%2Csign-in-azure-cli#sign-in-and-connect-your-app-code-to-azure-using-defaultazurecredential

See request in

* Slack: https://meltano.slack.com/archives/CMN8HELB0/p1692386533863739?thread_ts=1692366917.319929&cid=CMN8HELB0
* Linen: https://www.linen.dev/s/meltano/t/15516560/hey-i-m-a-bit-confused-how-to-set-the-state-id-when-using-me#d65c3bd0-9e62-48dd-a616-90d4d7246d33


Discussion:
@edgarrmondragon can I pick this up?
@XshubhamX go for it 😄
from @XshubhamX:

> 1. So right now when the client function https://github.com/meltano/meltano/blob/main/src/meltano/core/state_store/azure.py#L97 is called we just return a new BlobServiceClient if user has configured the connection string.
> 2. Now if we want to use the default credentials we can use the DefaultAzureCredential() funtion offered by the azure library.
> 3. And after getting these credentials we can initiate a new isntance BlobServiceClient using the credentials that we just fetched in above step and the account_url.
> 4. account_url = “https://<storageaccountname>.[blob.core.windows.net](http://blob.core.windows.net/)”
> 5. But he issue is that here also we’d require the user to configure the account_url which would basically mean that we need to have another state variable like `state_backend.azure.account_name`  and we’d allow user to configure this.

And some additional requirements:

1. document that leaving `state_backend.azure.account_name` unset will cause Meltano to try to use `state_backend.azure.connection_string`
2. give precedence to `DefaultAzureCredential()` and `state_backend.azure.account_name`,
3. and link to the official docs for instructions on where to find the account name


Resolving patch:
diff --git a/src/meltano/core/state_store/azure.py b/src/meltano/core/state_store/azure.py
--- a/src/meltano/core/state_store/azure.py
+++ b/src/meltano/core/state_store/azure.py
@@ -52,6 +52,7 @@ def __init__(
         self,
         connection_string: str | None = None,
         prefix: str | None = None,
+        storage_account_url: str | None = None,
         **kwargs,
     ):
         """Initialize the BaseFilesystemStateStoreManager.
@@ -59,6 +60,7 @@ def __init__(
         Args:
             connection_string: connection string to use in authenticating to Azure
             prefix: the prefix to store state at
+            storage_account_url: url of the azure stroga account
             kwargs: additional keyword args to pass to parent
 
         Raises:
@@ -66,6 +68,7 @@ def __init__(
         """
         super().__init__(**kwargs)
         self.connection_string = connection_string
+        self.storage_account_url = storage_account_url
 
         if not self.parsed.hostname:
             raise MeltanoError(
@@ -104,11 +107,21 @@ def client(self) -> BlobServiceClient:
             MeltanoError: If connection string is not provided.
         """
         with requires_azure():
+            if self.storage_account_url:
+                from azure.identity import DefaultAzureCredential
+
+                default_credential = DefaultAzureCredential()
+                return BlobServiceClient(
+                    self.storage_account_url,
+                    credential=default_credential,
+                )
+
             if self.connection_string:
                 return BlobServiceClient.from_connection_string(self.connection_string)
 
             raise MeltanoError(
-                "Azure state backend requires a connection string",
+                "Azure state backend requires a connection string "
+                "or an account URL to use host credentials",
                 "Read https://learn.microsoft.com/en-us/azure/storage/common/storage-configure-connection-string for more information.",  # noqa: E501
             )
 


Test patch:
diff --git a/tests/meltano/core/state_store/test_state_store.py b/tests/meltano/core/state_store/test_state_store.py
--- a/tests/meltano/core/state_store/test_state_store.py
+++ b/tests/meltano/core/state_store/test_state_store.py
@@ -4,6 +4,7 @@
 from pathlib import Path
 
 import pytest
+from azure.storage.blob import BlobServiceClient
 
 from meltano.core.error import MeltanoError
 from meltano.core.project import Project
@@ -49,6 +50,10 @@ def test_state_store_manager_from_project_settings(project: Project, state_path:
         ["state_backend", "azure", "connection_string"],
         "SOME_CONNECTION_STRING",
     )
+    project.settings.set(
+        ["state_backend", "azure", "storage_account_url"],
+        "SOME_STORAGE_ACCOUNT_URL",
+    )
     az_state_store: AZStorageStateStoreManager = (
         state_store_manager_from_project_settings(project.settings)
     )
@@ -56,22 +61,41 @@ def test_state_store_manager_from_project_settings(project: Project, state_path:
     assert az_state_store.container_name == "some_container"
     assert az_state_store.prefix == "/some/path"
     assert az_state_store.connection_string == "SOME_CONNECTION_STRING"
+    assert az_state_store.storage_account_url == "SOME_STORAGE_ACCOUNT_URL"
 
     # Azure, missing container name
     project.settings.set(["state_backend", "uri"], "azure://")
+    with pytest.raises(MeltanoError):
+        state_store_manager_from_project_settings(project.settings)
+
+    # Azure, missing connection string
+    project.settings.set(["state_backend", "uri"], "azure://some_container/some/path")
+    project.settings.unset(["state_backend", "azure", "connection_string"])
+    az_state_store: AZStorageStateStoreManager = (
+        state_store_manager_from_project_settings(project.settings)
+    )
+    # Should create client using default creds
+    assert isinstance(az_state_store.client, BlobServiceClient)
+
+    # Azure, missing storage account url
+    project.settings.unset(["state_backend", "uri", "storage_account_url"])
     project.settings.set(
         ["state_backend", "azure", "connection_string"],
         "SOME_CONNECTION_STRING",
     )
-    with pytest.raises(MeltanoError):
+    az_state_store: AZStorageStateStoreManager = (
         state_store_manager_from_project_settings(project.settings)
+    )
+    # Should create client using connection string
+    assert isinstance(az_state_store.client, BlobServiceClient)
 
-    # Azure, missing connection string
-    project.settings.set(["state_backend", "uri"], "azure://some_container/some/path")
+    # Azure, missing connection string and storage account url
     project.settings.unset(["state_backend", "azure", "connection_string"])
+    project.settings.unset(["state_backend", "azure", "storage_account_url"])
     az_state_store: AZStorageStateStoreManager = (
         state_store_manager_from_project_settings(project.settings)
     )
+    # Should raise error
     with pytest.raises(MeltanoError):
         _ = az_state_store.client  # noqa: WPS122