qontract-reconcile 0.10.1rc1029__py3-none-any.whl → 0.10.1rc1030__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qontract-reconcile
3
- Version: 0.10.1rc1029
3
+ Version: 0.10.1rc1030
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Home-page: https://github.com/app-sre/qontract-reconcile
6
6
  Author: Red Hat App-SRE Team
@@ -35,7 +35,7 @@ reconcile/gitlab_labeler.py,sha256=4xJHmVX155fclrHqkR926sL1GH6RTN5XfZ8PnqNXbRA,4
35
35
  reconcile/gitlab_members.py,sha256=PrJE9OhDRdGG_gHM_77nQojLb4B18jtUu8DxgLsRS88,8417
36
36
  reconcile/gitlab_mr_sqs_consumer.py,sha256=O46mdziPgGOndbU-0_UJKJVUaiEoVzJPEgKm4_UvYoI,2571
37
37
  reconcile/gitlab_owners.py,sha256=sn9njaKOtqcvnhi2qtm-faAfAR4zNqflbSuusA9RUuI,13456
38
- reconcile/gitlab_permissions.py,sha256=wq0jbWWPN5iOWnRLuZeemCgKh4a_iGy2d4Iq3WFJykM,3636
38
+ reconcile/gitlab_permissions.py,sha256=tEjbpr-QH03vZ4L6CuVJhkgA3oaB_gyJ7d58MTR4JJQ,7389
39
39
  reconcile/gitlab_projects.py,sha256=K3tFf_aD1W4Ijp5q-9Qek3kwFGEWPcZ1kd7tzFJ4GyQ,1781
40
40
  reconcile/integrations_manager.py,sha256=gvOhVklJDeMPURxLjV30Q4hnLET3BZ-NeEEtQBoo_E0,9500
41
41
  reconcile/jenkins_base.py,sha256=0Gocu3fU2YTltaxBlbDQOUvP-7CP2OSQV1ZRwtWeVXw,875
@@ -518,7 +518,7 @@ reconcile/test/test_github_repo_invites.py,sha256=WrPn5ROAoJYK0ihzlZcFR0V9Pu2HcM
518
518
  reconcile/test/test_gitlab_housekeeping.py,sha256=ScD9Tgf9OOgGfAFfTy6Kn2222l2wH_A3gMRKdpvoWe0,10053
519
519
  reconcile/test/test_gitlab_labeler.py,sha256=PmYXiU2g0_O5OTdMGPzdeqBAfat92U9bhjjfeyiGSmQ,4336
520
520
  reconcile/test/test_gitlab_members.py,sha256=yjfQRUFG_F0kLYegax4_ec5VIBAnCPrvAgqMcN1GXzc,9985
521
- reconcile/test/test_gitlab_permissions.py,sha256=vPFEsdjyP-lO8pc2rN6acMns3Sjz9YJs16msbBR8DZc,2736
521
+ reconcile/test/test_gitlab_permissions.py,sha256=aMf5SUeVp-aQ1bWGQPQLYa85auzRlyfZVTWqyybJ6mo,5850
522
522
  reconcile/test/test_instrumented_wrappers.py,sha256=CZzhnQH0c4i7-Rxjg7-0dfFMvVPegLHL46z5NHOOCwo,608
523
523
  reconcile/test/test_integrations_manager.py,sha256=xpyQAVz57wAbovrcQzAeuyq8VzdItUyW2d2kp1WW_5c,38184
524
524
  reconcile/test/test_jenkins_worker_fleets.py,sha256=o1jlT7OBBSgu0M3iI4xMdz_x6SciF7yhNBpLk5gTJfg,2361
@@ -674,7 +674,7 @@ reconcile/utils/filtering.py,sha256=S4PbMHuFr3ED0P2Q_ea5CAaB7FimI62B-F5YTaKrphA,
674
674
  reconcile/utils/git.py,sha256=JkpbUO10oBTtNHZ1IhjyG6dTOUizc7I5H0vm7NvDVNw,1409
675
675
  reconcile/utils/git_secrets.py,sha256=y1rEhwA8DyDpBSAEuhMS7Y2X3mpxT2zQ4zyDFkhLe_g,1936
676
676
  reconcile/utils/github_api.py,sha256=R8OvqyPdnRqvP-Efnv9RvIcbBlb4M0KC4RlbnJMD0Tg,2426
