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.
Files changed (110) hide show
  1. databricks/__init__.py +3 -0
  2. databricks/labs/__init__.py +3 -0
  3. databricks/labs/ucx/__about__.py +2 -0
  4. databricks/labs/ucx/__init__.py +3 -0
  5. databricks/labs/ucx/account.py +253 -0
  6. databricks/labs/ucx/assessment/__init__.py +0 -0
  7. databricks/labs/ucx/assessment/aws.py +391 -0
  8. databricks/labs/ucx/assessment/azure.py +173 -0
  9. databricks/labs/ucx/assessment/clusters.py +233 -0
  10. databricks/labs/ucx/assessment/crawlers.py +67 -0
  11. databricks/labs/ucx/assessment/init_scripts.py +87 -0
  12. databricks/labs/ucx/assessment/jobs.py +342 -0
  13. databricks/labs/ucx/assessment/pipelines.py +77 -0
  14. databricks/labs/ucx/assessment/secrets.py +69 -0
  15. databricks/labs/ucx/aws/__init__.py +0 -0
  16. databricks/labs/ucx/aws/access.py +369 -0
  17. databricks/labs/ucx/aws/credentials.py +208 -0
  18. databricks/labs/ucx/azure/__init__.py +0 -0
  19. databricks/labs/ucx/azure/access.py +236 -0
  20. databricks/labs/ucx/azure/credentials.py +283 -0
  21. databricks/labs/ucx/azure/locations.py +197 -0
  22. databricks/labs/ucx/azure/resources.py +376 -0
  23. databricks/labs/ucx/cli.py +503 -0
  24. databricks/labs/ucx/config.py +60 -0
  25. databricks/labs/ucx/configure.py +73 -0
  26. databricks/labs/ucx/framework/__init__.py +0 -0
  27. databricks/labs/ucx/framework/crawlers.py +122 -0
  28. databricks/labs/ucx/framework/dashboards.py +390 -0
  29. databricks/labs/ucx/framework/tasks.py +252 -0
  30. databricks/labs/ucx/framework/utils.py +25 -0
  31. databricks/labs/ucx/hive_metastore/__init__.py +5 -0
  32. databricks/labs/ucx/hive_metastore/catalog_schema.py +94 -0
  33. databricks/labs/ucx/hive_metastore/grants.py +309 -0
  34. databricks/labs/ucx/hive_metastore/locations.py +250 -0
  35. databricks/labs/ucx/hive_metastore/mapping.py +205 -0
  36. databricks/labs/ucx/hive_metastore/table_migrate.py +521 -0
  37. databricks/labs/ucx/hive_metastore/table_size.py +85 -0
  38. databricks/labs/ucx/hive_metastore/tables.py +270 -0
  39. databricks/labs/ucx/hive_metastore/tables.scala +115 -0
  40. databricks/labs/ucx/hive_metastore/udfs.py +119 -0
  41. databricks/labs/ucx/hive_metastore/verification.py +39 -0
  42. databricks/labs/ucx/install.py +1018 -0
  43. databricks/labs/ucx/installer/__init__.py +0 -0
  44. databricks/labs/ucx/installer/hms_lineage.py +82 -0
  45. databricks/labs/ucx/installer/policy.py +181 -0
  46. databricks/labs/ucx/mixins/README.md +1 -0
  47. databricks/labs/ucx/mixins/__init__.py +0 -0
  48. databricks/labs/ucx/mixins/fixtures.py +1163 -0
  49. databricks/labs/ucx/mixins/redash.py +186 -0
  50. databricks/labs/ucx/queries/assessment/azure/05_0_azure_service_principals.sql +10 -0
  51. databricks/labs/ucx/queries/assessment/estimates/00_0_metastore_assignment.md +21 -0
  52. databricks/labs/ucx/queries/assessment/estimates/00_2_is_metastore_assigned.sql +3 -0
  53. databricks/labs/ucx/queries/assessment/estimates/00_4_is_incompatible_submit_run_detected.sql +6 -0
  54. databricks/labs/ucx/queries/assessment/estimates/01_0_group_migration.md +21 -0
  55. databricks/labs/ucx/queries/assessment/estimates/01_2_group_migration.sql +3 -0
  56. databricks/labs/ucx/queries/assessment/estimates/01_5_group_migration_complexity.sql +10 -0
  57. databricks/labs/ucx/queries/assessment/estimates/02_0_data_modeling.md +10 -0
  58. databricks/labs/ucx/queries/assessment/estimates/02_2_uc_data_modeling.sql +3 -0
  59. databricks/labs/ucx/queries/assessment/estimates/02_5_uc_data_modeling_complexity.sql +9 -0
  60. databricks/labs/ucx/queries/assessment/estimates/03_0_data_migration.md +28 -0
  61. databricks/labs/ucx/queries/assessment/estimates/03_2_data_migration_summary.sql +3 -0
  62. databricks/labs/ucx/queries/assessment/estimates/03_5_data_migration_complexity.sql +11 -0
  63. databricks/labs/ucx/queries/assessment/main/00_0_compatibility.sql +6 -0
  64. databricks/labs/ucx/queries/assessment/main/00_1_count_total_databases.sql +4 -0
  65. databricks/labs/ucx/queries/assessment/main/00_2_count_table_failures.sql +4 -0
  66. databricks/labs/ucx/queries/assessment/main/00_3_count_total_tables.sql +4 -0
  67. databricks/labs/ucx/queries/assessment/main/00_4_count_external_locations.sql +4 -0
  68. databricks/labs/ucx/queries/assessment/main/00_5_count_total_views.sql +3 -0
  69. databricks/labs/ucx/queries/assessment/main/01_0_count_jobs.sql +3 -0
  70. databricks/labs/ucx/queries/assessment/main/01_2_assessment_summary.sql +7 -0
  71. databricks/labs/ucx/queries/assessment/main/02_0_object_readiness.sql +9 -0
  72. databricks/labs/ucx/queries/assessment/main/02_2_count_table_by_storage.sql +19 -0
  73. databricks/labs/ucx/queries/assessment/main/02_4_count_table_by_schema_format.sql +22 -0
  74. databricks/labs/ucx/queries/assessment/main/03_0_database_summary.sql +53 -0
  75. databricks/labs/ucx/queries/assessment/main/04_0_external_locations.sql +4 -0
  76. databricks/labs/ucx/queries/assessment/main/04_3_mount_points.sql +5 -0
  77. databricks/labs/ucx/queries/assessment/main/05_0_all_tables.sql +31 -0
  78. databricks/labs/ucx/queries/assessment/main/05_3_job_details.sql +11 -0
  79. databricks/labs/ucx/queries/assessment/main/06_0_clusters.sql +9 -0
  80. databricks/labs/ucx/queries/assessment/main/06_3_jobs.sql +10 -0
  81. databricks/labs/ucx/queries/assessment/main/06_4_submit_runs.sql +8 -0
  82. databricks/labs/ucx/queries/assessment/main/06_5_submit_runs.sql +8 -0
  83. databricks/labs/ucx/queries/assessment/main/07_0_pipelines.sql +8 -0
  84. databricks/labs/ucx/queries/assessment/main/07_3_global_init_scripts.sql +9 -0
  85. databricks/labs/ucx/queries/assessment/main/08_0_cluster_policies.sql +9 -0
  86. databricks/labs/ucx/queries/assessment/main/README.md +6 -0
  87. databricks/labs/ucx/queries/views/grant_detail.sql +35 -0
  88. databricks/labs/ucx/queries/views/objects.sql +33 -0
  89. databricks/labs/ucx/queries/views/table_estimates.sql +13 -0
  90. databricks/labs/ucx/runtime.py +408 -0
  91. databricks/labs/ucx/uninstall.py +14 -0
  92. databricks/labs/ucx/upgrades/v0.15.0_added_cluster_policy.py +23 -0
  93. databricks/labs/ucx/upgrades/v0.16.0_changing_cluster_table_schema.py +17 -0
  94. databricks/labs/ucx/upgrades/v0.4.0_added_log_dir.py +11 -0
  95. databricks/labs/ucx/workspace_access/__init__.py +0 -0
  96. databricks/labs/ucx/workspace_access/base.py +38 -0
  97. databricks/labs/ucx/workspace_access/generic.py +445 -0
  98. databricks/labs/ucx/workspace_access/groups.py +728 -0
  99. databricks/labs/ucx/workspace_access/listing.py +109 -0
  100. databricks/labs/ucx/workspace_access/manager.py +216 -0
  101. databricks/labs/ucx/workspace_access/redash.py +271 -0
  102. databricks/labs/ucx/workspace_access/scim.py +159 -0
  103. databricks/labs/ucx/workspace_access/secrets.py +122 -0
  104. databricks/labs/ucx/workspace_access/tacl.py +136 -0
  105. databricks_labs_ucx-0.17.0.dist-info/METADATA +751 -0
  106. databricks_labs_ucx-0.17.0.dist-info/RECORD +110 -0
  107. databricks_labs_ucx-0.17.0.dist-info/WHEEL +4 -0
  108. databricks_labs_ucx-0.17.0.dist-info/entry_points.txt +2 -0
  109. databricks_labs_ucx-0.17.0.dist-info/licenses/LICENSE +69 -0
  110. databricks_labs_ucx-0.17.0.dist-info/licenses/NOTICE +16 -0
databricks/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # DO NOT ADD ANYTHING ELSE TO THIS FILE FOR COMPATIBILITY WITH OTHER databricks.* PACKAGES
2
+ # SEE https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,3 @@
1
+ # DO NOT ADD ANYTHING ELSE TO THIS FILE FOR COMPATIBILITY WITH OTHER databricks.* PACKAGES
2
+ # SEE https://packaging.python.org/guides/packaging-namespace-packages/#pkgutil-style-namespace-packages
3
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
@@ -0,0 +1,2 @@
1
+ # DO NOT MODIFY THIS FILE
2
+ __version__ = "0.17.0"
@@ -0,0 +1,3 @@
1
+ from databricks.labs.blueprint.logger import install_logger
2
+
3
+ install_logger()
@@ -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(" ", "")