databricks-labs-ucx 0.17.0__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.
- databricks/__init__.py +3 -0
- databricks/labs/__init__.py +3 -0
- databricks/labs/ucx/__about__.py +2 -0
- databricks/labs/ucx/__init__.py +3 -0
- databricks/labs/ucx/account.py +253 -0
- databricks/labs/ucx/assessment/__init__.py +0 -0
- databricks/labs/ucx/assessment/aws.py +391 -0
- databricks/labs/ucx/assessment/azure.py +173 -0
- databricks/labs/ucx/assessment/clusters.py +233 -0
- databricks/labs/ucx/assessment/crawlers.py +67 -0
- databricks/labs/ucx/assessment/init_scripts.py +87 -0
- databricks/labs/ucx/assessment/jobs.py +342 -0
- databricks/labs/ucx/assessment/pipelines.py +77 -0
- databricks/labs/ucx/assessment/secrets.py +69 -0
- databricks/labs/ucx/aws/__init__.py +0 -0
- databricks/labs/ucx/aws/access.py +369 -0
- databricks/labs/ucx/aws/credentials.py +208 -0
- databricks/labs/ucx/azure/__init__.py +0 -0
- databricks/labs/ucx/azure/access.py +236 -0
- databricks/labs/ucx/azure/credentials.py +283 -0
- databricks/labs/ucx/azure/locations.py +197 -0
- databricks/labs/ucx/azure/resources.py +376 -0
- databricks/labs/ucx/cli.py +503 -0
- databricks/labs/ucx/config.py +60 -0
- databricks/labs/ucx/configure.py +73 -0
- databricks/labs/ucx/framework/__init__.py +0 -0
- databricks/labs/ucx/framework/crawlers.py +122 -0
- databricks/labs/ucx/framework/dashboards.py +390 -0
- databricks/labs/ucx/framework/tasks.py +252 -0
- databricks/labs/ucx/framework/utils.py +25 -0
- databricks/labs/ucx/hive_metastore/__init__.py +5 -0
- databricks/labs/ucx/hive_metastore/catalog_schema.py +94 -0
- databricks/labs/ucx/hive_metastore/grants.py +309 -0
- databricks/labs/ucx/hive_metastore/locations.py +250 -0
- databricks/labs/ucx/hive_metastore/mapping.py +205 -0
- databricks/labs/ucx/hive_metastore/table_migrate.py +521 -0
- databricks/labs/ucx/hive_metastore/table_size.py +85 -0
- databricks/labs/ucx/hive_metastore/tables.py +270 -0
- databricks/labs/ucx/hive_metastore/tables.scala +115 -0
- databricks/labs/ucx/hive_metastore/udfs.py +119 -0
- databricks/labs/ucx/hive_metastore/verification.py +39 -0
- databricks/labs/ucx/install.py +1018 -0
- databricks/labs/ucx/installer/__init__.py +0 -0
- databricks/labs/ucx/installer/hms_lineage.py +82 -0
- databricks/labs/ucx/installer/policy.py +181 -0
- databricks/labs/ucx/mixins/README.md +1 -0
- databricks/labs/ucx/mixins/__init__.py +0 -0
- databricks/labs/ucx/mixins/fixtures.py +1163 -0
- databricks/labs/ucx/mixins/redash.py +186 -0
- databricks/labs/ucx/queries/assessment/azure/05_0_azure_service_principals.sql +10 -0
- databricks/labs/ucx/queries/assessment/estimates/00_0_metastore_assignment.md +21 -0
- databricks/labs/ucx/queries/assessment/estimates/00_2_is_metastore_assigned.sql +3 -0
- databricks/labs/ucx/queries/assessment/estimates/00_4_is_incompatible_submit_run_detected.sql +6 -0
- databricks/labs/ucx/queries/assessment/estimates/01_0_group_migration.md +21 -0
- databricks/labs/ucx/queries/assessment/estimates/01_2_group_migration.sql +3 -0
- databricks/labs/ucx/queries/assessment/estimates/01_5_group_migration_complexity.sql +10 -0
- databricks/labs/ucx/queries/assessment/estimates/02_0_data_modeling.md +10 -0
- databricks/labs/ucx/queries/assessment/estimates/02_2_uc_data_modeling.sql +3 -0
- databricks/labs/ucx/queries/assessment/estimates/02_5_uc_data_modeling_complexity.sql +9 -0
- databricks/labs/ucx/queries/assessment/estimates/03_0_data_migration.md +28 -0
- databricks/labs/ucx/queries/assessment/estimates/03_2_data_migration_summary.sql +3 -0
- databricks/labs/ucx/queries/assessment/estimates/03_5_data_migration_complexity.sql +11 -0
- databricks/labs/ucx/queries/assessment/main/00_0_compatibility.sql +6 -0
- databricks/labs/ucx/queries/assessment/main/00_1_count_total_databases.sql +4 -0
- databricks/labs/ucx/queries/assessment/main/00_2_count_table_failures.sql +4 -0
- databricks/labs/ucx/queries/assessment/main/00_3_count_total_tables.sql +4 -0
- databricks/labs/ucx/queries/assessment/main/00_4_count_external_locations.sql +4 -0
- databricks/labs/ucx/queries/assessment/main/00_5_count_total_views.sql +3 -0
- databricks/labs/ucx/queries/assessment/main/01_0_count_jobs.sql +3 -0
- databricks/labs/ucx/queries/assessment/main/01_2_assessment_summary.sql +7 -0
- databricks/labs/ucx/queries/assessment/main/02_0_object_readiness.sql +9 -0
- databricks/labs/ucx/queries/assessment/main/02_2_count_table_by_storage.sql +19 -0
- databricks/labs/ucx/queries/assessment/main/02_4_count_table_by_schema_format.sql +22 -0
- databricks/labs/ucx/queries/assessment/main/03_0_database_summary.sql +53 -0
- databricks/labs/ucx/queries/assessment/main/04_0_external_locations.sql +4 -0
- databricks/labs/ucx/queries/assessment/main/04_3_mount_points.sql +5 -0
- databricks/labs/ucx/queries/assessment/main/05_0_all_tables.sql +31 -0
- databricks/labs/ucx/queries/assessment/main/05_3_job_details.sql +11 -0
- databricks/labs/ucx/queries/assessment/main/06_0_clusters.sql +9 -0
- databricks/labs/ucx/queries/assessment/main/06_3_jobs.sql +10 -0
- databricks/labs/ucx/queries/assessment/main/06_4_submit_runs.sql +8 -0
- databricks/labs/ucx/queries/assessment/main/06_5_submit_runs.sql +8 -0
- databricks/labs/ucx/queries/assessment/main/07_0_pipelines.sql +8 -0
- databricks/labs/ucx/queries/assessment/main/07_3_global_init_scripts.sql +9 -0
- databricks/labs/ucx/queries/assessment/main/08_0_cluster_policies.sql +9 -0
- databricks/labs/ucx/queries/assessment/main/README.md +6 -0
- databricks/labs/ucx/queries/views/grant_detail.sql +35 -0
- databricks/labs/ucx/queries/views/objects.sql +33 -0
- databricks/labs/ucx/queries/views/table_estimates.sql +13 -0
- databricks/labs/ucx/runtime.py +408 -0
- databricks/labs/ucx/uninstall.py +14 -0
- databricks/labs/ucx/upgrades/v0.15.0_added_cluster_policy.py +23 -0
- databricks/labs/ucx/upgrades/v0.16.0_changing_cluster_table_schema.py +17 -0
- databricks/labs/ucx/upgrades/v0.4.0_added_log_dir.py +11 -0
- databricks/labs/ucx/workspace_access/__init__.py +0 -0
- databricks/labs/ucx/workspace_access/base.py +38 -0
- databricks/labs/ucx/workspace_access/generic.py +445 -0
- databricks/labs/ucx/workspace_access/groups.py +728 -0
- databricks/labs/ucx/workspace_access/listing.py +109 -0
- databricks/labs/ucx/workspace_access/manager.py +216 -0
- databricks/labs/ucx/workspace_access/redash.py +271 -0
- databricks/labs/ucx/workspace_access/scim.py +159 -0
- databricks/labs/ucx/workspace_access/secrets.py +122 -0
- databricks/labs/ucx/workspace_access/tacl.py +136 -0
- databricks_labs_ucx-0.17.0.dist-info/METADATA +751 -0
- databricks_labs_ucx-0.17.0.dist-info/RECORD +110 -0
- databricks_labs_ucx-0.17.0.dist-info/WHEEL +4 -0
- databricks_labs_ucx-0.17.0.dist-info/entry_points.txt +2 -0
- databricks_labs_ucx-0.17.0.dist-info/licenses/LICENSE +69 -0
- databricks_labs_ucx-0.17.0.dist-info/licenses/NOTICE +16 -0
databricks/__init__.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from databricks.labs.blueprint.installation import Installation
|
|
5
|
+
from databricks.labs.blueprint.tui import Prompts
|
|
6
|
+
from databricks.sdk import AccountClient, WorkspaceClient
|
|
7
|
+
from databricks.sdk.errors import NotFound, ResourceConflict
|
|
8
|
+
from databricks.sdk.service.iam import ComplexValue, Group, Patch, PatchOp, PatchSchema
|
|
9
|
+
from databricks.sdk.service.provisioning import Workspace
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AccountWorkspaces:
|
|
15
|
+
_tlds: ClassVar[dict[str, str]] = {
|
|
16
|
+
"aws": "cloud.databricks.com",
|
|
17
|
+
"azure": "azuredatabricks.net",
|
|
18
|
+
"gcp": "gcp.databricks.com",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SYNC_FILE_NAME: ClassVar[str] = "workspaces.json"
|
|
22
|
+
|
|
23
|
+
def __init__(self, account_client: AccountClient, new_workspace_client=WorkspaceClient):
|
|
24
|
+
self._new_workspace_client = new_workspace_client
|
|
25
|
+
self._ac = account_client
|
|
26
|
+
|
|
27
|
+
def _workspaces(self):
|
|
28
|
+
return self._ac.workspaces.list()
|
|
29
|
+
|
|
30
|
+
def _get_cloud(self) -> str:
|
|
31
|
+
if self._ac.config.is_azure:
|
|
32
|
+
return "azure"
|
|
33
|
+
if self._ac.config.is_gcp:
|
|
34
|
+
return "gcp"
|
|
35
|
+
return "aws"
|
|
36
|
+
|
|
37
|
+
def client_for(self, workspace: Workspace) -> WorkspaceClient:
|
|
38
|
+
return self._ac.get_workspace_client(workspace)
|
|
39
|
+
|
|
40
|
+
def workspace_clients(self) -> list[WorkspaceClient]:
|
|
41
|
+
"""
|
|
42
|
+
Return a list of WorkspaceClient for each configured workspace in the account
|
|
43
|
+
:return: list[WorkspaceClient]
|
|
44
|
+
"""
|
|
45
|
+
clients = []
|
|
46
|
+
for workspace in self._workspaces():
|
|
47
|
+
ws = self.client_for(workspace)
|
|
48
|
+
clients.append(ws)
|
|
49
|
+
return clients
|
|
50
|
+
|
|
51
|
+
def sync_workspace_info(self):
|
|
52
|
+
"""
|
|
53
|
+
Create a json dump for each Workspace in account
|
|
54
|
+
For each user that has ucx installed in their workspace,
|
|
55
|
+
upload the json dump of workspace info in the .ucx folder
|
|
56
|
+
"""
|
|
57
|
+
workspaces = []
|
|
58
|
+
for workspace in self._workspaces():
|
|
59
|
+
workspaces.append(workspace)
|
|
60
|
+
for ws in self.workspace_clients():
|
|
61
|
+
for installation in Installation.existing(ws, "ucx"):
|
|
62
|
+
installation.save(workspaces, filename=self.SYNC_FILE_NAME)
|
|
63
|
+
|
|
64
|
+
def create_account_level_groups(self, prompts: Prompts, workspace_ids: list[int] | None = None):
|
|
65
|
+
acc_groups = self._get_account_groups()
|
|
66
|
+
workspace_ids = self._get_valid_workspaces_ids(workspace_ids)
|
|
67
|
+
all_valid_workspace_groups = self._get_valid_workspaces_groups(prompts, workspace_ids)
|
|
68
|
+
|
|
69
|
+
for group_name, valid_group in all_valid_workspace_groups.items():
|
|
70
|
+
acc_group = self._try_create_account_groups(group_name, acc_groups)
|
|
71
|
+
|
|
72
|
+
if not acc_group or not valid_group.members or not acc_group.id:
|
|
73
|
+
continue
|
|
74
|
+
if len(valid_group.members) > 0:
|
|
75
|
+
self._add_members_to_acc_group(self._ac, acc_group.id, group_name, valid_group)
|
|
76
|
+
logger.info(f"Group {group_name} created in the account")
|
|
77
|
+
|
|
78
|
+
def _try_create_account_groups(
|
|
79
|
+
self, group_name: str, acc_groups: dict[str | None, list[ComplexValue] | None]
|
|
80
|
+
) -> Group | None:
|
|
81
|
+
try:
|
|
82
|
+
if group_name in acc_groups:
|
|
83
|
+
logger.info(f"Group {group_name} already exist in the account, ignoring")
|
|
84
|
+
return None
|
|
85
|
+
return self._ac.groups.create(display_name=group_name)
|
|
86
|
+
except ResourceConflict:
|
|
87
|
+
logger.info(f"Group {group_name} already exist in the account, ignoring")
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _get_valid_workspaces_ids(self, workspace_ids: list[int] | None = None) -> list[int]:
|
|
91
|
+
if not workspace_ids:
|
|
92
|
+
logger.info("No workspace ids provided, using current workspace instead")
|
|
93
|
+
return [self._new_workspace_client().get_workspace_id()]
|
|
94
|
+
|
|
95
|
+
all_workspace_ids = [workspace.workspace_id for workspace in self._workspaces()]
|
|
96
|
+
|
|
97
|
+
valid_workspace_ids = []
|
|
98
|
+
for workspace_id in workspace_ids:
|
|
99
|
+
if workspace_id in all_workspace_ids:
|
|
100
|
+
valid_workspace_ids.append(workspace_id)
|
|
101
|
+
else:
|
|
102
|
+
logger.info(f"Workspace id {workspace_id} not found on the account")
|
|
103
|
+
|
|
104
|
+
if not valid_workspace_ids:
|
|
105
|
+
raise ValueError("No workspace ids provided in the configuration found in the account")
|
|
106
|
+
|
|
107
|
+
workspace_ids_str = ','.join(str(x) for x in valid_workspace_ids)
|
|
108
|
+
logger.info(f"Creating account groups for workspaces IDs : {workspace_ids_str}")
|
|
109
|
+
return valid_workspace_ids
|
|
110
|
+
|
|
111
|
+
def _add_members_to_acc_group(
|
|
112
|
+
self, acc_client: AccountClient, acc_group_id: str, group_name: str, valid_group: Group
|
|
113
|
+
):
|
|
114
|
+
for chunk in self._chunks(valid_group.members, 20):
|
|
115
|
+
logger.debug(f"Adding {len(chunk)} members to acc group {group_name}")
|
|
116
|
+
acc_client.groups.patch(
|
|
117
|
+
acc_group_id,
|
|
118
|
+
operations=[Patch(op=PatchOp.ADD, path="members", value=[x.as_dict() for x in chunk])],
|
|
119
|
+
schemas=[PatchSchema.URN_IETF_PARAMS_SCIM_API_MESSAGES_2_0_PATCH_OP],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def _chunks(self, lst, chunk_size):
|
|
123
|
+
"""Yield successive n-sized chunks from lst."""
|
|
124
|
+
for i in range(0, len(lst), chunk_size):
|
|
125
|
+
yield lst[i : i + chunk_size]
|
|
126
|
+
|
|
127
|
+
def _get_valid_workspaces_groups(self, prompts: Prompts, workspace_ids: list[int]) -> dict[str, Group]:
|
|
128
|
+
all_workspaces_groups: dict[str, Group] = {}
|
|
129
|
+
|
|
130
|
+
for workspace in self._workspaces():
|
|
131
|
+
if workspace.workspace_id not in workspace_ids:
|
|
132
|
+
continue
|
|
133
|
+
self._load_workspace_groups(prompts, workspace, all_workspaces_groups)
|
|
134
|
+
|
|
135
|
+
return all_workspaces_groups
|
|
136
|
+
|
|
137
|
+
def _load_workspace_groups(self, prompts, workspace, all_workspaces_groups):
|
|
138
|
+
client = self.client_for(workspace)
|
|
139
|
+
logger.info(f"Crawling groups in workspace {client.config.host}")
|
|
140
|
+
ws_group_ids = client.groups.list(attributes="id")
|
|
141
|
+
for group_id in ws_group_ids:
|
|
142
|
+
full_workspace_group = self._safe_groups_get(client, group_id.id)
|
|
143
|
+
if not full_workspace_group:
|
|
144
|
+
continue
|
|
145
|
+
group_name = full_workspace_group.display_name
|
|
146
|
+
if self._is_group_out_of_scope(full_workspace_group):
|
|
147
|
+
continue
|
|
148
|
+
if not group_name:
|
|
149
|
+
continue
|
|
150
|
+
if group_name in all_workspaces_groups:
|
|
151
|
+
if self._has_same_members(all_workspaces_groups[group_name], full_workspace_group):
|
|
152
|
+
logger.info(f"Workspace group {group_name} already found, ignoring")
|
|
153
|
+
continue
|
|
154
|
+
if prompts.confirm(
|
|
155
|
+
f"Group {group_name} does not have the same amount of members "
|
|
156
|
+
f"in workspace {client.config.host} than previous workspaces which contains the same group name,"
|
|
157
|
+
f"it will be created at the account with name : {workspace.workspace_name}_{group_name}"
|
|
158
|
+
):
|
|
159
|
+
all_workspaces_groups[f"{workspace.workspace_name}_{group_name}"] = full_workspace_group
|
|
160
|
+
continue
|
|
161
|
+
logger.info(f"Found new group {group_name}")
|
|
162
|
+
all_workspaces_groups[group_name] = full_workspace_group
|
|
163
|
+
logger.info(f"Found a total of {len(all_workspaces_groups)} groups to migrate to the account")
|
|
164
|
+
|
|
165
|
+
def _is_group_out_of_scope(self, group: Group) -> bool:
|
|
166
|
+
if group.display_name in {"users", "admins", "account users"}:
|
|
167
|
+
logger.debug(f"Group {group.display_name} is a system group, ignoring")
|
|
168
|
+
return True
|
|
169
|
+
meta = group.meta
|
|
170
|
+
if not meta:
|
|
171
|
+
return False
|
|
172
|
+
if meta.resource_type != "WorkspaceGroup":
|
|
173
|
+
logger.debug(f"Group {group.display_name} is an account group, ignoring")
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def _has_same_members(self, group_1: Group, group_2: Group) -> bool:
|
|
178
|
+
ws_members_set_1 = set([m.display for m in group_1.members] if group_1.members else [])
|
|
179
|
+
ws_members_set_2 = set([m.display for m in group_2.members] if group_2.members else [])
|
|
180
|
+
return not bool((ws_members_set_1 - ws_members_set_2).union(ws_members_set_2 - ws_members_set_1))
|
|
181
|
+
|
|
182
|
+
def _get_account_groups(self) -> dict[str | None, list[ComplexValue] | None]:
|
|
183
|
+
logger.debug("Listing groups in account")
|
|
184
|
+
acc_groups = {}
|
|
185
|
+
for acc_grp_id in self._ac.groups.list(attributes="id"):
|
|
186
|
+
if not acc_grp_id.id:
|
|
187
|
+
continue
|
|
188
|
+
full_account_group = self._safe_groups_get(self._ac, acc_grp_id.id)
|
|
189
|
+
if not full_account_group:
|
|
190
|
+
continue
|
|
191
|
+
logger.debug(f"Found account group {full_account_group.display_name}")
|
|
192
|
+
acc_groups[full_account_group.display_name] = full_account_group.members
|
|
193
|
+
|
|
194
|
+
logger.info(f"{len(acc_groups)} account groups found")
|
|
195
|
+
return acc_groups
|
|
196
|
+
|
|
197
|
+
def _safe_groups_get(self, interface, group_id) -> Group | None:
|
|
198
|
+
try:
|
|
199
|
+
if not group_id:
|
|
200
|
+
return None
|
|
201
|
+
return interface.groups.get(group_id)
|
|
202
|
+
except NotFound:
|
|
203
|
+
logger.info(f"Group {group_id} has been deleted")
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class WorkspaceInfo:
|
|
208
|
+
def __init__(self, installation: Installation, ws: WorkspaceClient):
|
|
209
|
+
self._installation = installation
|
|
210
|
+
self._ws = ws
|
|
211
|
+
|
|
212
|
+
def _load_workspace_info(self) -> dict[int, Workspace]:
|
|
213
|
+
try:
|
|
214
|
+
id_to_workspace = {}
|
|
215
|
+
for workspace in self._installation.load(list[Workspace], filename=AccountWorkspaces.SYNC_FILE_NAME):
|
|
216
|
+
assert workspace.workspace_id is not None
|
|
217
|
+
id_to_workspace[workspace.workspace_id] = workspace
|
|
218
|
+
return id_to_workspace
|
|
219
|
+
except NotFound:
|
|
220
|
+
msg = "Please run as account-admin: databricks labs ucx sync-workspace-info"
|
|
221
|
+
raise ValueError(msg) from None
|
|
222
|
+
|
|
223
|
+
def current(self) -> str:
|
|
224
|
+
workspace_id = self._ws.get_workspace_id()
|
|
225
|
+
workspaces = self._load_workspace_info()
|
|
226
|
+
if workspace_id not in workspaces:
|
|
227
|
+
msg = f"Current workspace is not known: {workspace_id}"
|
|
228
|
+
raise KeyError(msg) from None
|
|
229
|
+
workspace = workspaces[workspace_id]
|
|
230
|
+
assert workspace.workspace_name, "workspace name undefined"
|
|
231
|
+
return workspace.workspace_name
|
|
232
|
+
|
|
233
|
+
def manual_workspace_info(self, prompts: Prompts):
|
|
234
|
+
logger.warning(
|
|
235
|
+
'You are strongly recommended to run "databricks labs ucx sync-workspace-info" by account admin,'
|
|
236
|
+
' otherwise there is a significant risk of inconsistencies between different workspaces. This '
|
|
237
|
+
'command will overwrite all UCX installations on this given workspace. Result may be consistent '
|
|
238
|
+
f'only within {self._ws.config.host}'
|
|
239
|
+
)
|
|
240
|
+
workspaces = []
|
|
241
|
+
workspace_id = self._ws.get_workspace_id()
|
|
242
|
+
while workspace_id:
|
|
243
|
+
workspace_name = prompts.question(
|
|
244
|
+
f"Workspace name for {workspace_id}", default=f"workspace-{workspace_id}", valid_regex=r"^[\w-]+$"
|
|
245
|
+
)
|
|
246
|
+
workspaces.append(Workspace(workspace_id=int(workspace_id), workspace_name=workspace_name))
|
|
247
|
+
answer = prompts.question("Next workspace id", valid_number=True, default="stop")
|
|
248
|
+
if answer == "stop":
|
|
249
|
+
break
|
|
250
|
+
workspace_id = int(answer)
|
|
251
|
+
for installation in Installation.existing(self._ws, 'ucx'):
|
|
252
|
+
installation.save(workspaces, filename=AccountWorkspaces.SYNC_FILE_NAME)
|
|
253
|
+
logger.info("Synchronised workspace id mapping for installations on current workspace")
|
|
File without changes
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import typing
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
from databricks.sdk.service.catalog import Privilege
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class AWSRole:
|
|
18
|
+
path: str
|
|
19
|
+
role_name: str
|
|
20
|
+
role_id: str
|
|
21
|
+
arn: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AWSPolicyAction:
|
|
26
|
+
resource_type: str
|
|
27
|
+
privilege: str
|
|
28
|
+
resource_path: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class AWSRoleAction:
|
|
33
|
+
role_arn: str
|
|
34
|
+
resource_type: str
|
|
35
|
+
privilege: str
|
|
36
|
+
resource_path: str
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def role_name(self):
|
|
40
|
+
role_match = re.match(AWSInstanceProfile.ROLE_NAME_REGEX, self.role_arn)
|
|
41
|
+
return role_match.group(1)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AWSInstanceProfile:
|
|
46
|
+
instance_profile_arn: str
|
|
47
|
+
iam_role_arn: str | None = None
|
|
48
|
+
|
|
49
|
+
ROLE_NAME_REGEX = r"arn:aws:iam::[0-9]+:(?:instance-profile|role)\/([a-zA-Z0-9+=,.@_-]*)$"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def role_name(self) -> str | None:
|
|
53
|
+
if self.iam_role_arn:
|
|
54
|
+
arn = self.iam_role_arn
|
|
55
|
+
else:
|
|
56
|
+
arn = self.instance_profile_arn
|
|
57
|
+
role_match = re.match(self.ROLE_NAME_REGEX, arn)
|
|
58
|
+
if not role_match:
|
|
59
|
+
logger.error(f"Role ARN is mismatched {self.iam_role_arn}")
|
|
60
|
+
return None
|
|
61
|
+
return role_match.group(1)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@lru_cache(maxsize=1024)
|
|
65
|
+
def run_command(command):
|
|
66
|
+
logger.info(f"Invoking Command {command}")
|
|
67
|
+
with subprocess.Popen(command.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) as process:
|
|
68
|
+
output, error = process.communicate()
|
|
69
|
+
return process.returncode, output.decode("utf-8"), error.decode("utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AWSResources:
|
|
73
|
+
S3_ACTIONS: typing.ClassVar[set[str]] = {"s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:PutObjectAcl"}
|
|
74
|
+
S3_READONLY: typing.ClassVar[str] = "s3:GetObject"
|
|
75
|
+
S3_REGEX: typing.ClassVar[str] = r"arn:aws:s3:::([a-zA-Z0-9\/+=,.@_-]*)\/\*$"
|
|
76
|
+
S3_PREFIX: typing.ClassVar[str] = "arn:aws:s3:::"
|
|
77
|
+
S3_PATH_REGEX: typing.ClassVar[str] = r"((s3:\/\/)|(s3a:\/\/))(.*)"
|
|
78
|
+
UC_MASTER_ROLES_ARN: typing.ClassVar[list[str]] = [
|
|
79
|
+
"arn:aws:iam::414351767826:role/unity-catalog-prod-UCMasterRole-14S5ZJVKOTYTL",
|
|
80
|
+
"arn:aws:iam::707343435239:role/unity-catalog-dev-UCMasterRole-G3MMN8SP21FO",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
def __init__(self, profile: str, command_runner: Callable[[str], tuple[int, str, str]] = run_command):
|
|
84
|
+
self._profile = profile
|
|
85
|
+
self._command_runner = command_runner
|
|
86
|
+
|
|
87
|
+
def validate_connection(self):
|
|
88
|
+
validate_command = f"sts get-caller-identity --profile {self._profile}"
|
|
89
|
+
result = self._run_json_command(validate_command)
|
|
90
|
+
if result:
|
|
91
|
+
logger.info(result)
|
|
92
|
+
return result
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def list_role_policies(self, role_name: str):
|
|
96
|
+
list_policies_cmd = f"iam list-role-policies --profile {self._profile} --role-name {role_name}"
|
|
97
|
+
policies = self._run_json_command(list_policies_cmd)
|
|
98
|
+
if not policies:
|
|
99
|
+
return []
|
|
100
|
+
return policies.get("PolicyNames", [])
|
|
101
|
+
|
|
102
|
+
def list_attached_policies_in_role(self, role_name: str):
|
|
103
|
+
list_attached_policies_cmd = (
|
|
104
|
+
f"iam list-attached-role-policies --profile {self._profile} --role-name {role_name}"
|
|
105
|
+
)
|
|
106
|
+
policies = self._run_json_command(list_attached_policies_cmd)
|
|
107
|
+
if not policies:
|
|
108
|
+
return []
|
|
109
|
+
attached_policies = []
|
|
110
|
+
for policy in policies.get("AttachedPolicies", []):
|
|
111
|
+
attached_policies.append(policy.get("PolicyArn"))
|
|
112
|
+
return attached_policies
|
|
113
|
+
|
|
114
|
+
def list_all_uc_roles(self) -> list[AWSRole]:
|
|
115
|
+
roles = self._run_json_command(f"iam list-roles --profile {self._profile}")
|
|
116
|
+
uc_roles: list[AWSRole] = []
|
|
117
|
+
roles = roles.get("Roles")
|
|
118
|
+
if not roles:
|
|
119
|
+
logger.warning("list-roles couldn't find any roles")
|
|
120
|
+
return uc_roles
|
|
121
|
+
for role in roles:
|
|
122
|
+
policy_document = role.get("AssumeRolePolicyDocument")
|
|
123
|
+
if not policy_document:
|
|
124
|
+
continue
|
|
125
|
+
for statement in policy_document["Statement"]:
|
|
126
|
+
effect = statement.get("Effect")
|
|
127
|
+
action = statement.get("Action")
|
|
128
|
+
principal = statement.get("Principal")
|
|
129
|
+
if not (effect and action and principal):
|
|
130
|
+
continue
|
|
131
|
+
if effect != "Allow":
|
|
132
|
+
continue
|
|
133
|
+
if action != "sts:AssumeRole":
|
|
134
|
+
continue
|
|
135
|
+
principal = principal.get("AWS")
|
|
136
|
+
if not principal:
|
|
137
|
+
continue
|
|
138
|
+
if not self._is_uc_principal(principal):
|
|
139
|
+
continue
|
|
140
|
+
uc_roles.append(
|
|
141
|
+
AWSRole(
|
|
142
|
+
role_id=role.get("RoleId"),
|
|
143
|
+
role_name=role.get("RoleName"),
|
|
144
|
+
arn=role.get("Arn"),
|
|
145
|
+
path=role.get("Path"),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return uc_roles
|
|
150
|
+
|
|
151
|
+
def _is_uc_principal(self, principal):
|
|
152
|
+
if isinstance(principal, list):
|
|
153
|
+
for single_principal in principal:
|
|
154
|
+
if single_principal in self.UC_MASTER_ROLES_ARN:
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
return principal in self.UC_MASTER_ROLES_ARN
|
|
158
|
+
|
|
159
|
+
def get_role_policy(self, role_name, policy_name: str | None = None, attached_policy_arn: str | None = None):
|
|
160
|
+
if policy_name:
|
|
161
|
+
get_policy = (
|
|
162
|
+
f"iam get-role-policy --profile {self._profile} --role-name {role_name} " f"--policy-name {policy_name}"
|
|
163
|
+
)
|
|
164
|
+
elif attached_policy_arn:
|
|
165
|
+
get_attached_policy = f"iam get-policy --profile {self._profile} --policy-arn {attached_policy_arn}"
|
|
166
|
+
attached_policy = self._run_json_command(get_attached_policy)
|
|
167
|
+
if not attached_policy:
|
|
168
|
+
return []
|
|
169
|
+
policy_version = attached_policy["Policy"]["DefaultVersionId"]
|
|
170
|
+
get_policy = (
|
|
171
|
+
f"iam get-policy-version --profile {self._profile} --policy-arn {attached_policy_arn} "
|
|
172
|
+
f"--version-id {policy_version}"
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
logger.error("Failed to retrieve role. No role name or attached role ARN specified.")
|
|
176
|
+
return []
|
|
177
|
+
policy = self._run_json_command(get_policy)
|
|
178
|
+
if not policy:
|
|
179
|
+
return []
|
|
180
|
+
if policy_name:
|
|
181
|
+
actions = policy["PolicyDocument"].get("Statement", [])
|
|
182
|
+
else:
|
|
183
|
+
actions = policy["PolicyVersion"]["Document"].get("Statement", [])
|
|
184
|
+
return self._policy_actions(actions)
|
|
185
|
+
|
|
186
|
+
def _policy_actions(self, actions):
|
|
187
|
+
policy_actions = []
|
|
188
|
+
for action in actions:
|
|
189
|
+
if action.get("Effect", "Deny") != "Allow":
|
|
190
|
+
continue
|
|
191
|
+
actions = action["Action"]
|
|
192
|
+
s3_actions = self._s3_actions(actions)
|
|
193
|
+
if not s3_actions or self.S3_READONLY not in s3_actions:
|
|
194
|
+
continue
|
|
195
|
+
privilege = Privilege.WRITE_FILES.value
|
|
196
|
+
for s3_action_type in self.S3_ACTIONS:
|
|
197
|
+
if s3_action_type not in s3_actions:
|
|
198
|
+
privilege = Privilege.READ_FILES.value
|
|
199
|
+
continue
|
|
200
|
+
for resource in action.get("Resource", []):
|
|
201
|
+
match = re.match(self.S3_REGEX, resource)
|
|
202
|
+
if match:
|
|
203
|
+
policy_actions.append(AWSPolicyAction("s3", privilege, f"s3://{match.group(1)}"))
|
|
204
|
+
policy_actions.append(AWSPolicyAction("s3", privilege, f"s3a://{match.group(1)}"))
|
|
205
|
+
return policy_actions
|
|
206
|
+
|
|
207
|
+
def _s3_actions(self, actions):
|
|
208
|
+
s3_actions = []
|
|
209
|
+
if isinstance(actions, list):
|
|
210
|
+
for single_action in actions:
|
|
211
|
+
if single_action in self.S3_ACTIONS:
|
|
212
|
+
s3_actions.append(single_action)
|
|
213
|
+
continue
|
|
214
|
+
elif actions in self.S3_ACTIONS:
|
|
215
|
+
s3_actions = [actions]
|
|
216
|
+
return s3_actions
|
|
217
|
+
|
|
218
|
+
def _aws_role_trust_doc(self, external_id="0000"):
|
|
219
|
+
return self._get_json_for_cli(
|
|
220
|
+
{
|
|
221
|
+
"Version": "2012-10-17",
|
|
222
|
+
"Statement": [
|
|
223
|
+
{
|
|
224
|
+
"Effect": "Allow",
|
|
225
|
+
"Principal": {
|
|
226
|
+
"AWS": "arn:aws:iam::414351767826:role/unity-catalog-prod-UCMasterRole-14S5ZJVKOTYTL"
|
|
227
|
+
},
|
|
228
|
+
"Action": "sts:AssumeRole",
|
|
229
|
+
"Condition": {"StringEquals": {"sts:ExternalId": external_id}},
|
|
230
|
+
}
|
|
231
|
+
],
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _aws_s3_policy(self, s3_prefixes, account_id, role_name, kms_key=None):
|
|
236
|
+
"""
|
|
237
|
+
Create the UC IAM policy for the given S3 prefixes, account ID, role name, and KMS key.
|
|
238
|
+
"""
|
|
239
|
+
s3_prefixes_strip = set()
|
|
240
|
+
for path in s3_prefixes:
|
|
241
|
+
match = re.match(AWSResources.S3_PATH_REGEX, path)
|
|
242
|
+
if match:
|
|
243
|
+
s3_prefixes_strip.add(match.group(4))
|
|
244
|
+
|
|
245
|
+
s3_prefixes_enriched = sorted([self.S3_PREFIX + s3_prefix for s3_prefix in s3_prefixes_strip])
|
|
246
|
+
statement = [
|
|
247
|
+
{
|
|
248
|
+
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:GetBucketLocation"],
|
|
249
|
+
"Resource": s3_prefixes_enriched,
|
|
250
|
+
"Effect": "Allow",
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
"Action": ["sts:AssumeRole"],
|
|
254
|
+
"Resource": [f"arn:aws:iam::{account_id}:role/{role_name}"],
|
|
255
|
+
"Effect": "Allow",
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
if kms_key:
|
|
259
|
+
statement.append(
|
|
260
|
+
{
|
|
261
|
+
"Action": ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey*"],
|
|
262
|
+
"Resource": [f"arn:aws:kms:{kms_key}"],
|
|
263
|
+
"Effect": "Allow",
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
return self._get_json_for_cli(
|
|
267
|
+
{
|
|
268
|
+
"Version": "2012-10-17",
|
|
269
|
+
"Statement": statement,
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _create_role(self, role_name: str, assume_role_json: str) -> str | None:
|
|
274
|
+
"""
|
|
275
|
+
Create an AWS role with the given name and assume role policy document.
|
|
276
|
+
"""
|
|
277
|
+
add_role = self._run_json_command(
|
|
278
|
+
f"iam create-role --role-name {role_name} --assume-role-policy-document {assume_role_json}"
|
|
279
|
+
)
|
|
280
|
+
if not add_role:
|
|
281
|
+
return None
|
|
282
|
+
return add_role["Role"]["Arn"]
|
|
283
|
+
|
|
284
|
+
def create_uc_role(self, role_name: str) -> str | None:
|
|
285
|
+
"""
|
|
286
|
+
Create an IAM role for Unity Catalog to access the S3 buckets.
|
|
287
|
+
the AssumeRole condition will be modified later with the external ID captured from the UC credential.
|
|
288
|
+
https://docs.databricks.com/en/connect/unity-catalog/storage-credentials.html
|
|
289
|
+
"""
|
|
290
|
+
return self._create_role(role_name, self._aws_role_trust_doc())
|
|
291
|
+
|
|
292
|
+
def update_uc_trust_role(self, role_name: str, external_id: str = "0000") -> str | None:
|
|
293
|
+
"""
|
|
294
|
+
Modify an existing IAM role for Unity Catalog to access the S3 buckets with the external ID
|
|
295
|
+
captured from the UC credential.
|
|
296
|
+
https://docs.databricks.com/en/connect/unity-catalog/storage-credentials.html
|
|
297
|
+
"""
|
|
298
|
+
update_role = self._run_json_command(
|
|
299
|
+
f"iam update-assume-role-policy --role-name {role_name} --policy-document {self._aws_role_trust_doc(external_id)}"
|
|
300
|
+
)
|
|
301
|
+
if not update_role:
|
|
302
|
+
return None
|
|
303
|
+
return update_role["Role"]["Arn"]
|
|
304
|
+
|
|
305
|
+
def put_role_policy(
|
|
306
|
+
self, role_name: str, policy_name: str, s3_prefixes: set[str], account_id: str, kms_key=None
|
|
307
|
+
) -> bool:
|
|
308
|
+
if not self._run_command(
|
|
309
|
+
f"iam put-role-policy --role-name {role_name} "
|
|
310
|
+
f"--policy-name {policy_name} "
|
|
311
|
+
f"--policy-document {self._aws_s3_policy(s3_prefixes, account_id, role_name, kms_key)}"
|
|
312
|
+
):
|
|
313
|
+
return False
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
def create_migration_role(self, role_name: str) -> str | None:
|
|
317
|
+
aws_role_trust_doc = {
|
|
318
|
+
"Version": "2012-10-17",
|
|
319
|
+
"Statement": [
|
|
320
|
+
{"Effect": "Allow", "Principal": {"Service": "ec2.amazonaws.com"}, "Action": "sts:AssumeRole"}
|
|
321
|
+
],
|
|
322
|
+
}
|
|
323
|
+
assume_role_json = self._get_json_for_cli(aws_role_trust_doc)
|
|
324
|
+
return self._create_role(role_name, assume_role_json)
|
|
325
|
+
|
|
326
|
+
def get_instance_profile(self, instance_profile_name: str) -> str | None:
|
|
327
|
+
instance_profile = self._run_json_command(
|
|
328
|
+
f"iam get-instance-profile --instance-profile-name {instance_profile_name}"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if not instance_profile:
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
return instance_profile["InstanceProfile"]["Arn"]
|
|
335
|
+
|
|
336
|
+
def create_instance_profile(self, instance_profile_name: str) -> str | None:
|
|
337
|
+
instance_profile = self._run_json_command(
|
|
338
|
+
f"iam create-instance-profile --instance-profile-name {instance_profile_name}"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if not instance_profile:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
return instance_profile["InstanceProfile"]["Arn"]
|
|
345
|
+
|
|
346
|
+
def delete_instance_profile(self, instance_profile_name: str, role_name: str):
|
|
347
|
+
self._run_json_command(
|
|
348
|
+
f"iam remove-role-from-instance-profile --instance-profile-name {instance_profile_name}"
|
|
349
|
+
f" --role-name {role_name}"
|
|
350
|
+
)
|
|
351
|
+
self._run_json_command(f"iam delete-instance-profile --instance-profile-name {instance_profile_name}")
|
|
352
|
+
self._run_json_command(f"iam delete-role --role-name {role_name}")
|
|
353
|
+
|
|
354
|
+
def add_role_to_instance_profile(self, instance_profile_name: str, role_name: str):
|
|
355
|
+
# there can only be one role associated with iam instance profile
|
|
356
|
+
self._run_command(
|
|
357
|
+
f"iam add-role-to-instance-profile --instance-profile-name {instance_profile_name} --role-name {role_name}"
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def role_exists(self, role_name: str) -> bool:
|
|
361
|
+
"""
|
|
362
|
+
Check if the given role exists in the AWS account.
|
|
363
|
+
"""
|
|
364
|
+
result = self._run_json_command("iam list-roles")
|
|
365
|
+
roles = result.get("Roles", [])
|
|
366
|
+
for role in roles:
|
|
367
|
+
if role["RoleName"] == role_name:
|
|
368
|
+
return True
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
def _run_json_command(self, command: str):
|
|
372
|
+
aws_cmd = shutil.which("aws")
|
|
373
|
+
code, output, error = self._command_runner(f"{aws_cmd} {command} --output json")
|
|
374
|
+
if code != 0:
|
|
375
|
+
logger.error(error)
|
|
376
|
+
return None
|
|
377
|
+
if output == "":
|
|
378
|
+
return {}
|
|
379
|
+
return json.loads(output)
|
|
380
|
+
|
|
381
|
+
def _run_command(self, command: str):
|
|
382
|
+
aws_cmd = shutil.which("aws")
|
|
383
|
+
code, _, error = self._command_runner(f"{aws_cmd} {command} --output json")
|
|
384
|
+
if code != 0:
|
|
385
|
+
logger.error(error)
|
|
386
|
+
return False
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _get_json_for_cli(input_json: dict) -> str:
|
|
391
|
+
return json.dumps(input_json).replace('\n', '').replace(" ", "")
|