Repository: meltano/meltano

Issue:
bug: S3StateStoreManager cannot delete state when state_backend.s3.aws_* settings are not present
### Meltano Version

2.11.1

### Python Version

3.10

### Bug scope

API

### Operating System

meltano/meltano:v2.11.1 image in ECS fargate

### Description

When no `aws_secret_access_key` or `aws_access_key_id` settings are present in a Meltano project, the `S3StateStoreManager.client` attribute does not get initialized. Currently this is a rare circumstance, since the relevant `AWS_*` environment variables are aliased to the Meltano settings. However, there are some circumstances (e.g. running in ECS) where we should still fallback to boto3's internal logic for initiating a client (we already do this for reading and writing objects, but listing and deleting objects require a standalone client outside of the read/write logic). Once https://github.com/meltano/meltano/pull/7078 is released though, this will potentially cause more issues for end users that are directly using the `AWS_*` environment variables for S3 state backend configuration.

### Code

_No response_


Discussion:

Resolving patch:
diff --git a/src/meltano/core/state_store/filesystem.py b/src/meltano/core/state_store/filesystem.py
--- a/src/meltano/core/state_store/filesystem.py
+++ b/src/meltano/core/state_store/filesystem.py
@@ -25,7 +25,7 @@
 logger = logging.getLogger(__name__)
 
 
-class InvalidStateBackendConfigurationException:
+class InvalidStateBackendConfigurationException(Exception):
     """Occurs when state backend configuration is invalid."""
 
 
diff --git a/src/meltano/core/state_store/s3.py b/src/meltano/core/state_store/s3.py
--- a/src/meltano/core/state_store/s3.py
+++ b/src/meltano/core/state_store/s3.py
@@ -2,12 +2,45 @@
 from __future__ import annotations
 
 import re
+from contextlib import contextmanager
+
+from cached_property import cached_property  # type: ignore
 
 from meltano.core.state_store.filesystem import (
     BaseFilesystemStateStoreManager,
     InvalidStateBackendConfigurationException,
 )
 
+try:
+    import boto3
+except ImportError:
+    boto3 = None  # type: ignore
+
+
+class MissingBoto3Error(Exception):
+    """Raised when boto3 is required but not installed."""
+
+    def __init__(self):
+        """Initialize a MissingBoto3Error."""
+        super().__init__(
+            "boto3 required but not installed. Install meltano[s3] to use S3 as a state backend.",  # noqa: E501
+        )
+
+
+@contextmanager
+def requires_boto3():
+    """Raise MissingBoto3Error if boto3 is required but missing in context.
+
+    Raises:
+        MissingBoto3Error: if boto3 is not installed.
+
+    Yields:
+        None
+    """
+    if not boto3:
+        raise MissingBoto3Error()
+    yield
+
 
 class S3StateStoreManager(BaseFilesystemStateStoreManager):
     """State backend for S3."""
@@ -39,7 +72,6 @@ def __init__(
         self.bucket = bucket or self.parsed.hostname
         self.prefix = prefix or self.parsed.path
         self.endpoint_url = endpoint_url
-        self._client = None
 
     @staticmethod
     def is_file_not_found_error(err: Exception) -> bool:
@@ -57,7 +89,8 @@ def is_file_not_found_error(err: Exception) -> bool:
             or (isinstance(err, KeyError) and "ActualObjectSize" in err.args[0])
         )
 
-    @property
+    @cached_property
+    @requires_boto3()
     def client(self):
         """Get an authenticated boto3.Client.
 
@@ -67,24 +100,22 @@ def client(self):
         Raises:
             InvalidStateBackendConfigurationException: when configured AWS settings are invalid.
         """
-        if not self._client:
-            if self.aws_secret_access_key and self.aws_access_key_id:
-                from boto3 import Session
-
-                session = Session(
-                    aws_access_key_id=self.aws_access_key_id,
-                    aws_secret_access_key=self.aws_secret_access_key,
-                )
-                self._client = session.client("s3", endpoint_url=self.endpoint_url)
-            elif self.aws_secret_access_key and not self.aws_access_key_id:
-                raise InvalidStateBackendConfigurationException(
-                    "AWS secret access key configured, but not AWS access key ID."
-                )
-            elif self.aws_access_key_id and not self.aws_secret_access_key:
-                raise InvalidStateBackendConfigurationException(
-                    "AWS access key ID configured, but no AWS secret access key."
-                )
-        return self._client
+        if self.aws_secret_access_key and self.aws_access_key_id:
+            session = boto3.Session(
+                aws_access_key_id=self.aws_access_key_id,
+                aws_secret_access_key=self.aws_secret_access_key,
+            )
+            return session.client("s3", endpoint_url=self.endpoint_url)
+        elif self.aws_secret_access_key and not self.aws_access_key_id:
+            raise InvalidStateBackendConfigurationException(
+                "AWS secret access key configured, but not AWS access key ID."
+            )
+        elif self.aws_access_key_id and not self.aws_secret_access_key:
+            raise InvalidStateBackendConfigurationException(
+                "AWS access key ID configured, but no AWS secret access key."
+            )
+        session = boto3.Session()
+        return session.client("s3")
 
     @property
     def state_dir(self) -> str:


Test patch: