cumulusci-plus 5.0.35__py3-none-any.whl → 5.0.45__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.
Files changed (39) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/cci.py +3 -2
  3. cumulusci/cli/task.py +9 -10
  4. cumulusci/cli/tests/test_org.py +5 -0
  5. cumulusci/cli/tests/test_task.py +34 -0
  6. cumulusci/core/config/__init__.py +1 -0
  7. cumulusci/core/config/org_config.py +2 -1
  8. cumulusci/core/config/project_config.py +12 -0
  9. cumulusci/core/config/scratch_org_config.py +12 -0
  10. cumulusci/core/config/sfdx_org_config.py +4 -1
  11. cumulusci/core/config/tests/test_config.py +1 -0
  12. cumulusci/core/dependencies/base.py +4 -0
  13. cumulusci/cumulusci.yml +18 -1
  14. cumulusci/schema/cumulusci.jsonschema.json +5 -0
  15. cumulusci/tasks/apex/testrunner.py +7 -4
  16. cumulusci/tasks/bulkdata/tests/test_select_utils.py +20 -0
  17. cumulusci/tasks/metadata_etl/__init__.py +2 -0
  18. cumulusci/tasks/metadata_etl/applications.py +256 -0
  19. cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
  20. cumulusci/tasks/salesforce/insert_record.py +18 -19
  21. cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
  22. cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
  23. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +523 -8
  24. cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
  25. cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
  26. cumulusci/tasks/salesforce/update_external_credential.py +89 -4
  27. cumulusci/tasks/salesforce/update_record.py +217 -0
  28. cumulusci/tasks/sfdmu/sfdmu.py +14 -1
  29. cumulusci/tasks/utility/credentialManager.py +58 -12
  30. cumulusci/tasks/utility/secretsToEnv.py +42 -11
  31. cumulusci/tasks/utility/tests/test_credentialManager.py +586 -0
  32. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1240 -62
  33. cumulusci/utils/yaml/cumulusci_yml.py +1 -0
  34. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/METADATA +5 -7
  35. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/RECORD +39 -33
  36. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/WHEEL +1 -1
  37. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/entry_points.txt +0 -0
  38. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/licenses/AUTHORS.rst +0 -0
  39. {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.45.dist-info}/licenses/LICENSE +0 -0
@@ -4,8 +4,10 @@ from typing import Any, Dict, List, Optional
4
4
  from pydantic.v1 import root_validator
5
5
 
6
6
  from cumulusci.core.exceptions import SalesforceDXException
7
+ from cumulusci.core.utils import determine_managed_mode
7
8
  from cumulusci.salesforce_api.utils import get_simple_salesforce_connection
8
9
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
10
+ from cumulusci.utils import inject_namespace
9
11
  from cumulusci.utils.options import CCIOptions, Field
10
12
 
11
13
 
@@ -109,6 +111,10 @@ class ExternalCredentialParameter(CCIOptions):
109
111
  False,
110
112
  description="Is the value a secret. [default to False]",
111
113
  )
114
+ external_auth_identity_provider: str = Field(
115
+ None,
116
+ description="External auth identity provider name. [default to None]",
117
+ )
112
118
 
113
119
  @root_validator
114
120
  def check_parameters(cls, values):
@@ -125,6 +131,7 @@ class ExternalCredentialParameter(CCIOptions):
125
131
  "named_principal",
126
132
  "per_user_principal",
127
133
  "signing_certificate",
134
+ "external_auth_identity_provider",
128
135
  ]
129
136
 
130
137
  provided_params = [
@@ -157,9 +164,15 @@ class ExternalCredentialParameter(CCIOptions):
157
164
 
158
165
  if self.auth_provider is not None:
159
166
  ext_cred_param["parameterType"] = "AuthProvider"
160
- ext_cred_param["parameterValue"] = self.auth_provider
161
167
  ext_cred_param["parameterName"] = "AuthProvider"
162
- ext_cred_param["authProvider"] = ext_cred_param["parameterValue"]
168
+ ext_cred_param["authProvider"] = self.auth_provider
169
+
170
+ if self.external_auth_identity_provider is not None:
171
+ ext_cred_param["parameterType"] = "ExternalAuthIdentityProvider"
172
+ ext_cred_param["parameterName"] = "ExternalAuthIdentityProvider"
173
+ ext_cred_param[
174
+ "externalAuthIdentityProvider"
175
+ ] = self.external_auth_identity_provider
163
176
 
164
177
  if self.auth_provider_url is not None:
165
178
  ext_cred_param["parameterType"] = "AuthProviderUrl"
@@ -451,6 +464,16 @@ class UpdateExternalCredential(BaseSalesforceApiTask):
451
464
  ):