677
- reconcile/utils/gitlab_api.py,sha256=WfhLhOp3-cOwgptiCNPhWja74Lo41ZIlJ6HFSWaIDRw,30512
677
+ reconcile/utils/gitlab_api.py,sha256=XCBME1p42D6dbedbcLlFt1VmTLZLlOjZEnusoXEr4ps,29029
678
678
  reconcile/utils/gpg.py,sha256=EKG7_fdMv8BMlV5yUdPiqoTx-KrzmVSEAl2sLkaKwWI,1123
679
679
  reconcile/utils/gql.py,sha256=NqxosFCInLy_dF_fcXnRYcZgsThedSyv_I3U8k2SLMg,14161
680
680
  reconcile/utils/grouping.py,sha256=vr9SFHZ7bqmHYrvYcEZt-Er3-yQYfAAdq5sHLZVmXPY,456
@@ -865,8 +865,8 @@ tools/test/test_qontract_cli.py,sha256=_D61RFGAN5x44CY1tYbouhlGXXABwYfxKSWSQx3Jr
865
865
  tools/test/test_saas_promotion_state.py,sha256=dy4kkSSAQ7bC0Xp2CociETGN-2aABEfL6FU5D9Jl00Y,6056
866
866
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
867
867
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
868
- qontract_reconcile-0.10.1rc1029.dist-info/METADATA,sha256=w9MZTHnW2xZ0UpGosbgS2pYbE8FSj9XvFbw_uJnNjRw,2213
869
- qontract_reconcile-0.10.1rc1029.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
870
- qontract_reconcile-0.10.1rc1029.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
871
- qontract_reconcile-0.10.1rc1029.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
872
- qontract_reconcile-0.10.1rc1029.dist-info/RECORD,,
868
+ qontract_reconcile-0.10.1rc1030.dist-info/METADATA,sha256=1Kn_s1Qs4zQwmniZl-bI8OXCfwOCb0q2uUMwePoypzk,2213
869
+ qontract_reconcile-0.10.1rc1030.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
870
+ qontract_reconcile-0.10.1rc1030.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
871
+ qontract_reconcile-0.10.1rc1030.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
872
+ qontract_reconcile-0.10.1rc1030.dist-info/RECORD,,
@@ -1,21 +1,146 @@
1
1
  import itertools
2
2
  import logging
3
+ from dataclasses import dataclass
3
4
  from typing import Any
4
5
 
5
- from gitlab.const import MAINTAINER_ACCESS
6
+ from gitlab.v4.objects import (
7
+ GroupProject,
8
+ Project,
9
+ )
6
10
  from sretoolbox.utils import threaded
7
11
 
8
12
  from reconcile import queries
9
13
  from reconcile.utils import batches
10
14
  from reconcile.utils.defer import defer
15
+ from reconcile.utils.differ import diff_mappings
11
16
  from reconcile.utils.gitlab_api import GitLabApi
12
17
  from reconcile.utils.unleash import get_feature_toggle_state
13
18
 
14
19
  QONTRACT_INTEGRATION = "gitlab-permissions"
15
20
  APP_SRE_GROUP_NAME = "app-sre"
21
+ GROUP_ACCESS = "maintainer"
16
22
  PAGE_SIZE = 100
17
23
 
18
24
 
