cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.35__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/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +17 -0
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +88 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/project_config.py +2 -20
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +1 -1
- cumulusci/core/dependencies/dependencies.py +1 -1
- cumulusci/core/dependencies/github.py +1 -2
- cumulusci/core/dependencies/resolvers.py +1 -1
- cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
- cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
- cumulusci/core/flowrunner.py +90 -6
- cumulusci/core/github.py +1 -1
- cumulusci/core/sfdx.py +3 -1
- cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
- cumulusci/core/source_transforms/transforms.py +1 -1
- cumulusci/core/tasks.py +13 -2
- cumulusci/core/tests/test_flowrunner.py +100 -0
- cumulusci/core/tests/test_tasks.py +65 -0
- cumulusci/core/utils.py +3 -1
- cumulusci/core/versions.py +1 -1
- cumulusci/cumulusci.yml +55 -0
- cumulusci/oauth/client.py +1 -1
- cumulusci/plugins/plugin_base.py +5 -3
- cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
- cumulusci/salesforce_api/rest_deploy.py +1 -1
- cumulusci/schema/cumulusci.jsonschema.json +64 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +416 -142
- cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
- cumulusci/tasks/bulkdata/extract.py +0 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
- cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
- cumulusci/tasks/bulkdata/select_utils.py +1 -1
- cumulusci/tasks/bulkdata/snowfakery.py +100 -25
- cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
- cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
- cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
- cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
- cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
- cumulusci/tasks/create_package_version.py +190 -16
- cumulusci/tasks/datadictionary.py +1 -1
- cumulusci/tasks/metadata_etl/base.py +7 -3
- cumulusci/tasks/metadata_etl/layouts.py +1 -1
- cumulusci/tasks/metadata_etl/permissions.py +1 -1
- cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
- cumulusci/tasks/push/README.md +15 -17
- cumulusci/tasks/release_notes/README.md +13 -13
- cumulusci/tasks/release_notes/generator.py +13 -8
- cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
- cumulusci/tasks/salesforce/Deploy.py +53 -2
- cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
- cumulusci/tasks/salesforce/__init__.py +1 -0
- cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
- cumulusci/tasks/salesforce/composite.py +1 -1
- cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
- cumulusci/tasks/salesforce/enable_prediction.py +5 -1
- cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
- cumulusci/tasks/salesforce/sourcetracking.py +1 -1
- cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
- cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
- cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_credential.py +562 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/users/permsets.py +62 -5
- cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
- cumulusci/tasks/sfdmu/__init__.py +0 -0
- cumulusci/tasks/sfdmu/sfdmu.py +363 -0
- cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
- cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
- cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
- cumulusci/tasks/tests/test_create_package_version.py +716 -1
- cumulusci/tasks/tests/test_util.py +42 -0
- cumulusci/tasks/util.py +37 -1
- cumulusci/tasks/utility/copyContents.py +402 -0
- cumulusci/tasks/utility/credentialManager.py +256 -0
- cumulusci/tasks/utility/directoryRecreator.py +30 -0
- cumulusci/tasks/utility/env_management.py +1 -1
- cumulusci/tasks/utility/secretsToEnv.py +135 -0
- cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
- cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
- cumulusci/tests/test_integration_infrastructure.py +3 -1
- cumulusci/tests/test_utils.py +70 -6
- cumulusci/utils/__init__.py +54 -9
- cumulusci/utils/classutils.py +5 -2
- cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
- cumulusci/utils/options.py +23 -1
- cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
- cumulusci/utils/yaml/cumulusci_yml.py +7 -3
- cumulusci/utils/yaml/model_parser.py +2 -2
- cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
- cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
- cumulusci/vcs/base.py +23 -15
- cumulusci/vcs/bootstrap.py +5 -4
- cumulusci/vcs/utils/list_modified_files.py +189 -0
- cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +121 -96
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from inspect import signature
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
from pydantic.v1 import create_model
|
|
6
|
+
|
|
7
|
+
from cumulusci.core.exceptions import SalesforceException, TaskOptionsError
|
|
8
|
+
from cumulusci.core.utils import determine_managed_mode
|
|
9
|
+
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
|
|
10
|
+
from cumulusci.utils import inject_namespace
|
|
11
|
+
from cumulusci.utils.options import CCIOptions, CCIOptionType, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PermissionSetGroupAssignmentsOption(CCIOptionType):
|
|
15
|
+
"""Parses a dictionary of Permission Set Group to Permission Sets assignments.
|
|
16
|
+
|
|
17
|
+
Supports formats:
|
|
18
|
+
- JSON: {"PermissionSetGroup1": ["PermissionSet1", "PermissionSet2"]}
|
|
19
|
+
- YAML: PermissionSetGroup1: [PermissionSet1, PermissionSet2]
|
|
20
|
+
- Command line: "PermissionSetGroup1:PermissionSet1,PermissionSet2;PermissionSetGroup2:PermissionSet3,PermissionSet4"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def validate(cls, v):
|
|
25
|
+
"""Validate and convert a value to Dict[str, List[str]]."""
|
|
26
|
+
# Handle dict input (from YAML/JSON)
|
|
27
|
+
if isinstance(v, dict):
|
|
28
|
+
# Normalize: ensure all values are lists
|
|
29
|
+
normalized = {k: v if isinstance(v, list) else [v] for k, v in v.items()}
|
|
30
|
+
# Handle string input
|
|
31
|
+
elif isinstance(v, str):
|
|
32
|
+
# If it's a JSON string, parse it
|
|
33
|
+
if v.startswith("{") or v.startswith("["):
|
|
34
|
+
parsed = json.loads(v)
|
|
35
|
+
if isinstance(parsed, dict):
|
|
36
|
+
normalized = {
|
|
37
|
+
k: v if isinstance(v, list) else [v] for k, v in parsed.items()
|
|
38
|
+
}
|
|
39
|
+
else:
|
|
40
|
+
raise TaskOptionsError(
|
|
41
|
+
f"Expected dict in JSON string, got {type(parsed)}"
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
# Handle command line format: PSG1:PS1,PS2;PSG2:PS3,PS4
|
|
45
|
+
normalized = cls.from_str(v)
|
|
46
|
+
else:
|
|
47
|
+
raise TaskOptionsError(
|
|
48
|
+
f"Invalid format for assignments. Expected dict or string in format "
|
|
49
|
+
f"'PSG1:PS1,PS2;PSG2:PS3,PS4' or JSON dict, got: {type(v)}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Validate with Pydantic using the return type annotation
|
|
53
|
+
target_type = signature(cls.from_str).return_annotation
|
|
54
|
+
Dummy = create_model(cls.name or cls.__name__, __root__=(target_type, ...))
|
|
55
|
+
return Dummy.parse_obj(normalized).__root__
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_str(cls, v) -> Dict[str, List[str]]:
|
|
59
|
+
"""Parse command line format: PSG1:PS1,PS2;PSG2:PS3,PS4 or PSG1:PS1"""
|
|
60
|
+
# Handle command line format: presence of colon indicates command line format
|
|
61
|
+
if ":" in v:
|
|
62
|
+
result = {}
|
|
63
|
+
# Split by semicolon to get groups (if multiple groups)
|
|
64
|
+
# If no semicolon, treat entire string as single group assignment
|
|
65
|
+
group_assignments = v.split(";") if ";" in v else [v]
|
|
66
|
+
|
|
67
|
+
for group_assignment in group_assignments:
|
|
68
|
+
group_assignment = group_assignment.strip()
|
|
69
|
+
if ":" in group_assignment:
|
|
70
|
+
psg_name, ps_names = group_assignment.split(":", maxsplit=1)
|
|
71
|
+
psg_name = psg_name.strip()
|
|
72
|
+
# Split permission sets by comma (if multiple permission sets)
|
|
73
|
+
# If no comma, treat entire string as single permission set
|
|
74
|
+
ps_list = [ps.strip() for ps in ps_names.split(",") if ps.strip()]
|
|
75
|
+
if psg_name:
|
|
76
|
+
result[psg_name] = ps_list
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
raise TaskOptionsError(
|
|
80
|
+
"Invalid format for assignments. Expected string in format "
|
|
81
|
+
"'PSG1:PS1' or 'PSG1:PS1,PS2;PSG2:PS3,PS4'"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AssignPermissionSetToPermissionSetGroup(BaseSalesforceApiTask):
|
|
86
|
+
"""Assign Permission Sets to Permission Set Groups.
|
|
87
|
+
|
|
88
|
+
This task creates PermissionSetGroupComponent records to associate
|
|
89
|
+
Permission Sets with Permission Set Groups using the Composite API.
|
|
90
|
+
|
|
91
|
+
Task options:
|
|
92
|
+
- assignments: A dictionary where:
|
|
93
|
+
key: Permission Set Group API name (DeveloperName)
|
|
94
|
+
- value: List of Permission Set API names (Name) to assign to the Permission Set Group
|
|
95
|
+
|
|
96
|
+
Example 1: Passed as JSON
|
|
97
|
+
"PermissionSetGroup1": ["PermissionSet1", "PermissionSet2"]
|
|
98
|
+
"PermissionSetGroup2": ["PermissionSet3", "PermissionSet4"]
|
|
99
|
+
|
|
100
|
+
Example 2: Passed as YAML
|
|
101
|
+
PermissionSetGroup1:
|
|
102
|
+
- PermissionSet1
|
|
103
|
+
- PermissionSet2
|
|
104
|
+
PermissionSetGroup2:
|
|
105
|
+
- PermissionSet3
|
|
106
|
+
- PermissionSet4
|
|
107
|
+
|
|
108
|
+
Example 3: Passed in command line arguments
|
|
109
|
+
- --assignments "PermissionSetGroup1:PermissionSet1,PermissionSet2;PermissionSetGroup2:PermissionSet3,PermissionSet4"
|
|
110
|
+
- --assignments "PermissionSetGroup1:PermissionSet1;"
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
class Options(CCIOptions):
|
|
114
|
+
assignments: PermissionSetGroupAssignmentsOption = Field(
|
|
115
|
+
...,
|
|
116
|
+
description=(
|
|
117
|
+
"Dictionary mapping Permission Set Group API names to lists of "
|
|
118
|
+
"Permission Set API names. Supports JSON, YAML, or command line format."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
namespace_inject: str = Field(
|
|
122
|
+
None,
|
|
123
|
+
description="Namespace to use for Permission Set names. If not provided, the namespace from the project config will be used.",
|
|
124
|
+
)
|
|
125
|
+
managed: bool = Field(
|
|
126
|
+
None,
|
|
127
|
+
description="Whether the deployment is managed. If not provided, the managed mode will be determined based on the org config.",
|
|
128
|
+
)
|
|
129
|
+
fail_on_error: bool = Field(
|
|
130
|
+
True,
|
|
131
|
+
description="Whether the task should fail if any Permission Set Group Component creation fails. If set to False, the task will continue even if some assignments fail.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
parsed_options: Options
|
|
135
|
+
|
|
136
|
+
def _init_options(self, kwargs):
|
|
137
|
+
super()._init_options(kwargs)
|
|
138
|
+
|
|
139
|
+
if self.parsed_options.namespace_inject is None:
|
|
140
|
+
self.parsed_options.namespace_inject = (
|
|
141
|
+
self.project_config.project__package__namespace
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if self.parsed_options.managed is None:
|
|
145
|
+
self.parsed_options.managed = determine_managed_mode(
|
|
146
|
+
self.parsed_options, self.project_config, self.org_config
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self.namespaced_org = bool(
|
|
150
|
+
self.parsed_options.namespace_inject
|
|
151
|
+
) and self.parsed_options.namespace_inject == getattr(
|
|
152
|
+
self.org_config, "namespace", None
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self.psg_names_sanitized = {}
|
|
156
|
+
self.ps_names_sanitized = {}
|
|
157
|
+
|
|
158
|
+
def _run_task(self):
|
|
159
|
+
"""Execute the task to assign Permission Sets to Permission Set Groups."""
|
|
160
|
+
assignments = self.parsed_options.assignments
|
|
161
|
+
|
|
162
|
+
if not assignments:
|
|
163
|
+
self.logger.warning("No assignments provided. Nothing to do.")
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Step 1: Query Permission Set Groups to get their IDs
|
|
167
|
+
try:
|
|
168
|
+
self._get_permission_set_group_ids(list(assignments.keys()))
|
|
169
|
+
except Exception as e:
|
|
170
|
+
raise SalesforceException(
|
|
171
|
+
f"Error querying Permission Set Groups: {str(e)}"
|
|
172
|
+
) from e
|
|
173
|
+
|
|
174
|
+
# Log missing groups
|
|
175
|
+
missing = set(self.psg_names_sanitized.values()) - set(self.psg_ids.keys())
|
|
176
|
+
if missing:
|
|
177
|
+
msg = f"Permission Set Groups not found in the org: {', '.join(missing)}"
|
|
178
|
+
if self.parsed_options.fail_on_error:
|
|
179
|
+
raise SalesforceException(msg)
|
|
180
|
+
self.logger.warning(msg)
|
|
181
|
+
|
|
182
|
+
# Step 2: Query Permission Sets to get their IDs
|
|
183
|
+
all_ps_names = []
|
|
184
|
+
for ps_list in assignments.values():
|
|
185
|
+
all_ps_names.extend(ps_list)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
self._get_permission_set_ids(all_ps_names)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
msg = f"Error querying Permission Sets: {str(e)}"
|
|
191
|
+
if self.parsed_options.fail_on_error:
|
|
192
|
+
raise SalesforceException(msg)
|
|
193
|
+
self.logger.error(msg)
|
|
194
|
+
|
|
195
|
+
# Log missing permission sets
|
|
196
|
+
missing = set(self.ps_names_sanitized.values()) - set(self.ps_ids.keys())
|
|
197
|
+
if missing:
|
|
198
|
+
msg = f"Permission Sets not found in the org: {', '.join(missing)}"
|
|
199
|
+
if self.parsed_options.fail_on_error:
|
|
200
|
+
raise SalesforceException(msg)
|
|
201
|
+
self.logger.warning(msg)
|
|
202
|
+
|
|
203
|
+
# Step 3: Build composite request to create PermissionSetGroupComponent records
|
|
204
|
+
records = []
|
|
205
|
+
for psg_name, ps_names in assignments.items():
|
|
206
|
+
psg_id = self.psg_ids.get(self.psg_names_sanitized[psg_name])
|
|
207
|
+
if not psg_id:
|
|
208
|
+
self.logger.warning(
|
|
209
|
+
f"Permission Set Group '{psg_name}' not found in the org. Skipping assignment creation."
|
|
210
|
+
)
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
for ps_name in ps_names:
|
|
214
|
+
ps_id = self.ps_ids.get(self.ps_names_sanitized[ps_name])
|
|
215
|
+
if not ps_id:
|
|
216
|
+
self.logger.warning(
|
|
217
|
+
f"Permission Set '{ps_name}' not found in the org. Skipping assignment creation."
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
records.append(
|
|
222
|
+
{
|
|
223
|
+
"attributes": {"type": "PermissionSetGroupComponent"},
|
|
224
|
+
"PermissionSetGroupId": psg_id,
|
|
225
|
+
"PermissionSetId": ps_id,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if not records:
|
|
230
|
+
self.logger.warning("No valid records to create. Nothing to do.")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
# Step 4: Use Composite API to create records in batches of 200
|
|
234
|
+
for i in range(0, len(records), 200):
|
|
235
|
+
batch = records[i : i + 200]
|
|
236
|
+
self.logger.info(f"Processing batch {i // 200 + 1} ({len(batch)} records)")
|
|
237
|
+
try:
|
|
238
|
+
self._create_permission_set_group_components(batch)
|
|
239
|
+
except Exception as e:
|
|
240
|
+
self.logger.error(f"Error processing batch {i // 200 + 1}: {str(e)}")
|
|
241
|
+
if self.parsed_options.fail_on_error:
|
|
242
|
+
raise e
|
|
243
|
+
|
|
244
|
+
def _get_permission_set_group_ids(self, psg_names: List[str]):
|
|
245
|
+
"""Query Permission Set Groups by DeveloperName and return mapping of name to ID."""
|
|
246
|
+
self.psg_ids = {}
|
|
247
|
+
if not psg_names:
|
|
248
|
+
self.logger.warning("No Permission Set Groups provided. Nothing to do.")
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
self.psg_names_sanitized = self._process_namespaces(psg_names)
|
|
252
|
+
|
|
253
|
+
name_conditions, name_mapping = build_name_conditions(
|
|
254
|
+
list(self.psg_names_sanitized.values()), field_name="DeveloperName"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
query = (
|
|
258
|
+
f"SELECT Id, DeveloperName, NamespacePrefix FROM PermissionSetGroup "
|
|
259
|
+
f"WHERE ({' OR '.join(name_conditions)})"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
result = self.sf.query(query)
|
|
263
|
+
for record in result.get("records", []):
|
|
264
|
+
record_name = record["DeveloperName"]
|
|
265
|
+
namespace_prefix = record.get("NamespacePrefix")
|
|
266
|
+
key = (record_name, namespace_prefix)
|
|
267
|
+
if key in name_mapping:
|
|
268
|
+
original_name = name_mapping[key]
|
|
269
|
+
self.psg_ids[original_name] = record["Id"]
|
|
270
|
+
elif (record_name, None) in name_mapping:
|
|
271
|
+
original_name = name_mapping[(record_name, None)]
|
|
272
|
+
self.psg_ids[original_name] = record["Id"]
|
|
273
|
+
|
|
274
|
+
def _process_namespaces(self, names: List[str]):
|
|
275
|
+
"""Process namespace tokens in names."""
|
|
276
|
+
# names_json_string = json.dumps(names)
|
|
277
|
+
names_processed = {}
|
|
278
|
+
for name in names:
|
|
279
|
+
_, name_processed = inject_namespace(
|
|
280
|
+
"",
|
|
281
|
+
name,
|
|
282
|
+
namespace=self.parsed_options.namespace_inject,
|
|
283
|
+
managed=self.parsed_options.managed,
|
|
284
|
+
namespaced_org=self.namespaced_org,
|
|
285
|
+
logger=self.logger,
|
|
286
|
+
)
|
|
287
|
+
names_processed[name] = name_processed
|
|
288
|
+
return names_processed
|
|
289
|
+
|
|
290
|
+
def _get_permission_set_ids(self, ps_names: List[str]):
|
|
291
|
+
"""Query Permission Sets by Name and return mapping of name to ID.
|
|
292
|
+
|
|
293
|
+
Handles namespace tokens (%%%NAMESPACE%%%) in Permission Set names by:
|
|
294
|
+
1. Replacing tokens with actual namespace prefix
|
|
295
|
+
2. Querying using both Name and NamespacePrefix fields
|
|
296
|
+
"""
|
|
297
|
+
self.ps_ids = {}
|
|
298
|
+
if not ps_names:
|
|
299
|
+
self.logger.warning("No Permission Sets provided. Nothing to do.")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Remove duplicates while preserving order
|
|
303
|
+
unique_ps_names = list(dict.fromkeys(ps_names))
|
|
304
|
+
|
|
305
|
+
# Process namespace tokens in Permission Set names
|
|
306
|
+
self.ps_names_sanitized = self._process_namespaces(unique_ps_names)
|
|
307
|
+
|
|
308
|
+
# Replace namespace tokens in names and build query conditions
|
|
309
|
+
name_conditions, name_mapping = build_name_conditions(
|
|
310
|
+
list(self.ps_names_sanitized.values())
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Build SOQL query with namespace handling
|
|
314
|
+
query = (
|
|
315
|
+
f"SELECT Id, Name, NamespacePrefix FROM PermissionSet "
|
|
316
|
+
f"WHERE IsOwnedByProfile = false AND ({' OR '.join(name_conditions)})"
|
|
317
|
+
)
|
|
318
|
+
result = self.sf.query(query)
|
|
319
|
+
|
|
320
|
+
# Build mapping considering namespace prefix
|
|
321
|
+
for record in result.get("records", []):
|
|
322
|
+
record_name = record["Name"]
|
|
323
|
+
namespace_prefix = record.get("NamespacePrefix")
|
|
324
|
+
|
|
325
|
+
# Try to match by (name, namespace_prefix) tuple
|
|
326
|
+
key = (record_name, namespace_prefix)
|
|
327
|
+
if key in name_mapping:
|
|
328
|
+
original_name = name_mapping[key]
|
|
329
|
+
self.ps_ids[original_name] = record["Id"]
|
|
330
|
+
# Fallback: match by name only if namespace_prefix is None
|
|
331
|
+
elif (record_name, None) in name_mapping:
|
|
332
|
+
original_name = name_mapping[(record_name, None)]
|
|
333
|
+
self.ps_ids[original_name] = record["Id"]
|
|
334
|
+
|
|
335
|
+
def _create_permission_set_group_components(self, records: List[Dict]):
|
|
336
|
+
"""Create PermissionSetGroupComponent records using Composite API."""
|
|
337
|
+
request_body = {
|
|
338
|
+
"allOrNone": False,
|
|
339
|
+
"records": records,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
result = self.sf.restful(
|
|
344
|
+
"composite/sobjects", method="POST", data=json.dumps(request_body)
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Process response
|
|
348
|
+
composite_response = isinstance(result, list) and result or []
|
|
349
|
+
success_count = 0
|
|
350
|
+
error_count = 0
|
|
351
|
+
|
|
352
|
+
for i, response in enumerate(composite_response):
|
|
353
|
+
success = response.get("success", False)
|
|
354
|
+
if success is True:
|
|
355
|
+
success_count += 1
|
|
356
|
+
record_id = response.get("id", "Unknown")
|
|
357
|
+
self.logger.debug(
|
|
358
|
+
f"Created PermissionSetGroupComponent record: {record_id}"
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
|
|
362
|
+
errors = response.get("errors", [])
|
|
363
|
+
is_duplicate_error = any(
|
|
364
|
+
err.get("statusCode")
|
|
365
|
+
for err in errors
|
|
366
|
+
if isinstance(err, dict)
|
|
367
|
+
and err.get("statusCode", "Unknown status code")
|
|
368
|
+
== "DUPLICATE_VALUE"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
error_messages = [
|
|
372
|
+
f"{err.get('message', 'Unknown error')} ({err.get('statusCode', 'Unknown status code')})"
|
|
373
|
+
for err in errors
|
|
374
|
+
if isinstance(err, dict)
|
|
375
|
+
]
|
|
376
|
+
psg_name = next(
|
|
377
|
+
(
|
|
378
|
+
key
|
|
379
|
+
for key, value in self.psg_ids.items()
|
|
380
|
+
if value
|
|
381
|
+
== records[i].get("PermissionSetGroupId", "Unknown")
|
|
382
|
+
),
|
|
383
|
+
None,
|
|
384
|
+
)
|
|
385
|
+
ps_name = next(
|
|
386
|
+
(
|
|
387
|
+
key
|
|
388
|
+
for key, value in self.ps_ids.items()
|
|
389
|
+
if value == records[i].get("PermissionSetId", "Unknown")
|
|
390
|
+
),
|
|
391
|
+
None,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if is_duplicate_error:
|
|
395
|
+
self.logger.info(
|
|
396
|
+
f"Permission Set '{ps_name}' is already assigned to Permission Set Group '{self.psg_names_sanitized.get(psg_name, psg_name)}'. Skipping assignment creation."
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
error_count += 1
|
|
400
|
+
self.logger.error(
|
|
401
|
+
f"Failed to create PermissionSetGroupComponent for Permission Set Group '{self.psg_names_sanitized.get(psg_name, psg_name)}' and Permission Set '{self.ps_names_sanitized.get(ps_name, ps_name)}': {', '.join(error_messages)}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
self.logger.info(
|
|
405
|
+
f"Permission Set Group Assignments results: {success_count} succeeded, {error_count} failed"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if error_count > 0:
|
|
409
|
+
raise SalesforceException(
|
|
410
|
+
f"Failed to create {error_count} PermissionSetGroupComponent record(s)"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
except Exception as e:
|
|
414
|
+
raise SalesforceException(
|
|
415
|
+
f"Error creating PermissionSetGroupComponent records: {str(e)}"
|
|
416
|
+
) from e
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def build_name_conditions(names: List[str], field_name: str = "Name"):
|
|
420
|
+
name_conditions = []
|
|
421
|
+
name_mapping = (
|
|
422
|
+
{}
|
|
423
|
+
) # Maps (original_name, namespace_prefix) tuple back to original name
|
|
424
|
+
for name in names:
|
|
425
|
+
# Check if name contains namespace prefix (format: namespace__Name)
|
|
426
|
+
if "__" in name:
|
|
427
|
+
parts = name.split("__", 1)
|
|
428
|
+
if len(parts) == 2:
|
|
429
|
+
ns_prefix, ps_name = parts
|
|
430
|
+
# Query with namespace prefix
|
|
431
|
+
escaped_ns = "'" + ns_prefix.replace("'", "''") + "'"
|
|
432
|
+
escaped_name = "'" + ps_name.replace("'", "''") + "'"
|
|
433
|
+
name_conditions.append(
|
|
434
|
+
f"(NamespacePrefix = {escaped_ns} AND {field_name} = {escaped_name})"
|
|
435
|
+
)
|
|
436
|
+
name_mapping[(ps_name, ns_prefix)] = name
|
|
437
|
+
else:
|
|
438
|
+
# Fallback: query by name only
|
|
439
|
+
escaped_name = "'" + name.replace("'", "''") + "'"
|
|
440
|
+
name_conditions.append(f"{field_name} = {escaped_name}")
|
|
441
|
+
name_mapping[(name, None)] = name
|
|
442
|
+
else:
|
|
443
|
+
# No namespace prefix in name
|
|
444
|
+
escaped_name = "'" + name.replace("'", "''") + "'"
|
|
445
|
+
name_conditions.append(f"{field_name} = {escaped_name}")
|
|
446
|
+
name_mapping[(name, None)] = name
|
|
447
|
+
|
|
448
|
+
return name_conditions, name_mapping
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from cumulusci.cli.ui import CliTable
|
|
8
8
|
from cumulusci.core.exceptions import SalesforceException
|
|
9
|
-
from cumulusci.core.utils import
|
|
9
|
+
from cumulusci.core.utils import determine_managed_mode, process_list_arg
|
|
10
10
|
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
|
|
11
11
|
from cumulusci.utils import inject_namespace
|
|
12
12
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from simple_salesforce.exceptions import SalesforceError
|
|
4
4
|
|
|
5
5
|
from cumulusci.core.exceptions import TaskOptionsError
|
|
6
|
-
from cumulusci.core.utils import
|
|
6
|
+
from cumulusci.core.utils import determine_managed_mode, process_bool_arg
|
|
7
7
|
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
|
|
8
8
|
|
|
9
9
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
from simple_salesforce.exceptions import SalesforceError
|
|
2
2
|
|
|
3
3
|
from cumulusci.core.exceptions import CumulusCIException
|
|
4
|
-
from cumulusci.core.utils import
|
|
4
|
+
from cumulusci.core.utils import (
|
|
5
|
+
determine_managed_mode,
|
|
6
|
+
process_bool_arg,
|
|
7
|
+
process_list_arg,
|
|
8
|
+
)
|
|
5
9
|
from cumulusci.tasks.salesforce import BaseSalesforceApiTask
|
|
6
10
|
from cumulusci.utils import inject_namespace
|
|
7
11
|
from cumulusci.utils.http.requests_utils import safe_json_from_response
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from cumulusci.core.config.util import get_devhub_config
|
|
2
|
+
from cumulusci.core.exceptions import SalesforceDXException
|
|
3
|
+
from cumulusci.core.tasks import BaseTask
|
|
4
|
+
from cumulusci.core.versions import PackageType, PackageVersionNumber
|
|
5
|
+
from cumulusci.salesforce_api.utils import get_simple_salesforce_connection
|
|
6
|
+
from cumulusci.utils.options import CCIOptions, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GetPackageVersion(BaseTask):
|
|
10
|
+
"""Custom task to get package version ID"""
|
|
11
|
+
|
|
12
|
+
class Options(CCIOptions):
|
|
13
|
+
package_name: str = Field(
|
|
14
|
+
..., description="Package name to get package version ID for."
|
|
15
|
+
)
|
|
16
|
+
package_version: str = Field(
|
|
17
|
+
..., description="Package version to get package version ID for."
|
|
18
|
+
)
|
|
19
|
+
prefix: str = Field(
|
|
20
|
+
"",
|
|
21
|
+
description="Prefix to add to the package name.",
|
|
22
|
+
)
|
|
23
|
+
suffix: str = Field(
|
|
24
|
+
"",
|
|
25
|
+
description="Suffix to add to the package name.",
|
|
26
|
+
)
|
|
27
|
+
fail_on_error: bool = Field(
|
|
28
|
+
False, description="Fail on error. [default to False]"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
parsed_options: Options
|
|
32
|
+
|
|
33
|
+
def _init_options(self, kwargs):
|
|
34
|
+
super()._init_options(kwargs)
|
|
35
|
+
self.parsed_options.package_version = PackageVersionNumber.parse(
|
|
36
|
+
self.parsed_options.package_version, package_type=PackageType.SECOND_GEN
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def _init_task(self):
|
|
40
|
+
self.tooling = get_simple_salesforce_connection(
|
|
41
|
+
self.project_config,
|
|
42
|
+
get_devhub_config(self.project_config),
|
|
43
|
+
api_version=self.project_config.project__package__api_version,
|
|
44
|
+
base_url="tooling",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _run_task(self):
|
|
48
|
+
package_name = f"{self.parsed_options.prefix}{self.parsed_options.package_name}{self.parsed_options.suffix}".strip()
|
|
49
|
+
|
|
50
|
+
query = (
|
|
51
|
+
f"SELECT Id, SubscriberPackageVersionId FROM Package2Version WHERE Package2.Name='{package_name}' AND "
|
|
52
|
+
f"MajorVersion={self.parsed_options.package_version.MajorVersion} AND "
|
|
53
|
+
f"MinorVersion={self.parsed_options.package_version.MinorVersion} AND "
|
|
54
|
+
f"PatchVersion={self.parsed_options.package_version.PatchVersion} AND "
|
|
55
|
+
f"BuildNumber={self.parsed_options.package_version.BuildNumber}"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
res = self.tooling.query(query)
|
|
59
|
+
if res["size"] == 0:
|
|
60
|
+
msg = f"Package version {package_name} {self.parsed_options.package_version} not found"
|
|
61
|
+
self.logger.warning(msg)
|
|
62
|
+
if self.parsed_options.fail_on_error:
|
|
63
|
+
raise SalesforceDXException(msg)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
if res["size"] > 1:
|
|
67
|
+
msg = f"Multiple package versions found for {package_name} {self.parsed_options.package_version}"
|
|
68
|
+
self.logger.warning(msg)
|
|
69
|
+
if self.parsed_options.fail_on_error:
|
|
70
|
+
raise SalesforceDXException(msg)
|
|
71
|
+
|
|
72
|
+
self.return_values["package_version_id"] = res["records"][0]["Id"]
|
|
73
|
+
self.return_values["subscriber_package_version_id"] = res["records"][0][
|
|
74
|
+
"SubscriberPackageVersionId"
|
|
75
|
+
]
|
|
76
|
+
self.return_values["package_name"] = package_name
|
|
77
|
+
self.return_values["package_version"] = self.parsed_options.package_version
|
|
78
|
+
|
|
79
|
+
self.logger.info(
|
|
80
|
+
f"Package version {package_name} {self.parsed_options.package_version} found"
|
|
81
|
+
)
|
|
82
|
+
self.logger.info(
|
|
83
|
+
f"Package version id: {self.return_values['package_version_id']}"
|
|
84
|
+
)
|
|
85
|
+
self.logger.info(
|
|
86
|
+
f"SubscriberPackageVersion Id: {self.return_values['subscriber_package_version_id']}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return self.return_values
|