cumulusci-plus 5.0.35__py3-none-any.whl → 5.0.43__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.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/task.py +9 -10
- cumulusci/cli/tests/test_org.py +5 -0
- cumulusci/cli/tests/test_task.py +34 -0
- cumulusci/core/config/__init__.py +1 -0
- cumulusci/core/config/org_config.py +2 -1
- cumulusci/core/config/project_config.py +12 -0
- cumulusci/core/config/scratch_org_config.py +12 -0
- cumulusci/core/config/tests/test_config.py +1 -0
- cumulusci/core/dependencies/base.py +4 -0
- cumulusci/cumulusci.yml +18 -1
- cumulusci/schema/cumulusci.jsonschema.json +5 -0
- cumulusci/tasks/apex/testrunner.py +7 -4
- cumulusci/tasks/bulkdata/tests/test_select_utils.py +20 -0
- cumulusci/tasks/metadata_etl/__init__.py +2 -0
- cumulusci/tasks/metadata_etl/applications.py +256 -0
- cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
- cumulusci/tasks/salesforce/insert_record.py +18 -19
- cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
- cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +523 -8
- cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
- cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
- cumulusci/tasks/salesforce/update_external_credential.py +89 -4
- cumulusci/tasks/salesforce/update_record.py +217 -0
- cumulusci/tasks/sfdmu/sfdmu.py +14 -1
- cumulusci/tasks/utility/credentialManager.py +58 -12
- cumulusci/tasks/utility/secretsToEnv.py +2 -2
- cumulusci/tasks/utility/tests/test_credentialManager.py +586 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +42 -15
- cumulusci/utils/yaml/cumulusci_yml.py +1 -0
- {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +6 -7
- {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +37 -31
- {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
- {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.35.dist-info → cumulusci_plus-5.0.43.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"] =
|
|
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
|
|
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
|
|
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)}")
|
cumulusci/tasks/sfdmu/sfdmu.py
CHANGED
|
@@ -47,7 +47,20 @@ class SfdmuTask(BaseSalesforceTask, Command):
|
|
|
47
47
|
super()._init_options(kwargs)
|
|
48
48
|
|
|
49
49
|
# Convert path to absolute path
|
|
50
|
-
|
|
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
|
-
|
|
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] =
|
|
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
|
|
181
|
-
raise
|
|
182
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
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
|