452
465
  """Update the parameters"""
453
466
  for param_input in external_credential_parameters:
467
+
468
+ if param_input.external_auth_identity_provider is not None:
469
+ param_input.external_auth_identity_provider = self._inject_namespace(
470
+ param_input.external_auth_identity_provider
471
+ )
472
+ if param_input.auth_provider is not None:
473
+ param_input.auth_provider = self._inject_namespace(
474
+ param_input.auth_provider
475
+ )
476
+
454
477
  param_to_update = param_input.get_external_credential_parameter()
455
478
  secret = param_to_update.pop("secret", False)
456
479
 
@@ -459,6 +482,15 @@ class UpdateExternalCredential(BaseSalesforceApiTask):
459
482
  k: v for k, v in param_to_update.items() if k != "parameterValue"
460
483
  }
461
484
 
485
+ if param_to_update.get("parameterType") == "ExternalAuthIdentityProvider":
486
+ self._remove_conflicting_parameters(
487
+ external_credential, "ExternalAuthIdentityProvider", log=False
488
+ )
489
+ elif param_to_update.get("parameterType") == "AuthProvider":
490
+ self._remove_conflicting_parameters(
491
+ external_credential, "AuthProvider", log=False
492
+ )
493
+
462
494
  # Find existing parameter
463
495
  cred_param = next(
464
496
  (
@@ -481,7 +513,7 @@ class UpdateExternalCredential(BaseSalesforceApiTask):
481
513
  if cred_param.get("parameterName")
482
514
  else ""
483
515
  )
484
- + f" with new value {param_to_update['parameterValue'] if not secret else '********'}"
516
+ + f" with new value {param_to_update.get('parameterValue', '') if not secret else '********'}"
485
517
  )
486
518
  else:
487
519
  # Add new parameter
@@ -499,9 +531,50 @@ class UpdateExternalCredential(BaseSalesforceApiTask):
499
531
  if copy_template_param.get("parameterName")
500
532
  else ""
501
533
  )
502
- + f" with new value {param_to_update['parameterValue'] if not secret else '********'}"
534
+ + f" with new value {param_to_update.get('parameterValue', '') if not secret else '********'}"
503
535
  )
504
536
 
537
+ # Enforce mutual exclusivity between AuthProvider and ExternalAuthIdentityProvider
538
+ # If auth_provider is provided, remove any ExternalAuthIdentityProvider parameters
539
+ # If external_auth_identity_provider is provided, remove any AuthProvider parameters
540
+
541
+ if param_to_update.get("parameterType") == "AuthProvider":
542
+ self._remove_conflicting_parameters(
543
+ external_credential, "ExternalAuthIdentityProvider"
544
+ )
545
+ elif param_to_update.get("parameterType") == "ExternalAuthIdentityProvider":
546
+ self._remove_conflicting_parameters(external_credential, "AuthProvider")
547
+
548
+ def _remove_conflicting_parameters(
549
+ self, external_credential: Dict[str, Any], parameter_type: str, log: bool = True
550
+ ):
551
+ """Remove conflicting parameter types from external credential.
552
+
553
+ Args:
554
+ external_credential: The external credential object
555
+ parameter_type: The parameter type to remove (e.g., 'AuthProvider' or 'ExternalAuthIdentityProvider')
556
+ """
557
+ if "externalCredentialParameters" not in external_credential:
558
+ return
559
+
560
+ original_count = len(external_credential["externalCredentialParameters"])
561
+
562
+ # Filter out parameters with the conflicting type
563
+ external_credential["externalCredentialParameters"] = [
564
+ param
565
+ for param in external_credential["externalCredentialParameters"]
566
+ if param.get("parameterType") != parameter_type
567
+ ]
568
+
569
+ removed_count = original_count - len(
570
+ external_credential["externalCredentialParameters"]
571
+ )
572
+
573
+ if removed_count > 0 and log:
574
+ self.logger.info(
575
+ f"Removed {removed_count} conflicting parameter(s) of type '{parameter_type}'"
576
+ )
577
+
505
578
  def _get_external_credential_template_parameter(self) -> Dict[str, Any]:
