cumulusci-plus 5.0.25__py3-none-any.whl → 5.0.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cumulusci-plus might be problematic. Click here for more details.

Files changed (37) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/tests/test_error.py +3 -1
  3. cumulusci/core/github.py +1 -1
  4. cumulusci/core/sfdx.py +3 -1
  5. cumulusci/cumulusci.yml +8 -0
  6. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  7. cumulusci/salesforce_api/rest_deploy.py +1 -1
  8. cumulusci/tasks/apex/anon.py +1 -1
  9. cumulusci/tasks/apex/testrunner.py +6 -1
  10. cumulusci/tasks/bulkdata/extract.py +0 -1
  11. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  12. cumulusci/tasks/bulkdata/tests/test_select_utils.py +6 -0
  13. cumulusci/tasks/metadata_etl/base.py +7 -3
  14. cumulusci/tasks/push/README.md +15 -17
  15. cumulusci/tasks/release_notes/README.md +13 -13
  16. cumulusci/tasks/robotframework/tests/test_robotframework.py +1 -1
  17. cumulusci/tasks/salesforce/Deploy.py +5 -1
  18. cumulusci/tasks/salesforce/composite.py +1 -1
  19. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  20. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  21. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  22. cumulusci/tasks/salesforce/update_profile.py +17 -13
  23. cumulusci/tasks/salesforce/users/permsets.py +16 -9
  24. cumulusci/tasks/utility/credentialManager.py +256 -0
  25. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  26. cumulusci/tasks/utility/secretsToEnv.py +130 -0
  27. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  28. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  29. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  30. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  31. cumulusci/utils/yaml/tests/test_model_parser.py +2 -2
  32. {cumulusci_plus-5.0.25.dist-info → cumulusci_plus-5.0.26.dist-info}/METADATA +7 -9
  33. {cumulusci_plus-5.0.25.dist-info → cumulusci_plus-5.0.26.dist-info}/RECORD +37 -31
  34. {cumulusci_plus-5.0.25.dist-info → cumulusci_plus-5.0.26.dist-info}/WHEEL +0 -0
  35. {cumulusci_plus-5.0.25.dist-info → cumulusci_plus-5.0.26.dist-info}/entry_points.txt +0 -0
  36. {cumulusci_plus-5.0.25.dist-info → cumulusci_plus-5.0.26.dist-info}/licenses/AUTHORS.rst +0 -0
  37. {cumulusci_plus-5.0.25.dist-info → cumulusci_plus-5.0.26.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,256 @@
1
+ import logging
2
+ import os
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Optional
5
+
6
+
7
+ # Abstract Base Class for Credential Providers
8
+ class CredentialProvider(ABC):
9
+ """
10
+ Abstract Base Class that defines the interface for all credential providers.
11
+ """
12
+
13
+ # A class-level dictionary to store a mapping of provider names to provider classes
14
+ _registry = {}
15
+ logger: logging.Logger
16
+
17
+ def __init__(self, **kwargs):
18
+ self.key_prefix = kwargs.get(
19
+ "key_prefix", os.getenv("CUMULUSCI_PREFIX_SECRETS", "").upper()
20
+ )
21
+ self.logger = logging.getLogger(__name__)
22
+
23
+ def __init_subclass__(cls, **kwargs):
24
+ """
25
+ This method is called automatically when a new class inherits from CredentialProvider.
26
+ It's used to register the new class in our registry.
27
+ """
28
+ super().__init_subclass__(**kwargs)
29
+ # We'll use an attribute on each class to define its provider type.
30
+ # This will be the key in our registry.
31
+ if hasattr(cls, "provider_type"):
32
+ CredentialProvider._registry[cls.provider_type] = cls
33
+
34
+ @abstractmethod
35
+ def get_credentials(
36
+ self, key: str, options: Optional[dict[str, Any]] = None
37
+ ) -> Any:
38
+ """
39
+ Retrieves and returns credentials.
40
+ """
41
+ raise NotImplementedError("Subclasses must implement this method")
42
+
43
+ @abstractmethod
44
+ def get_all_credentials(
45
+ self, key: str, options: Optional[dict[str, Any]] = None
46
+ ) -> Any:
47
+ """
48
+ Retrieves and returns all credentials in a group, for example all secrets in AWS secret.
49
+ """
50
+ raise NotImplementedError("Subclasses must implement this method")
51
+
52
+ def get_key(self, key: str) -> str:
53
+ return f"{self.key_prefix}{key}"
54
+
55
+
56
+ # Concrete Credential Provider for Local Development
57
+ class DevEnvironmentVariableProvider(CredentialProvider):
58
+ """
59
+ Retrieves secrets from environment variables.
60
+ This is suitable for local development.
61
+ """
62
+
63
+ provider_type = "local"
64
+
65
+ def get_credentials(
66
+ self, key: str, options: Optional[dict[str, Any]] = None
67
+ ) -> Any:
68
+ value = options.get("value", None)
69
+ self.logger.info(f"Credentials for {key} from local environment is {value}.")
70
+ return value
71
+
72
+ def get_all_credentials(
73
+ self, key: str, options: Optional[dict[str, Any]] = None
74
+ ) -> Any:
75
+ """Local provider doesn't support retrieving all credentials."""
76
+ raise NotImplementedError("Local provider doesn't support get_all_credentials")
77
+
78
+
79
+ # Concrete Credential Provider for Local Development
80
+ class EnvironmentVariableProvider(CredentialProvider):
81
+ """
82
+ Retrieves secrets from environment variables.
83
+ This is suitable for local development.
84
+ """
85
+
86
+ provider_type = "environment"
87
+
88
+ def get_credentials(
89
+ self, key: str, options: Optional[dict[str, Any]] = None
90
+ ) -> Any:
91
+ value = options.get("value", None)
92
+ ret_value = os.getenv(self.get_key(key))
93
+ if ret_value is None:
94
+ self.logger.info(f"Credentials for {key} from environment is {value}.")
95
+ return ret_value or value
96
+
97
+ def get_all_credentials(
98
+ self, key: str, options: Optional[dict[str, Any]] = None
99
+ ) -> Any:
100
+ """Environment provider doesn't support retrieving all credentials."""
101
+ raise NotImplementedError(
102
+ "Environment provider doesn't support get_all_credentials"
103
+ )
104
+
105
+
106
+ # Concrete Credential Provider for Azure Pipelines
107
+ class AwsSecretsManagerProvider(CredentialProvider):
108
+ """
109
+ Retrieves secrets from AWS Secrets Manager.
110
+ This is designed for use in a CI/CD environment like Azure Pipelines,
111
+ where a Service Connection provides a role to assume.
112
+ """
113
+
114
+ provider_type = "aws_secrets"
115
+ secrets_cache: dict[str, dict[str, Any]]
116
+ aws_region: str
117
+
118
+ def __init__(self, **kwargs):
119
+ super().__init__(**kwargs)
120
+ self.secrets_cache = kwargs.get("secrets_cache", {})
121
+
122
+ self.aws_region = kwargs.get("aws_region", os.getenv("AWS_REGION", None))
123
+ if self.aws_region is None:
124
+ raise ValueError(
125
+ "AWS_REGION environment variable or aws_region option is required for AWS Secrets Manager."
126
+ )
127
+
128
+ def get_credentials(
129
+ self, key: str, options: Optional[dict[str, Any]] = None
130
+ ) -> Any:
131
+ """
132
+ Connects to AWS Secrets Manager to retrieve a secret.
133
+ The boto3 client automatically uses the credentials provided by the
134
+ Azure DevOps AWS Service Connection (e.g., through OIDC or static keys).
135
+ """
136
+ return self.aws_creds(key, options).get(key, None)
137
+
138
+ def get_all_credentials(
139
+ self, key: str, options: Optional[dict[str, Any]] = None
140
+ ) -> Any:
141
+ return self.aws_creds(key, options)
142
+
143
+ def aws_creds(
144
+ self, key: str, options: Optional[dict[str, Any]] = None
145
+ ) -> dict[str, Any]:
146
+ secret_name = options.get("secret_name", None)
147
+ if secret_name is None:
148
+ raise ValueError("Secret name is required for AWS Secrets Manager.")
149
+
150
+ try:
151
+ if secret_name in self.secrets_cache:
152
+ return self.secrets_cache[secret_name]
153
+
154
+ import json
155
+
156
+ import boto3
157
+ from botocore.exceptions import ClientError
158
+
159
+ # Boto3 automatically handles credential lookup. In an Azure Pipeline,
160
+ # it will find the temporary credentials provided by the AWS Service Connection.
161
+ # Create a Secrets Manager client
162
+ session = boto3.session.Session()
163
+ client = session.client(
164
+ service_name="secretsmanager", region_name=self.aws_region
165
+ )
166
+
167
+ get_secret_value_response = client.get_secret_value(SecretId=secret_name)
168
+
169
+ secret = get_secret_value_response["SecretString"]
170
+
171
+ # Assuming the secret is a JSON string with the credentials
172
+ # We need to check the binary type of the secret at later development
173
+ self.secrets_cache[secret_name] = json.loads(secret)
174
+
175
+ return self.secrets_cache[secret_name]
176
+ except ClientError as e:
177
+ # For a list of exceptions thrown, see
178
+ # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
179
+ raise e
180
+ except ImportError:
181
+ raise RuntimeError(
182
+ "boto3 is not installed. Please install it using 'pip install boto3' or 'pipx inject cumulusci-plus-azure-devops boto3'."
183
+ )
184
+ except Exception as e:
185
+ raise RuntimeError(f"Failed to retrieve secret '{key}': {e}")
186
+
187
+
188
+ # Concrete Credential Provider for Azure Variable Groups
189
+ class AzureVariableGroupProvider(CredentialProvider):
190
+ """
191
+ Retrieves secrets from Azure Pipeline Variable Groups.
192
+ When a variable group is linked, its variables are exposed as
193
+ environment variables in the pipeline job.
194
+ """
195
+
196
+ provider_type = "ado_variables"
197
+
198
+ def get_credentials(
199
+ self, key: str, options: Optional[dict[str, Any]] = None
200
+ ) -> Any:
201
+ """
202
+ Looks for AWS credentials in environment variables exposed by
203
+ an Azure variable group.
204
+ """
205
+ # Azure pipelines convert variable names to uppercase and replace dots with underscores.
206
+ key_env_var = self.get_key(key)
207
+ return os.getenv(key_env_var.upper().replace(".", "_"))
208
+
209
+ def get_all_credentials(
210
+ self, key: str, options: Optional[dict[str, Any]] = None
211
+ ) -> Any:
212
+ """Azure variable group provider doesn't support retrieving all credentials."""
213
+ raise NotImplementedError(
214
+ "Azure variable group provider doesn't support get_all_credentials"
215
+ )
216
+
217
+
218
+ # The CredentialManager to select the right provider
219
+ class CredentialManager:
220
+ """
221
+ Factory class to determine and return the correct CredentialProvider based on the environment.
222
+ """
223
+
224
+ env_secrets_type = "CUMULUSCI_SECRETS_TYPE"
225
+
226
+ @staticmethod
227
+ def _load_secrets_type_from_environment() -> str:
228
+ """Load any secrets specified by environment variables"""
229
+ provider_type = os.getenv(CredentialManager.env_secrets_type)
230
+
231
+ # If no provider type is found, use the dev provider
232
+ return (provider_type or "local").lower()
233
+
234
+ @staticmethod
235
+ def get_provider(
236
+ provider_type: Optional[str] = None, **kwargs
237
+ ) -> CredentialProvider:
238
+ """
239
+ Looks up the provider class in the registry and returns an instance.
240
+ """
241
+ if not provider_type:
242
+ # If no config is provided, load the secrets from the environment
243
+ provider_type = CredentialManager._load_secrets_type_from_environment()
244
+
245
+ # Check if the requested provider type exists in our registry
246
+ if provider_type not in CredentialProvider._registry:
247
+ raise ValueError(f"Unknown provider type specified: '{provider_type}'")
248
+
249
+ # Get the class from the registry
250
+ ProviderClass = CredentialProvider._registry[provider_type]
251
+ provider = ProviderClass(**kwargs)
252
+ provider.logger.info(
253
+ f'Using "{provider.provider_type}" provider for credentials.'
254
+ )
255
+
256
+ return provider
@@ -0,0 +1,30 @@
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ from cumulusci.core.exceptions import TaskOptionsError
6
+ from cumulusci.core.tasks import BaseTask
7
+ from cumulusci.utils.options import CCIOptions, Field
8
+
9
+
10
+ class DirectoryRecreator(BaseTask):
11
+ class Options(CCIOptions):
12
+ path: Path = Field(..., description="Path to the directory to recreate")
13
+
14
+ parsed_options: Options
15
+
16
+ def _init_options(self, kwargs):
17
+ super()._init_options(kwargs)
18
+
19
+ if os.path.isfile(self.parsed_options.path):
20
+ raise TaskOptionsError(f"Path {self.parsed_options.path} is a file")
21
+
22
+ def _run_task(self):
23
+ created = "created"
24
+ if os.path.exists(self.parsed_options.path):
25
+ shutil.rmtree(self.parsed_options.path)
26
+ created = "removed and created"
27
+
28
+ os.makedirs(self.parsed_options.path, exist_ok=True)
29
+
30
+ self.logger.info(f"Directory {self.parsed_options.path} {created}.")
@@ -0,0 +1,130 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Union
4
+
5
+ from dotenv import dotenv_values
6
+
7
+ from cumulusci.core.tasks import BaseTask
8
+ from cumulusci.utils.options import (
9
+ CCIOptions,
10
+ Field,
11
+ ListOfStringsOption,
12
+ MappingOption,
13
+ )
14
+
15
+ from .credentialManager import CredentialManager
16
+
17
+
18
+ class SecretsToEnv(BaseTask):
19
+ class Options(CCIOptions):
20
+ env_path: Path = Field("./.env", description="Path to the .env file")
21
+ secrets_provider: str = Field(
22
+ "local",
23
+ description="Secrets provider type i.e local, ado_variables, aws_secrets. Defaults to 'local'",
24
+ )
25
+ provider_options: MappingOption = Field({}, description="Provider options")
26
+ secrets: Union[ListOfStringsOption, MappingOption] = Field(
27
+ [],
28
+ description="List of secret keys to retrieve be it with a list of keys or a mapping of secret name to key. Defaults to empty list.",
29
+ )
30
+
31
+ parsed_options: Options
32
+
33
+ def _init_options(self, kwargs):
34
+ super()._init_options(kwargs)
35
+ self.provider = CredentialManager.get_provider(
36
+ self.parsed_options.secrets_provider, **self.parsed_options.provider_options
37
+ )
38
+ self.env_values = dotenv_values(self.parsed_options.env_path)
39
+
40
+ def _init_secrets(self):
41
+ if (
42
+ isinstance(self.parsed_options.secrets, list)
43
+ and self.parsed_options.secrets
44
+ ):
45
+ try:
46
+ self.secrets = MappingOption.from_str(
47
+ ",".join(self.parsed_options.secrets)
48
+ )
49
+ except Exception:
50
+ self.secrets = {
51
+ secret: secret for secret in self.parsed_options.secrets
52
+ }
53
+ elif isinstance(self.parsed_options.secrets, dict):
54
+ self.secrets = self.parsed_options.secrets
55
+ else:
56
+ self.secrets = {}
57
+
58
+ def _run_task(self):
59
+
60
+ self._init_secrets()
61
+
62
+ output_file = self.parsed_options.env_path
63
+
64
+ for secret_key, secret_name in self.secrets.items():
65
+ if secret_key == "*":
66
+ self.env_values.update(
67
+ self._get_all_credentials(secret_key, secret_name=secret_name)
68
+ )
69
+ else:
70
+ safe_value, _ = self._get_credential(
71
+ secret_key, secret_key, secret_name=secret_name
72
+ )
73
+ self.env_values[secret_key] = safe_value
74
+
75
+ # Ensure output directory exists
76
+ os.makedirs(os.path.dirname(output_file) or ".", exist_ok=True)
77
+
78
+ # Write env file with UTF-8 encoding to support Unicode characters
79
+ with open(output_file, "w", encoding="utf-8") as env_file:
80
+ for key, value in self.env_values.items():
81
+ env_file.write(f'{key}="{value}"\n')
82
+
83
+ def _get_credential(
84
+ self,
85
+ credential_key,
86
+ value,
87
+ env_key=None,
88
+ display_value="*****",
89
+ secret_name=None,
90
+ ):
91
+ if env_key is None:
92
+ env_key = credential_key
93
+
94
+ cred_secret_value = self.provider.get_credentials(
95
+ credential_key, {"value": value, "secret_name": secret_name}
96
+ )
97
+
98
+ if cred_secret_value is None:
99
+ raise ValueError(
100
+ f"Failed to retrieve secret {credential_key} from {self.provider.provider_type}"
101
+ )
102
+
103
+ self.logger.info(
104
+ f"Set secrets env var from {self.provider.provider_type}: {env_key}={display_value}"
105
+ )
106
+ safe_value = cred_secret_value.replace('"', '\\"').replace("\n", "\\n")
107
+ return safe_value, cred_secret_value
108
+
109
+ def _get_all_credentials(
110
+ self, credential_key, display_value="*****", secret_name=None
111
+ ):
112
+
113
+ cred_secret_values = self.provider.get_all_credentials(
114
+ credential_key, {"secret_name": secret_name}
115
+ )
116
+
117
+ if cred_secret_values is None:
118
+ raise ValueError(
119
+ f"Failed to retrieve secret {credential_key}({secret_name}) from {self.provider.provider_type}"
120
+ )
121
+
122
+ dict_values = {}
123
+ for key, value in cred_secret_values.items():
124
+ self.logger.info(
125
+ f"Set secrets env var from {self.provider.provider_type}: {key}={display_value}"
126
+ )
127
+ safe_value = value.replace('"', '\\"').replace("\n", "\\n")
128
+ dict_values[key] = safe_value
129
+
130
+ return dict_values