25
+ @dataclass
26
+ class GroupSpec:
27
+ group_name: str
28
+ group_access_level: int
29
+
30
+
31
+ class GroupAccessLevelError(Exception):
32
+ pass
33
+
34
+
35
+ class GroupPermissionHandler:
36
+ def __init__(
37
+ self, gl: GitLabApi, group_name: str, access: str, dry_run: bool
38
+ ) -> None:
39
+ self.gl = gl
40
+ self.dry_run = dry_run
41
+ self.access_level_string = access
42
+ self.access_level = self.gl.get_access_level(access)
43
+ self.group = self.gl.get_group(group_name)
44
+
45
+ def run(self, repos: list[str]) -> None:
46
+ # filter projects belonging to the same group and remove it from the state data
47
+ filtered_project_repos = self.filter_group_owned_projects(repos)
48
+ desired_state = {
49
+ project_repo_url: GroupSpec(self.group.name, self.access_level)
50
+ for project_repo_url in filtered_project_repos
51
+ }
52
+ # get all projects shared with group
53
+ shared_projects = self.gl.get_items(self.group.shared_projects.list)
54
+ current_state = {
55
+ project.web_url: self.extract_group_spec(project)
56
+ for project in shared_projects
57
+ }
58
+ self.reconcile(desired_state, current_state)
59
+
60
+ def filter_group_owned_projects(self, repos: list[str]) -> set[str]:
61
+ # get only the projects that are owned by group and its sub groups
62
+ query = {"with_shared": False, "include_subgroups": True}
63
+ group_owned_projects = self.gl.get_items(
64
+ self.group.projects.list, query_parameters=query
65
+ )
66
+ group_owned_repo_list = {project.web_url for project in group_owned_projects}
67
+ return set(repos) - group_owned_repo_list
68
+
69
+ def extract_group_spec(self, project: GroupProject) -> GroupSpec:
70
+ return next(
71
+ GroupSpec(
72
+ group_name=self.group.name,
73
+ group_access_level=group["group_access_level"],
74
+ )
75
+ for group in project.shared_with_groups
76
+ if group["group_id"] == self.group.id
77
+ )
78
+
79
+ def can_share_project(self, project: Project) -> bool:
80
+ # check if user have access greater or equal access to be shared with the group
81
+ user = project.members_all.get(id=self.gl.user.id)
82
+ return user.access_level >= self.access_level
83
+
84
+ def reconcile(
85
+ self,
86
+ desired_state: dict[str, GroupSpec],
87
+ current_state: dict[str, GroupSpec],
88
+ ) -> None:
89
+ # get the diff data
90
+ diff_data = diff_mappings(
91
+ current=current_state,
92
+ desired=desired_state,
93
+ equal=lambda current, desired: current.group_access_level
94
+ == desired.group_access_level,
95
+ )
96
+ errors: list[Exception] = []
97
+ for repo in diff_data.add:
98
+ project = self.gl.get_project(repo)
99
+ if not self.can_share_project(project):
100
+ errors.append(
101
+ GroupAccessLevelError(
102
+ f"{repo} is not shared with {self.gl.user.username} as {self.access_level_string}"
103
+ )
104
+ )
105
+ continue
106
+ logging.info([
107
+ "share",
108
+ repo,
109
+ self.group.name,
110
+ self.access_level_string,
111
+ ])
112
+ if not self.dry_run:
113
+ self.gl.share_project_with_group(
114
+ project=project,
115
+ group_id=self.group.id,
116
+ access_level=self.access_level,
117
+ )
118
+ for repo in diff_data.change:
119
+ project = self.gl.get_project(repo)
120
+ if not self.can_share_project(project):
121
+ errors.append(
122
+ GroupAccessLevelError(
123
+ f"{repo} is not shared with {self.gl.user.username} as {self.access_level_string}"
124
+ )
125
+ )
126
+ continue
127
+ logging.info([
128
+ "reshare",
129
+ repo,
130
+ self.group.name,
131
+ self.access_level_string,
132
+ ])
133
+ if not self.dry_run:
134
+ self.gl.share_project_with_group(
135
+ project=project,
136
+ group_id=self.group.id,
137
+ access_level=self.access_level,
138
+ reshare=True,
139
+ )
140
+ if errors:
141
+ raise ExceptionGroup("Reconcile errors occurred", errors)
142
+
143
+
19
144
  def get_members_to_add(repo, gl, app_sre):
20
145
  maintainers = get_all_app_sre_maintainers(repo, gl, app_sre)
21
146
  if maintainers is None:
@@ -56,7 +181,10 @@ def run(dry_run, thread_pool_size=10, defer=None):
56
181
  default=False,
57
182
  )
58
183
  if share_with_group_enabled:
59
- share_project_with_group(gl, repos, dry_run)
184
+ group_permission_handler = GroupPermissionHandler(
185
+ gl=gl, group_name=APP_SRE_GROUP_NAME, access=GROUP_ACCESS, dry_run=dry_run
186
+ )
187
+ group_permission_handler.run(repos=repos)
60
188
  else:
61
189
  share_project_with_group_members(gl, repos, thread_pool_size, dry_run)
62
190
 