506
579
  """Get the external credential template parameter"""
507
580
  return {
@@ -560,3 +633,15 @@ class UpdateExternalCredential(BaseSalesforceApiTask):
560
633
  raise SalesforceDXException(msg)
561
634
 
562
635
  self.logger.info(f"Updated credential {param.named_principal.name}")
636
+
637
+ def _inject_namespace(self, value: str) -> str:
638
+ _, value = inject_namespace(
639
+ "",
640
+ value,
641
+ namespace=self.parsed_options.namespace,
642
+ managed=determine_managed_mode(
643
+ self.options, self.project_config, self.org_config
644
+ ),
645
+ namespaced_org=self.org_config.namespaced or False,
646
+ )
647
+ return value
@@ -0,0 +1,217 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from pydantic.v1 import root_validator
5
+
6
+ from cumulusci.core.exceptions import SalesforceException
7
+ from cumulusci.tasks.salesforce import BaseSalesforceApiTask
8
+ from cumulusci.utils.options import CCIOptions, Field, MappingOption
9
+
10
+
11
+ class UpdateRecord(BaseSalesforceApiTask):
12
+ task_docs = """
13
+ Update one or more Salesforce records.
14
+ For example, update by record ID:
15
+ cci task run update_record --org dev --object Account --record_id 001xx000003DGbXXXX --values Name:UpdatedName,Status__c:Active
16
+ Or update by query criteria:
17
+ cci task run update_record --org dev --object Account --where Name:TestAccount,Status__c:Draft --values Name:UpdatedName,Status__c:Active
18
+ Or use environment variables with transform_values:
19
+ cci task run update_record --org dev --object Account --record_id 001xx000003DGbXXXX --transform_values Name:ACCOUNT_NAME_VAR,Status__c:ACCOUNT_STATUS_VAR
20
+ """
21
+
22
+ class Options(CCIOptions):
23
+ object: str = Field(..., description="An sObject type to update")
24
+ values: Optional[MappingOption] = Field(
25
+ None,
26
+ description="Field names and values to update in the format 'aa:bb,cc:dd', or a YAML dict in cumulusci.yml.",
27
+ )
28
+ transform_values: Optional[MappingOption] = Field(
29
+ None,
30
+ description="Field names and environment variable keys in the format 'field:ENV_KEY,field2:ENV_KEY2'. Values will be extracted from environment variables.",
31
+ )
32
+ record_id: Optional[str] = Field(
33
+ None,
34
+ description="The ID of a specific record to update. If specified, the 'where' option is ignored.",
35
+ )
36
+ where: Optional[str] = Field(
37
+ None,
38
+ description="Query criteria to identify records in the format 'field:value,field2:value2'. Multiple records may be updated.",
39
+ )
40
+ tooling: bool = Field(
41
+ False, description="If True, use the Tooling API instead of REST API."
42
+ )
43
+ fail_on_error: bool = Field(
44
+ True,
45
+ description="If True (default), fail the task if any record update fails. If False, log errors but continue.",
46
+ )
47
+
48
+ @root_validator
49
+ def validate_options(cls, values):
50
+ """Validate required option combinations"""
51
+ # Validate that either record_id or where is provided
52
+ if not values.get("record_id") and not values.get("where"):
53
+ raise SalesforceException(
54
+ "Either 'record_id' or 'where' option must be specified"
55
+ )
56
+
57
+ # Validate that at least values or transform_values is provided
58
+ if not values.get("values") and not values.get("transform_values"):
59
+ raise SalesforceException(
60
+ "Either 'values' or 'transform_values' option must be specified"
61
+ )
62
+
63
+ return values
64
+
65
+ parsed_options: Options
66
+
67
+ def _init_task(self):
68
+ super()._init_task()
69
+ self.api = self.sf if not self.parsed_options.tooling else self.tooling
70
+
71
+ # Build the final values dict by merging values and transform_values
72
+ self.final_values = {}
73
+
74
+ # Start with regular values if provided
75
+ if self.parsed_options.values:
76
+ self.final_values.update(self.parsed_options.values)
77
+
78
+ # Process transform_values and extract from environment
79
+ if self.parsed_options.transform_values:
80
+ for field, env_key in self.parsed_options.transform_values.items():
81
+ env_value = os.environ.get(env_key, env_key)
82
+ self.final_values[field] = env_value
83
+ self.logger.info(
84
+ f"Transform value for field '{field}': {env_key} -> {env_value}"
85
+ )
86
+
87
+ def _run_task(self):
88
+ if self.parsed_options.record_id:
89
+ # Direct update by record ID
90
+ self._update_by_id(self.parsed_options.record_id)
91
+ else:
92
+ # Query and update multiple records
93
+ self._update_by_query()
94
+
95
+ def _update_by_id(self, record_id):
96
+ """Update a single record by ID"""
97
+ object_handler = getattr(self.api, self.parsed_options.object)
98
+
99
+ try:
100
+ rc = object_handler.update(record_id, self.final_values)
101
+ if rc == 204 or (isinstance(rc, dict) and rc.get("success")):
102
+ self.logger.info(
103
+ f"{self.parsed_options.object} record updated successfully: {record_id}"
104
+ )
105
+ else:
106
+ error_msg = (
107
+ f"Could not update {self.parsed_options.object} record {record_id}"
108
+ )
109
+ if isinstance(rc, dict) and "errors" in rc:
110
+ error_msg += f": {rc['errors']}"
111
+ if self.parsed_options.fail_on_error:
112
+ raise SalesforceException(error_msg)
113
+ else:
114
+ self.logger.error(error_msg)
115
+ except Exception as e:
116
+ if self.parsed_options.fail_on_error:
117
+ raise SalesforceException(
118
+ f"Error updating {self.parsed_options.object} record {record_id}: {str(e)}"
119
+ )
120
+ else:
121
+ self.logger.error(
122
+ f"Error updating {self.parsed_options.object} record {record_id}: {str(e)}"
123
+ )
124
+
125
+ def _update_by_query(self):
126
+ """Query records and update all matching records"""
127
+ # Parse where clause into query criteria - MappingOption already parses it
128
+ from cumulusci.core.utils import parse_list_of_pairs_dict_arg
129
+
130
+ where_criteria = parse_list_of_pairs_dict_arg(self.parsed_options.where)
131
+
132
+ # Build WHERE clause
133
+ where_parts = [
134
+ f"{field} = '{value}'" for field, value in where_criteria.items()
135
+ ]
136
+ where_clause = " AND ".join(where_parts)
137
+
138
+ # Build and execute query
139
+ query = f"SELECT Id FROM {self.parsed_options.object} WHERE {where_clause}"
140
+ self.logger.info(f"Querying records: {query}")
141
+
142
+ try:
143
+ result = self.api.query(query)
144
+ except Exception as e:
145
+ raise SalesforceException(f"Error executing query: {str(e)}")
146
+
147
+ records = result.get("records", [])
148
+ total_count = len(records)
149
+
150
+ if total_count == 0:
151
+ self.logger.warning(
152
+ f"No {self.parsed_options.object} records found matching criteria: {self.parsed_options.where}"
153
+ )
154
+ return
155
+
156
+ self.logger.info(
157
+ f"Found {total_count} {self.parsed_options.object} record(s) to update"
158
+ )
159
+
160
+ # Use different update strategy based on record count
161
+ if total_count == 1:
162
+ # Single record: use direct update
163
+ self._update_by_id(records[0]["Id"])
164
+ else:
165
+ # Multiple records: use bulk update
166
+ self._update_records_bulk(records)
167
+
168
+ def _update_records_bulk(self, records):
169
+ """Update multiple records using Bulk API"""
170
+ # Prepare data for bulk update
171
+ update_data = []
172
+ for record in records:
173
+ record_data = {"Id": record["Id"]}
174
+ record_data.update(self.final_values)
175
+ update_data.append(record_data)
176
+
177
+ self.logger.info(
178
+ f"Performing bulk update of {len(update_data)} {self.parsed_options.object} records"
179
+ )
180
+
181
+ try:
182
+ # Use Bulk API for update
183
+ results = self.bulk.update(self.parsed_options.object, update_data)
184
+
185
+ # Process results
186
+ success_count = 0
187
+ failed_records = []
188
+
189
+ for idx, result in enumerate(results):
190
+ record_id = update_data[idx]["Id"]
191
+ if result.success:
192
+ success_count += 1
193
+ self.logger.info(f"Updated record: {record_id}")
194
+ else:
195
+ error_msg = f"Failed to update record {record_id}: {result.error}"
196
+ failed_records.append({"id": record_id, "error": result.error})
197
+ self.logger.error(error_msg)
198
+
199
+ # Summary logging
200
+ self.logger.info(
201
+ f"Bulk update complete: {success_count}/{len(update_data)} records updated successfully"
202
+ )
203
+
204
+ # Handle failures
205
+ if failed_records and self.parsed_options.fail_on_error:
206
+ error_summary = "\n".join(
207
+ [f" - {rec['id']}: {rec['error']}" for rec in failed_records]
208
+ )
209
+ raise SalesforceException(
210
+ f"Failed to update {len(failed_records)} record(s):\n{error_summary}"
211
+ )
212
+
213
+ except Exception as e:
214
+ if self.parsed_options.fail_on_error:
215
+ raise SalesforceException(f"Bulk update failed: {str(e)}")
216
+ else:
217
+ self.logger.error(f"Bulk update failed: {str(e)}")
@@ -47,7 +47,20 @@ class SfdmuTask(BaseSalesforceTask, Command):
47
47
  super()._init_options(kwargs)
48
48
 
49
49
  # Convert path to absolute path
50
- self.options["path"] = os.path.abspath(self.options["path"])
50
+ if "path" in self.options and self.options["path"]:
51
+ if os.path.isabs(self.options["path"]):
52
+ # Path is already absolute, normalize it
53
+ self.options["path"] = os.path.abspath(self.options["path"])
54
+ else:
55
+ # Path is relative, join with repo_root
56
+ repo_root = self.project_config.repo_root
57
+ if not repo_root:
58
+ raise TaskOptionsError(
59
+ "Cannot resolve relative path: no repository root found"
60
+ )
61
+ self.options["path"] = os.path.abspath(
62
+ os.path.join(repo_root, self.options["path"])
63
+ )
51
64
 
52
65
  # Validate that the path exists and contains export.json
53
66
  if not os.path.exists(self.options["path"]):
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  from abc import ABC, abstractmethod
4
- from typing import Any, Optional
4
+ from typing import Any, Optional, Union
5
5
 
6
6
 
7
7
  # Abstract Base Class for Credential Providers
@@ -66,6 +66,9 @@ class DevEnvironmentVariableProvider(CredentialProvider):
66
66
  self, key: str, options: Optional[dict[str, Any]] = None
67
67
  ) -> Any:
68
68
  value = options.get("value", None)
69
+ if isinstance(value, dict):
70
+ value = next(iter(value.values()))
71
+
69
72
  self.logger.info(f"Credentials for {key} from local environment is {value}.")
70
73
  return value
71
74
 
@@ -89,7 +92,12 @@ class EnvironmentVariableProvider(CredentialProvider):
89
92
  self, key: str, options: Optional[dict[str, Any]] = None
90
93
  ) -> Any:
91
94
  value = options.get("value", None)
95
+ if isinstance(value, dict):
96
+ value = next(iter(value.values()))
97
+
92
98
  ret_value = os.getenv(self.get_key(key))
99
+ if ret_value is None and value is not None:
100
+ ret_value = os.getenv(self.get_key(value))
93
101
  if ret_value is None:
94
102
  self.logger.info(f"Credentials for {key} from environment is {value}.")
95
103
  return ret_value or value
@@ -148,13 +156,18 @@ class AwsSecretsManagerProvider(CredentialProvider):
148
156
  raise ValueError("Secret name is required for AWS Secrets Manager.")
149
157
 
150
158
  try:
151
- if secret_name in self.secrets_cache:
152
- return self.secrets_cache[secret_name]
153
-
154
159
  import json
155
160
 
156
161
  import boto3