@@ -75,26 +203,6 @@ def share_project_with_group_members(
75
203
  gl.add_project_member(m["repo"], m["user"])
76
204
 
77
205
 
78
- def share_project_with_group(gl: GitLabApi, repos: list[str], dry_run: bool) -> None:
79
- # get repos not owned by app-sre
80
- non_app_sre_project_repos = {repo for repo in repos if "/app-sre/" not in repo}
81
- group_id, shared_projects = gl.get_group_id_and_shared_projects(APP_SRE_GROUP_NAME)
82
- shared_project_repos = shared_projects.keys()
83
- repos_to_share = non_app_sre_project_repos - shared_project_repos
84
- repos_to_reshare = {
85
- repo
86
- for repo in non_app_sre_project_repos
87
- if (group_data := shared_projects.get(repo))
88
- and group_data["group_access_level"] < MAINTAINER_ACCESS
89
- }
90
- for repo in repos_to_share:
91
- gl.share_project_with_group(repo_url=repo, group_id=group_id, dry_run=dry_run)
92
- for repo in repos_to_reshare:
93
- gl.share_project_with_group(
94
- repo_url=repo, group_id=group_id, dry_run=dry_run, reshare=True
95
- )
96
-
97
-
98
206
  def early_exit_desired_state(*args, **kwargs) -> dict[str, Any]:
99
207
  instance = queries.get_gitlab_instance()
100
208
  return {
@@ -1,7 +1,17 @@
1
1
  from unittest.mock import MagicMock, create_autospec
2
2
 
3
3
  import pytest
4
- from gitlab.v4.objects import CurrentUser, GroupMember
4
+ from gitlab.v4.objects import (
5
+ CurrentUser,
6
+ Group,
7
+ GroupMember,
8
+ GroupProjectManager,
9
+ Project,
10
+ ProjectMember,
11
+ ProjectMemberAllManager,
12
+ SharedProject,
13
+ SharedProjectManager,
14
+ )
5
15
  from pytest_mock import MockerFixture
6
16
 
7
17
  from reconcile import gitlab_permissions
@@ -23,6 +33,7 @@ def mocked_gl() -> MagicMock:
23
33
  gl.server = "test_server"
24
34
  gl.user = create_autospec(CurrentUser)
25
35
  gl.user.username = "test_name"
36
+ gl.user.id = 1234
26
37
  return gl
27
38
 
28
39
 
@@ -49,13 +60,25 @@ def test_run_share_with_group(
49
60
  mocker.patch(
50
61
  "reconcile.gitlab_permissions.get_feature_toggle_state"
51
62
  ).return_value = True
52
- mocked_gl.get_group_id_and_shared_projects.return_value = (
53
- 1234,
54
- {"https://test.com": {"group_access_level": 30}},
63
+ group = create_autospec(Group, id=1234)
64
+ group.name = "app-sre"
65
+ group.projects = create_autospec(GroupProjectManager)
66
+ group.shared_projects = create_autospec(SharedProjectManager)
67
+ mocked_gl.get_items.side_effect = [
68
+ [],
69
+ [],
70
+ ]
71
+ mocked_gl.get_group.return_value = group
72
+ mocked_gl.get_access_level.return_value = 40
73
+ project = create_autospec(Project, web_url="https://test.com")
74
+ project.members_all = create_autospec(ProjectMemberAllManager)
75
+ project.members_all.get.return_value = create_autospec(
76
+ ProjectMember, id=mocked_gl.user.id, access_level=40
55
77
  )
78
+ mocked_gl.get_project.return_value = project
56
79
  gitlab_permissions.run(False, thread_pool_size=1)
57
80
  mocked_gl.share_project_with_group.assert_called_once_with(
58
- repo_url="https://test-gitlab.com", group_id=1234, dry_run=False
81
+ project, group_id=1234, access_level=40
59
82
  )
60
83
 
61
84
 
@@ -66,11 +89,76 @@ def test_run_reshare_with_group(
66
89
  mocker.patch(
67
90
  "reconcile.gitlab_permissions.get_feature_toggle_state"
68
91
  ).return_value = True
69
- mocked_gl.get_group_id_and_shared_projects.return_value = (
70
- 1234,
71
- {"https://test-gitlab.com": {"group_access_level": 30}},
92
+ group = create_autospec(Group, id=1234)
93
+ group.name = "app-sre"
94
+ group.projects = create_autospec(GroupProjectManager)
95
+ group.shared_projects = create_autospec(SharedProjectManager)
96
+ mocked_gl.get_items.side_effect = [
97
+ [],
98
+ [
99
+ create_autospec(
100
+ SharedProject,
101
+ web_url="https://test-gitlab.com",
102
+ shared_with_groups=[
103
+ {
104
+ "group_access_level": 30,
105
+ "group_name": "app-sre",
106
+ "group_id": 1234,
107
+ }
108
+ ],
109
+ )
110
+ ],
111
+ ]
112
+ mocked_gl.get_group.return_value = group
113
+ mocked_gl.get_access_level.return_value = 40
114
+ project = create_autospec(Project, web_url="https://test-gitlab.com")
115
+ project.members_all = create_autospec(ProjectMemberAllManager)
116
+ project.members_all.get.return_value = create_autospec(
117
+ ProjectMember, id=mocked_gl.user.id, access_level=40
72
118
  )
119
+ mocked_gl.get_project.return_value = project
73
120
  gitlab_permissions.run(False, thread_pool_size=1)
74
121
  mocked_gl.share_project_with_group.assert_called_once_with(
75
- repo_url="https://test-gitlab.com", group_id=1234, dry_run=False, reshare=True
122
+ project=project, group_id=1234, access_level=40, reshare=True
123
+ )
124
+
125
+
126
+ def test_run_share_with_group_failed(
127
+ mocked_queries: MagicMock, mocker: MockerFixture, mocked_gl: MagicMock
128
+ ) -> None:
129
+ mocker.patch("reconcile.gitlab_permissions.GitLabApi").return_value = mocked_gl
130
+ mocker.patch(
131
+ "reconcile.gitlab_permissions.get_feature_toggle_state"
132
+ ).return_value = True
133
+ group = create_autospec(Group, id=1234)
134
+ group.name = "app-sre"
135
+ group.projects = create_autospec(GroupProjectManager)
136
+ group.shared_projects = create_autospec(SharedProjectManager)
137
+ group.projects = create_autospec(GroupProjectManager)
138
+ group.shared_projects = create_autospec(SharedProjectManager)
139
+ mocked_gl.get_items.side_effect = [
140
+ [],
141
+ [
142
+ create_autospec(
143
+ SharedProject,
144
+ web_url="https://test-gitlab.com",
145
+ shared_with_groups=[
146
+ {
147
+ "group_access_level": 30,
148
+ "group_name": "app-sre",
149
+ "group_id": 134,
150
+ }
151
+ ],
152
+ )
153
+ ],
154
+ ]
155
+ mocked_gl.get_group.return_value = group
156
+ mocked_gl.get_access_level.return_value = 40
157
+ project = create_autospec(Project)
158
+ project.members_all = create_autospec(ProjectMemberAllManager)
159
+ project.members_all.get.return_value = create_autospec(
160
+ ProjectMember, id=mocked_gl.user.id, access_level=10
76
161
  )
162
+ mocked_gl.get_project.return_value = project
163
+ with pytest.raises(Exception):
164
+ gitlab_permissions.run(False, thread_pool_size=1)
@@ -262,51 +262,16 @@ class GitLabApi: # pylint: disable=too-many-public-methods
262
262
 
263
263
  def share_project_with_group(
264
264
  self,
265
- repo_url: str,
265
+ project: Project,
266
266
  group_id: int,
267
- dry_run: bool,
268
- access: str = "maintainer",
267
+ access_level: int,
269
268
  reshare: bool = False,
270
269
  ) -> None:
271
- project = self.get_project(repo_url)
272
- if project is None:
273
- return None
274
- access_level = self.get_access_level(access)
275
- # check if we have 'access_level' access so we can add the group with same role.
276
- members = self.get_items(
277
- project.members_all.list, query_parameters={"user_ids": self.user.id}
278
- )
279
- if not any(
280
- self.user.id == member.id and member.access_level >= access_level
281
- for member in members
282
- ):
283
- logging.error(
284
- "%s is not shared with %s as %s",
285
- repo_url,
286
- self.user.username,
287
- access,
288
- )
289
- return None
290
- logging.info(["add_group_as_maintainer", repo_url, group_id])
291
- if not dry_run:
292
- if reshare:
293
- gitlab_request.labels(integration=INTEGRATION_NAME).inc()
294
- project.unshare(group_id)
270
+ if reshare:
295
271
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()
296
- project.share(group_id, access_level)
297
-
298
- def get_group_id_and_shared_projects(
299
- self, group_name: str
300
- ) -> tuple[int, dict[str, Any]]:
272
+ project.unshare(group_id)
301
273
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()
302
- group = self.gl.groups.get(group_name)
303
- shared_projects = self.get_items(group.projects.list)
304
- return group.id, {
305
- project.web_url: shared_group
306
- for project in shared_projects
307
- for shared_group in project.shared_with_groups
308
- if shared_group["group_id"] == group.id
309
- }
274
+ project.share(group_id, access_level)
310
275
 
311
276
  @staticmethod
312
277
  def _is_bot_username(username: str) -> bool:
@@ -414,10 +379,9 @@ class GitLabApi: # pylint: disable=too-many-public-methods
414
379
  if access == "guest":
415
380
  return GUEST_ACCESS
416
381
 
417
- def get_group_id_and_projects(self, group_name: str) -> tuple[str, list[str]]:
382
+ def get_group(self, group_name: str) -> Group:
418
383
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()
419
- group = self.gl.groups.get(group_name)
420
- return group.id, [p.name for p in self.get_items(group.projects.list)]
384
+ return self.gl.groups.get(group_name)
421
385
 
422
386
  def create_project(self, group_id, project):
423
387
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()