157
162
  from botocore.exceptions import ClientError
163
+ except ImportError:
164
+ raise RuntimeError(
165
+ "boto3 is not installed. Please install it using 'pip install boto3' or 'pipx inject cumulusci-plus-azure-devops boto3'."
166
+ )
167
+
168
+ try:
169
+ if secret_name in self.secrets_cache:
170
+ return self.secrets_cache[secret_name]
158
171
 
159
172
  # Boto3 automatically handles credential lookup. In an Azure Pipeline,
160
173
  # it will find the temporary credentials provided by the AWS Service Connection.
@@ -166,24 +179,49 @@ class AwsSecretsManagerProvider(CredentialProvider):
166
179
 
167
180
  get_secret_value_response = client.get_secret_value(SecretId=secret_name)
168
181
 
169
- secret = get_secret_value_response["SecretString"]
182
+ if "SecretString" in get_secret_value_response:
183
+ secret = json.loads(get_secret_value_response["SecretString"])
184
+ elif "SecretBinary" in get_secret_value_response:
185
+ file_path = self.create_binary_file(
186
+ secret_name, get_secret_value_response["SecretBinary"]
187
+ )
188
+ secret_key = key if key and key != "*" else os.path.basename(file_path)
189
+ secret = {secret_key: file_path}
190
+ else:
191
+ raise ValueError(f"Secret {secret_name} is not a valid secret.")
170
192
 
171
193
  # Assuming the secret is a JSON string with the credentials
172
194
  # We need to check the binary type of the secret at later development
173
- self.secrets_cache[secret_name] = json.loads(secret)
195
+ self.secrets_cache[secret_name] = secret
174
196
 
175
197
  return self.secrets_cache[secret_name]
176
198
  except ClientError as e:
177
199
  # For a list of exceptions thrown, see
178
200
  # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html
179
201
  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
- )
202
+ except ValueError:
203
+ # Re-raise ValueError as-is (e.g., invalid secret format)
204
+ raise
184
205
  except Exception as e:
185
206
  raise RuntimeError(f"Failed to retrieve secret '{key}': {e}")
186
207
 
208
+ def create_binary_file(self, secret_name: str, content: Union[str, bytes]) -> str:
209
+ """
210
+ Write the binary content to a file.
211
+ secret_name is relative path to the root of the project.
212
+ create the directory if it doesn't exist.
213
+ return the absolute path to the file with windows or linux path separator.
214
+ """
215
+ absolute_path = os.path.abspath(os.path.join(".cci", secret_name))
216
+
217
+ try:
218
+ os.makedirs(os.path.dirname(absolute_path), exist_ok=True)
219
+ with open(absolute_path, "wb") as f:
220
+ f.write(content)
221
+ return absolute_path
222
+ except Exception as e:
223
+ raise RuntimeError(f"Failed to create binary file {absolute_path}: {e}")
224
+
187
225
 
188
226
  # Concrete Credential Provider for Azure Variable Groups
189
227
  class AzureVariableGroupProvider(CredentialProvider):
@@ -202,9 +240,17 @@ class AzureVariableGroupProvider(CredentialProvider):
202
240
  Looks for AWS credentials in environment variables exposed by
203
241
  an Azure variable group.
204
242
  """
243
+ value = options.get("value", None)
244
+ if isinstance(value, dict):
245
+ value = next(iter(value.values()))
246
+
205
247
  # 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(".", "_"))
248
+ ret_value = os.getenv(self.get_key(key).upper().replace(".", "_"))
249
+
250
+ if ret_value is None and value is not None:
251
+ ret_value = os.getenv(self.get_key(value).upper().replace(".", "_"))
252
+
253
+ return ret_value
208
254
 
209
255
  def get_all_credentials(
210
256
  self, key: str, options: Optional[dict[str, Any]] = None
@@ -27,8 +27,8 @@ class GenericOptions(CCIOptions):
27
27
  class SecretsToEnv(BaseTask):
28
28
  class Options(GenericOptions):
29
29
  secrets: Union[ListOfStringsOption, MappingOption] = Field(
30
- [],
31
- 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.",
30
+ ...,
31
+ description="List of secret keys to retrieve be it with a list of keys or a mapping of key to secret name.",
32
32
  )
33
33
 
34
34
  parsed_options: Options
@@ -64,18 +64,21 @@ class SecretsToEnv(BaseTask):
64
64
 
65
65
  self._init_secrets()
66
66
 
67
- output_file = self.parsed_options.env_path
68
-
69
67
  for secret_key, secret_name in self.secrets.items():
70
68
  if secret_key == "*":
71
69
  self.env_values.update(
72
70
  self._get_all_credentials(secret_key, secret_name=secret_name)
73
71
  )
74
72
  else:
75
- safe_value, _ = self._get_credential(
73
+ self.env_values[secret_key] = self._get_credential(
76
74
  secret_key, secret_key, secret_name=secret_name
77
75
  )
78
- self.env_values[secret_key] = safe_value
76
+ self.return_values = {"env_values": self.env_values}
77
+ self._write_env_file()
78
+
79
+ def _write_env_file(self):
80
+ safe_env_values = {}
81
+ output_file = self.parsed_options.env_path
79
82
 
80
83
  # Ensure output directory exists
81
84
  os.makedirs(os.path.dirname(output_file) or ".", exist_ok=True)
@@ -83,7 +86,37 @@ class SecretsToEnv(BaseTask):
83
86
  # Write env file with UTF-8 encoding to support Unicode characters
84
87
  with open(output_file, "w", encoding="utf-8") as env_file:
85
88
  for key, value in self.env_values.items():
86
- env_file.write(f'{key}="{value}"\n')
89
+ safe_env_values[key] = self._escape_env_value(value)
90
+ env_file.write(f'{key}="{safe_env_values[key]}"\n')
91
+
92
+ self.return_values["safe_env_values"] = safe_env_values
93
+
94
+ # https://pypi.org/project/python-dotenv/ -> README -> Escaping Values
95
+ def _escape_env_value(self, value):
96
+ """
97
+ Escape special characters for .env file values in double quotes.
98
+ Escapes: \\, \', \", \a, \b, \f, \n, \r, \t, \v
99
+ """
100
+ if not isinstance(value, str):
101
+ return value
102
+
103
+ escape_map = {
104
+ "\\": "\\\\", # Backslash must be first
105
+ "'": "\\'", # Single quote
106
+ '"': '\\"', # Double quote
107
+ "\a": "\\a", # Bell/Alert
108
+ "\b": "\\b", # Backspace
109
+ "\f": "\\f", # Form feed
110
+ "\n": "\\n", # Newline
111
+ "\r": "\\r", # Carriage return
112
+ "\t": "\\t", # Tab
113
+ "\v": "\\v", # Vertical tab
114
+ }
115
+
116
+ for char, escaped in escape_map.items():
117
+ value = value.replace(char, escaped)
118
+
119
+ return value
87
120
 
88
121
  def _get_credential(
89
122
  self,
@@ -108,8 +141,7 @@ class SecretsToEnv(BaseTask):
108
141
  self.logger.info(
109
142
  f"Set secrets env var from {self.provider.provider_type}: {env_key}={display_value}"
110
143
  )
111
- safe_value = cred_secret_value.replace('"', '\\"').replace("\n", "\\n")
112
- return safe_value, cred_secret_value
144
+ return cred_secret_value
113
145
 
114
146
  def _get_all_credentials(
115
147
  self, credential_key, display_value="*****", secret_name=None
@@ -129,7 +161,6 @@ class SecretsToEnv(BaseTask):
129
161
  self.logger.info(
130
162
  f"Set secrets env var from {self.provider.provider_type}: {key}={display_value}"
131
163
  )
132
- safe_value = value.replace('"', '\\"').replace("\n", "\\n")
133
- dict_values[key] = safe_value
164
+ dict_values[key] = value
134
165
 
135
166
  return dict_values