qontract-reconcile 0.10.1rc1180__py3-none-any.whl → 0.10.1rc1182__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.1rc1180
3
+ Version: 0.10.1rc1182
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
@@ -32,7 +32,7 @@ reconcile/github_validator.py,sha256=cVTVxJIGR4a1Jz8wrdXEAb_CMpXUzvykVmUURX4cook
32
32
  reconcile/gitlab_fork_compliance.py,sha256=c7UfqSAsW04c1bWJmXXaQDwtUcG4Kb6nCJAyRU2uAuw,4449
33
33
  reconcile/gitlab_housekeeping.py,sha256=D0DOqC-xuMBMct04_MI8Lq32OAi_QMvvGLOz_E-77Dw,22482
34
34
  reconcile/gitlab_labeler.py,sha256=4xJHmVX155fclrHqkR926sL1GH6RTN5XfZ8PnqNXbRA,4534
35
- reconcile/gitlab_members.py,sha256=PrJE9OhDRdGG_gHM_77nQojLb4B18jtUu8DxgLsRS88,8417
35
+ reconcile/gitlab_members.py,sha256=MUIgYDLeJx2-_vMypyq2Pa17cpKdXATYhtVACS2ghpQ,8297
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
38
  reconcile/gitlab_permissions.py,sha256=6ZBPnQci4-1LVJBvaUS-c0QwIJjITNKVflCeH-Y5Lbk,7507
@@ -520,7 +520,7 @@ reconcile/test/test_github_org.py,sha256=aGWeMhkz1fjogvaAQGjemSKR201L5gEZZOe0iMQ
520
520
  reconcile/test/test_github_repo_invites.py,sha256=WrPn5ROAoJYK0ihzlZcFR0V9Pu2HcMs13I6nazf7hq4,3307
521
521
  reconcile/test/test_gitlab_housekeeping.py,sha256=ScD9Tgf9OOgGfAFfTy6Kn2222l2wH_A3gMRKdpvoWe0,10053
522
522
  reconcile/test/test_gitlab_labeler.py,sha256=PmYXiU2g0_O5OTdMGPzdeqBAfat92U9bhjjfeyiGSmQ,4336
523
- reconcile/test/test_gitlab_members.py,sha256=yjfQRUFG_F0kLYegax4_ec5VIBAnCPrvAgqMcN1GXzc,9985
523
+ reconcile/test/test_gitlab_members.py,sha256=bupl1dsLor-913LGYCuRtJMKqDsXo_d_2nbtDe6B1Us,7016
524
524
  reconcile/test/test_gitlab_permissions.py,sha256=aMf5SUeVp-aQ1bWGQPQLYa85auzRlyfZVTWqyybJ6mo,5850
525
525
  reconcile/test/test_instrumented_wrappers.py,sha256=CZzhnQH0c4i7-Rxjg7-0dfFMvVPegLHL46z5NHOOCwo,608
526
526
  reconcile/test/test_integrations_manager.py,sha256=xpyQAVz57wAbovrcQzAeuyq8VzdItUyW2d2kp1WW_5c,38184
@@ -678,7 +678,7 @@ reconcile/utils/external_resources.py,sha256=y7Wz32cOAmCsUhQ6T-1N6lktnLikGkaHQ0S
678
678
  reconcile/utils/filtering.py,sha256=S4PbMHuFr3ED0P2Q_ea5CAaB7FimI62B-F5YTaKrphA,402
679
679
  reconcile/utils/git.py,sha256=wzVIYAeKlMGW538U1mkJWUI6h_mFRUY4lawh2AR8hw4,2345
680
680
  reconcile/utils/github_api.py,sha256=R8OvqyPdnRqvP-Efnv9RvIcbBlb4M0KC4RlbnJMD0Tg,2426
681
- reconcile/utils/gitlab_api.py,sha256=C1nsHQKKybsmFdaG9vsItBjJm69ym4VWbqbKfAEf7oY,29305
681
+ reconcile/utils/gitlab_api.py,sha256=ar3D1gaPGm71ecQReMvbMbBzCa8TRs3Pn1ZZJP_lwSw,28453
682
682
  reconcile/utils/gpg.py,sha256=EKG7_fdMv8BMlV5yUdPiqoTx-KrzmVSEAl2sLkaKwWI,1123
683
683
  reconcile/utils/gql.py,sha256=C0thIm_k9MBldfqwHzyqtYZk9sIvMdm9IbbnXLGwjD8,14158
684
684
  reconcile/utils/grouping.py,sha256=vr9SFHZ7bqmHYrvYcEZt-Er3-yQYfAAdq5sHLZVmXPY,456
@@ -838,10 +838,11 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
838
838
  tools/app_interface_reporter.py,sha256=oZPib4HPq0aZ2Zui1QGJGk6qQdfpeihujGDBnSdKyGE,17627
839
839
  tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
840
840
  tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
841
- tools/qontract_cli.py,sha256=YMOYkmP9WZFMX8wPxoJZ5ddstK3YyXMMx6ReXnCiH0w,140707
841
+ tools/qontract_cli.py,sha256=KfmjfaCuyd8F68oY1hHd2tEf0PY0kLd6t8sWqR81nmc,142498
842
842
  tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
843
843
  tools/template_validation.py,sha256=qpKYaTgk0GOPGa2Ct5_5sKdwIHtCAKIBGzsMPuJU5fw,3371
844
844
  tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
845
+ tools/cli_commands/container_images_report.py,sha256=PCJIzvUqiYmTdn5xJFcxHocCYp6dprrsJ_lkYdl3ET8,4417
845
846
  tools/cli_commands/erv2.py,sha256=469qdhyaf7thpPQ4hJSurvmxBqYDJsoI8H4AigQIF7U,20737
846
847
  tools/cli_commands/gpg_encrypt.py,sha256=x02JOMn834z89YSNvr5B-oJky7rR1C0begCkPh45eHk,4958
847
848
  tools/cli_commands/systems_and_tools.py,sha256=EMHOF1AtUDaoSk0bbjl6oUKYAz4rTZjIBaF-6E6GspM,16816
@@ -876,12 +877,13 @@ tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
876
877
  tools/test/conftest.py,sha256=CsDbu4otrxb7X7kXKKGyV3ZEzu3pCkgjCoCGiHNx6zc,2401
877
878
  tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvftCWEEf-g1mfXOtgCog-g,1271
878
879
  tools/test/test_erv2.py,sha256=EAS7QuJkHisRVO9bMGxm662L5B6i66wF_mT9PAjVzrU,3128
880
+ tools/test/test_get_container_images.py,sha256=L2XzfmYAd6WZ17UXNnr8Z4iwoGcCvQ0vN6gxAZ7gEws,6097
879
881
  tools/test/test_qontract_cli.py,sha256=iuzKbQ6ahinvjoQmQLBrG4shey0z-1rB6qCgS8T6dgU,5789
880
882
  tools/test/test_saas_promotion_state.py,sha256=dy4kkSSAQ7bC0Xp2CociETGN-2aABEfL6FU5D9Jl00Y,6056
881
883
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
882
884
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
883
- qontract_reconcile-0.10.1rc1180.dist-info/METADATA,sha256=5FtpxYHIFj662mfS5_AXkrDYRFw4uWWBGxXElRlLS24,2213
884
- qontract_reconcile-0.10.1rc1180.dist-info/WHEEL,sha256=bFJAMchF8aTQGUgMZzHJyDDMPTO3ToJ7x23SLJa1SVo,92
885
- qontract_reconcile-0.10.1rc1180.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
886
- qontract_reconcile-0.10.1rc1180.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
887
- qontract_reconcile-0.10.1rc1180.dist-info/RECORD,,
885
+ qontract_reconcile-0.10.1rc1182.dist-info/METADATA,sha256=gdReu60z2THXeO2P__JA2nYHErSt6uI8EoVgnxsl5ro,2213
886
+ qontract_reconcile-0.10.1rc1182.dist-info/WHEEL,sha256=bFJAMchF8aTQGUgMZzHJyDDMPTO3ToJ7x23SLJa1SVo,92
887
+ qontract_reconcile-0.10.1rc1182.dist-info/entry_points.txt,sha256=GKQqCl2j2X1BJQ69een6rHcR26PmnxnONLNOQB-nRjY,491
888
+ qontract_reconcile-0.10.1rc1182.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
889
+ qontract_reconcile-0.10.1rc1182.dist-info/RECORD,,
@@ -1,8 +1,11 @@
1
- import enum
2
1
  import logging
3
2
  from collections.abc import Callable
4
3
  from typing import Any
5
4
 
5
+ from gitlab.v4.objects import (
6
+ Group,
7
+ GroupMember,
8
+ )
6
9
  from pydantic import BaseModel
7
10
 
8
11
  from reconcile import queries
@@ -23,6 +26,7 @@ from reconcile.gql_definitions.gitlab_members.permissions import (
23
26
  )
24
27
  from reconcile.utils import gql
25
28
  from reconcile.utils.defer import defer
29
+ from reconcile.utils.differ import diff_mappings
26
30
  from reconcile.utils.exceptions import AppInterfaceSettingsError
27
31
  from reconcile.utils.gitlab_api import GitLabApi
28
32
  from reconcile.utils.pagerduty_api import (
@@ -37,50 +41,51 @@ QONTRACT_INTEGRATION = "gitlab-members"
37
41
 
38
42
  class GitlabUser(BaseModel):
39
43
  user: str
40
- access_level: str
44
+ access_level: int
41
45
 
42
46
 
43
- State = dict[str, list[GitlabUser]]
47
+ class CurrentStateSpec(BaseModel):
48
+ members: dict[str, GroupMember]
44
49
 
50
+ class Config:
51
+ arbitrary_types_allowed = True
45
52
 
46
- class Action(enum.Enum):
47
- add_user_to_group = enum.auto()
48
- remove_user_from_group = enum.auto()
49
- change_access = enum.auto()
50
53
 
54
+ class DesiredStateSpec(BaseModel):
55
+ members: dict[str, GitlabUser]
56
+
57
+ class Config:
58
+ arbitrary_types_allowed = True
51
59
 
52
- class Diff(BaseModel):
53
- action: Action
54
- group: str
55
- user: str
56
- access_level: str
57
60
 
61
+ CurrentState = dict[str, CurrentStateSpec]
62
+ DesiredState = dict[str, DesiredStateSpec]
58
63
 
59
- def get_current_state(instance: GitlabInstanceV1, gl: GitLabApi) -> State:
64
+
65
+ def get_current_state(
66
+ instance: GitlabInstanceV1, gl: GitLabApi, gitlab_groups_map: dict[str, Group]
67
+ ) -> CurrentState:
60
68
  """Get current gitlab group members for all managed groups."""
61
69
  return {
62
- g: [
63
- GitlabUser(user=u["user"], access_level=u["access_level"])
64
- for u in gl.get_group_members(g)
65
- ]
70
+ g: CurrentStateSpec(
71
+ members={
72
+ u.username: u for u in gl.get_group_members(gitlab_groups_map.get(g))
73
+ },
74
+ )
66
75
  for g in instance.managed_groups
67
76
  }
68
77
 
69
78
 
70
79
  def add_or_update_user(
71
- group_members: State, group_name: str, gitlab_user: GitlabUser
80
+ desired_state_spec: DesiredStateSpec, gitlab_user: GitlabUser
72
81
  ) -> None:
73
- existing_users = [
74
- gu for gu in group_members[group_name] if gu.user == gitlab_user.user
75
- ]
76
- if not existing_users:
77
- group_members[group_name].append(gitlab_user)
82
+ existing_user = desired_state_spec.members.get(gitlab_user.user)
83
+ if not existing_user:
84
+ desired_state_spec.members[gitlab_user.user] = gitlab_user
78
85
  else:
79
- existing_user = existing_users[0]
80
- if GitLabApi.get_access_level(
81
- existing_user.access_level
82
- ) < GitLabApi.get_access_level(gitlab_user.access_level):
83
- existing_user.access_level = gitlab_user.access_level
86
+ existing_user.access_level = max(
87
+ existing_user.access_level, gitlab_user.access_level
88
+ )
84
89
 
85
90
 
86
91
  def get_desired_state(
@@ -88,95 +93,41 @@ def get_desired_state(
88
93
  pagerduty_map: PagerDutyMap,
89
94
  permissions: list[PermissionGitlabGroupMembershipV1],
90
95
  all_users: list[User],
91
- ) -> State:
96
+ ) -> DesiredState:
92
97
  """Fetch all desired gitlab users from app-interface."""
93
- desired_group_members: State = {g: [] for g in instance.managed_groups}
94
- for g in desired_group_members:
95
- for p in permissions:
96
- if p.group == g:
97
- for r in p.roles or []:
98
- for u in (r.users or []) + (r.bots or []):
99
- gu = GitlabUser(user=u.org_username, access_level=p.access)
100
- add_or_update_user(desired_group_members, g, gu)
101
- if p.pagerduty:
102
- usernames_from_pagerduty = get_usernames_from_pagerduty(
103
- p.pagerduty,
104
- all_users,
105
- g,
106
- pagerduty_map,
107
- get_username_method=lambda u: u.org_username,
108
- )
109
- for u in usernames_from_pagerduty:
110
- gu = GitlabUser(user=u, access_level=p.access)
111
- add_or_update_user(desired_group_members, g, gu)
112
-
98
+ desired_group_members: DesiredState = {
99
+ g: build_desired_state_spec(g, permissions, pagerduty_map, all_users)
100
+ for g in instance.managed_groups
101
+ }
113
102
  return desired_group_members
114
103
 
115
104
 
116
- def calculate_diff(current_state: State, desired_state: State) -> list[Diff]:
117
- """Compare current and desired state and return all differences."""
118
- diff: list[Diff] = []
119
- diff += subtract_states(desired_state, current_state, Action.add_user_to_group)
120
- diff += subtract_states(current_state, desired_state, Action.remove_user_from_group)
121
- diff += check_access(current_state, desired_state)
122
- return diff
123
-
124
-
125
- def subtract_states(
126
- from_state: State, subtract_state: State, action: Action
127
- ) -> list[Diff]:
128
- """Return diff objects for items in from_state but not in subtract_state."""
129
- result = []
130
- for f_group, f_users in from_state.items():
131
- s_group = subtract_state[f_group]
132
- for f_user in f_users:
133
- found = False
134
- for s_user in s_group:
135
- if f_user.user != s_user.user:
136
- continue
137
- found = True
138
- break
139
- if not found:
140
- result.append(
141
- Diff(
142
- action=action,
143
- group=f_group,
144
- user=f_user.user,
145
- access_level=f_user.access_level,
146
- )
105
+ def build_desired_state_spec(
106
+ group_name: str,
107
+ permissions: list[PermissionGitlabGroupMembershipV1],
108
+ pagerduty_map: PagerDutyMap,
109
+ all_users: list[User],
110
+ ) -> DesiredStateSpec:
111
+ desired_state_spec = DesiredStateSpec(members={})
112
+ for p in permissions:
113
+ if p.group == group_name:
114
+ p_access_level = GitLabApi.get_access_level(p.access)
115
+ for r in p.roles or []:
116
+ for u in (r.users or []) + (r.bots or []):
117
+ gu = GitlabUser(user=u.org_username, access_level=p_access_level)
118
+ add_or_update_user(desired_state_spec, gu)
119
+ if p.pagerduty:
120
+ usernames_from_pagerduty = get_usernames_from_pagerduty(
121
+ p.pagerduty,
122
+ all_users,
123
+ group_name,
124
+ pagerduty_map,
125
+ get_username_method=lambda u: u.org_username,
147
126
  )
148
- return result
149
-
150
-
151
- def check_access(current_state: State, desired_state: State) -> list[Diff]:
152
- """Return diff objects for item where access level is different."""
153
- result = []
154
- for d_group, d_users in desired_state.items():
155
- c_group = current_state[d_group]
156
- for d_user in d_users:
157
- for c_user in c_group:
158
- if d_user.user == c_user.user:
159
- if d_user.access_level != c_user.access_level:
160
- result.append(
161
- Diff(
162
- action=Action.change_access,
163
- group=d_group,
164
- user=c_user.user,
165
- access_level=d_user.access_level,
166
- )
167
- )
168
- break
169
- return result
170
-
171
-
172
- def act(diff: Diff, gl: GitLabApi) -> None:
173
- """Apply a diff object."""
174
- if diff.action == Action.remove_user_from_group:
175
- gl.remove_group_member(diff.group, diff.user)
176
- if diff.action == Action.add_user_to_group:
177
- gl.add_group_member(diff.group, diff.user, diff.access_level)
178
- if diff.action == Action.change_access:
179
- gl.change_access(diff.group, diff.user, diff.access_level)
127
+ for u in usernames_from_pagerduty:
128
+ gu = GitlabUser(user=u, access_level=p_access_level)
129
+ add_or_update_user(desired_state_spec, gu)
130
+ return desired_state_spec
180
131
 
181
132
 
182
133
  def get_permissions(query_func: Callable) -> list[PermissionGitlabGroupMembershipV1]:
@@ -197,6 +148,11 @@ def get_gitlab_instance(query_func: Callable) -> GitlabInstanceV1:
197
148
  raise AppInterfaceSettingsError("No gitlab instance found!")
198
149
 
199
150
 
151
+ def get_managed_groups_map(group_names: list[str], gl: GitLabApi) -> dict[str, Group]:
152
+ gitlab_groups = {group_name: gl.get_group(group_name) for group_name in group_names}
153
+ return gitlab_groups
154
+
155
+
200
156
  @defer
201
157
  def run(
202
158
  dry_run: bool,
@@ -228,17 +184,58 @@ def run(
228
184
  pagerduty_map = get_pagerduty_map(
229
185
  secret_reader, pagerduty_instances=pagerduty_instances
230
186
  )
231
-
232
- # act
233
- current_state = get_current_state(instance, gl)
187
+ managed_groups_map = get_managed_groups_map(instance.managed_groups, gl)
188
+ current_state = get_current_state(instance, gl, managed_groups_map)
234
189
  desired_state = get_desired_state(instance, pagerduty_map, permissions, all_users)
235
- diffs = calculate_diff(current_state, desired_state)
236
-
237
- for diff in diffs:
238
- logging.info(diff)
239
-
240
- if not dry_run:
241
- act(diff, gl)
190
+ for group_name in instance.managed_groups:
191
+ reconcile_gitlab_members(
192
+ current_state_spec=current_state.get(group_name),
193
+ desired_state_spec=desired_state.get(group_name),
194
+ group=managed_groups_map.get(group_name),
195
+ gl=gl,
196
+ dry_run=dry_run,
197
+ )
198
+
199
+
200
+ def reconcile_gitlab_members(
201
+ current_state_spec: CurrentStateSpec | None,
202
+ desired_state_spec: DesiredStateSpec | None,
203
+ group: Group | None,
204
+ gl: GitLabApi,
205
+ dry_run: bool,
206
+ ) -> None:
207
+ if current_state_spec and desired_state_spec and group:
208
+ diff_data = diff_mappings(
209
+ current=current_state_spec.members,
210
+ desired=desired_state_spec.members,
211
+ equal=lambda current, desired: current.access_level == desired.access_level,
212
+ )
213
+ for key, gitlab_user in diff_data.add.items():
214
+ logging.info([
215
+ key,
216
+ "add_user_to_group",
217
+ group.name,
218
+ gl.get_access_level_string(gitlab_user.access_level),
219
+ ])
220
+ if not dry_run:
221
+ gl.add_group_member(group, gitlab_user)
222
+ for key, group_member in diff_data.delete.items():
223
+ logging.info([
224
+ key,
225
+ "remove_user_from_group",
226
+ group.name,
227
+ ])
228
+ if not dry_run:
229
+ gl.remove_group_member(group, group_member.id)
230
+ for key, diff_pair in diff_data.change.items():
231
+ logging.info([
232
+ key,
233
+ "change_access",
234
+ group.name,
235
+ gl.get_access_level_string(diff_pair.desired.access_level),
236
+ ])
237
+ if not dry_run:
238
+ gl.change_access(diff_pair.current, diff_pair.desired.access_level)
242
239
 
243
240
 
244
241
  def early_exit_desired_state(*args: Any, **kwargs: Any) -> dict[str, Any]:
@@ -1,15 +1,20 @@
1
- import copy
2
1
  from typing import Any
2
+ from unittest.mock import create_autospec
3
3
 
4
4
  import pytest
5
+ from gitlab.v4.objects import (
6
+ Group,
7
+ GroupMember,
8
+ )
5
9
  from pytest_mock import MockerFixture
6
10
 
7
11
  from reconcile import gitlab_members
8
12
  from reconcile.gitlab_members import (
9
- Action,
10
- Diff,
13
+ CurrentState,
14
+ CurrentStateSpec,
15
+ DesiredState,
16
+ DesiredStateSpec,
11
17
  GitlabUser,
12
- State,
13
18
  add_or_update_user,
14
19
  get_permissions,
15
20
  )
@@ -37,16 +42,41 @@ def instance(vault_secret: VaultSecret) -> GitlabInstanceV1:
37
42
 
38
43
 
39
44
  @pytest.fixture()
40
- def state() -> State:
45
+ def gitlab_groups_map() -> dict[str, Group]:
46
+ group1 = create_autospec(Group, name="group1", id="123")
47
+ group1.name = "group1"
48
+ group2 = create_autospec(Group, name="group2", id="124")
49
+ group2.name = "group2"
41
50
  return {
42
- "group1": [
43
- GitlabUser(access_level="developer", user="user1"),
44
- GitlabUser(access_level="maintainer", user="user2"),
45
- ],
46
- "group2": [
47
- GitlabUser(access_level="developer", user="user3"),
48
- GitlabUser(access_level="maintainer", user="user4"),
49
- ],
51
+ "group1": group1,
52
+ "group2": group2,
53
+ }
54
+
55
+
56
+ @pytest.fixture()
57
+ def all_users() -> list[GroupMember]:
58
+ user1 = create_autospec(GroupMember, username="user1", id="123", access_level=30)
59
+ user2 = create_autospec(GroupMember, username="user2", id="124", access_level=40)
60
+ user3 = create_autospec(GroupMember, username="user3", id="125", access_level=30)
61
+ user4 = create_autospec(GroupMember, username="user4", id="126", access_level=40)
62
+ return [user1, user2, user3, user4]
63
+
64
+
65
+ @pytest.fixture()
66
+ def current_state(all_users: list[GroupMember]) -> CurrentState:
67
+ return {
68
+ "group1": CurrentStateSpec(
69
+ members={
70
+ "user1": all_users[0],
71
+ "user2": all_users[1],
72
+ },
73
+ ),
74
+ "group2": CurrentStateSpec(
75
+ members={
76
+ "user3": all_users[2],
77
+ "user4": all_users[3],
78
+ },
79
+ ),
50
80
  }
51
81
 
52
82
 
@@ -72,20 +102,27 @@ def user() -> User:
72
102
 
73
103
 
74
104
  def test_gitlab_members_get_current_state(
75
- mocker: MockerFixture, instance: GitlabInstanceV1, state: State
105
+ mocker: MockerFixture,
106
+ instance: GitlabInstanceV1,
107
+ current_state: CurrentState,
108
+ gitlab_groups_map: dict[str, Group],
109
+ all_users: list[GroupMember],
76
110
  ) -> None:
77
111
  gl_mock = mocker.create_autospec(GitLabApi)
78
112
  gl_mock.get_group_members.side_effect = [
79
113
  [
80
- {"user": "user1", "access_level": "developer"},
81
- {"user": "user2", "access_level": "maintainer"},
114
+ all_users[0],
115
+ all_users[1],
82
116
  ],
83
117
  [
84
- {"user": "user3", "access_level": "developer"},
85
- {"user": "user4", "access_level": "maintainer"},
118
+ all_users[2],
119
+ all_users[3],
86
120
  ],
87
121
  ]
88
- assert gitlab_members.get_current_state(instance, gl_mock) == state
122
+ assert (
123
+ gitlab_members.get_current_state(instance, gl_mock, gitlab_groups_map)
124
+ == current_state
125
+ )
89
126
 
90
127
 
91
128
  def test_gitlab_members_get_desired_state(
@@ -103,210 +140,80 @@ def test_gitlab_members_get_desired_state(
103
140
  assert gitlab_members.get_desired_state(
104
141
  instance, mock_pagerduty_map, permissions, [user]
105
142
  ) == {
106
- "group1": [GitlabUser(user="devtools-bot", access_level="owner")],
107
- "group2": [
108
- GitlabUser(user="devtools-bot", access_level="owner"),
109
- GitlabUser(user="user1", access_level="owner"),
110
- GitlabUser(user="user2", access_level="owner"),
111
- GitlabUser(user="user3", access_level="owner"),
112
- GitlabUser(user="user4", access_level="owner"),
113
- GitlabUser(user="another-bot", access_level="owner"),
114
- ],
115
- }
116
-
117
-
118
- def test_gitlab_members_calculate_diff_no_changes(state: State) -> None:
119
- # pylint: disable-next=use-implicit-booleaness-not-comparison # for better readability
120
- assert gitlab_members.calculate_diff(state, state) == []
121
-
122
-
123
- def test_gitlab_members_subtract_states_no_changes_add(state: State) -> None:
124
- # pylint: disable-next=use-implicit-booleaness-not-comparison # for better readability
125
- assert gitlab_members.subtract_states(state, state, Action.add_user_to_group) == []
126
-
127
-
128
- def test_gitlab_members_subtract_states_no_changes_remove(state: State) -> None:
129
- # pylint: disable=use-implicit-booleaness-not-comparison # for better readability
130
- assert (
131
- gitlab_members.subtract_states(state, state, Action.remove_user_from_group)
132
- == []
133
- )
134
-
135
-
136
- def test_gitlab_members_subtract_states_add(state: State) -> None:
137
- current_state = copy.deepcopy(state)
138
- # enforce add users to groups
139
- current_state["group2"] = [GitlabUser(access_level="maintainer", user="otherone")]
140
- del current_state["group1"][1]
141
-
142
- desired_state = state
143
- assert gitlab_members.subtract_states(
144
- desired_state, current_state, Action.add_user_to_group
145
- ) == [
146
- Diff(
147
- action=Action.add_user_to_group,
148
- group="group1",
149
- user="user2",
150
- access_level="maintainer",
151
- ),
152
- Diff(
153
- action=Action.add_user_to_group,
154
- group="group2",
155
- user="user3",
156
- access_level="developer",
157
- ),
158
- Diff(
159
- action=Action.add_user_to_group,
160
- group="group2",
161
- user="user4",
162
- access_level="maintainer",
163
- ),
164
- ]
165
-
166
-
167
- def test_gitlab_members_subtract_states_remove(state: State) -> None:
168
- current_state = copy.deepcopy(state)
169
- # enforce remove user from group
170
- current_state["group2"] = [GitlabUser(access_level="maintainer", user="otherone")]
171
-
172
- desired_state = state
173
- assert gitlab_members.subtract_states(
174
- current_state, desired_state, Action.remove_user_from_group
175
- ) == [
176
- Diff(
177
- action=Action.remove_user_from_group,
178
- group="group2",
179
- user="otherone",
180
- access_level="maintainer",
181
- )
182
- ]
183
-
184
-
185
- def test_gitlab_members_check_access_no_changes(state: State) -> None:
186
- # pylint: disable-next=use-implicit-booleaness-not-comparison # for better readability
187
- assert gitlab_members.check_access(state, state) == []
188
-
189
-
190
- def test_gitlab_members_check_access(state: State) -> None:
191
- current_state = copy.deepcopy(state)
192
- # enforce access change
193
- current_state["group1"][0].access_level = "owner"
194
- desired_state = state
195
- assert gitlab_members.check_access(current_state, desired_state) == [
196
- Diff(
197
- action=Action.change_access,
198
- group="group1",
199
- user="user1",
200
- access_level="developer",
201
- ),
202
- ]
203
-
204
-
205
- def test_gitlab_members_calculate_diff_changes(state: State) -> None:
206
- current_state = copy.deepcopy(state)
207
- # enforce remove user from group
208
- current_state["group2"] = [GitlabUser(access_level="maintainer", user="otherone")]
209
- # enforce add user to group
210
- del current_state["group1"][1]
211
- # enforce access change
212
- current_state["group1"][0].access_level = "owner"
213
- desired_state = state
214
- assert gitlab_members.calculate_diff(current_state, desired_state) == [
215
- Diff(
216
- action=Action.add_user_to_group,
217
- group="group1",
218
- user="user2",
219
- access_level="maintainer",
220
- ),
221
- Diff(
222
- action=Action.add_user_to_group,
223
- group="group2",
224
- user="user3",
225
- access_level="developer",
226
- ),
227
- Diff(
228
- action=Action.add_user_to_group,
229
- group="group2",
230
- user="user4",
231
- access_level="maintainer",
232
- ),
233
- Diff(
234
- action=Action.remove_user_from_group,
235
- group="group2",
236
- user="otherone",
237
- access_level="maintainer",
143
+ "group1": DesiredStateSpec(
144
+ members={"devtools-bot": GitlabUser(user="devtools-bot", access_level=50)},
238
145
  ),
239
- Diff(
240
- action=Action.change_access,
241
- group="group1",
242
- user="user1",
243
- access_level="developer",
146
+ "group2": DesiredStateSpec(
147
+ members={
148
+ "devtools-bot": GitlabUser(user="devtools-bot", access_level=50),
149
+ "user1": GitlabUser(user="user1", access_level=50),
150
+ "user2": GitlabUser(user="user2", access_level=50),
151
+ "user3": GitlabUser(user="user3", access_level=50),
152
+ "user4": GitlabUser(user="user4", access_level=50),
153
+ "another-bot": GitlabUser(user="another-bot", access_level=50),
154
+ },
244
155
  ),
245
- ]
246
-
247
-
248
- def test_gitlab_members_act_add(mocker: MockerFixture) -> None:
249
- gl_mock = mocker.create_autospec(GitLabApi)
250
- diff = Diff(
251
- action=Action.add_user_to_group,
252
- group="group2",
253
- user="user4",
254
- access_level="maintainer",
255
- )
256
- gitlab_members.act(diff, gl_mock)
257
- gl_mock.add_group_member.assert_called_once()
258
- gl_mock.remove_group_member.assert_not_called()
259
- gl_mock.change_access.assert_not_called()
260
-
261
-
262
- def test_gitlab_members_act_remove(mocker: MockerFixture) -> None:
263
- gl_mock = mocker.create_autospec(GitLabApi)
264
- diff = Diff(
265
- action=Action.remove_user_from_group,
266
- group="group2",
267
- user="otherone",
268
- access_level="maintainer",
269
- )
270
- gitlab_members.act(diff, gl_mock)
271
- gl_mock.add_group_member.assert_not_called()
272
- gl_mock.remove_group_member.assert_called_once()
273
- gl_mock.change_access.assert_not_called()
156
+ }
274
157
 
275
158
 
276
- def test_gitlab_members_act_change(mocker: MockerFixture) -> None:
159
+ def test_gitlab_members_reconcile_gitlab_members(
160
+ gitlab_groups_map: dict[str, Group],
161
+ mocker: MockerFixture,
162
+ all_users: list[GroupMember],
163
+ ) -> None:
277
164
  gl_mock = mocker.create_autospec(GitLabApi)
278
- diff = Diff(
279
- action=Action.change_access,
280
- group="group1",
281
- user="user1",
282
- access_level="developer",
165
+ current_state: CurrentState = {
166
+ "group1": CurrentStateSpec(
167
+ members={
168
+ "user1": all_users[0],
169
+ "user3": all_users[2],
170
+ "user4": all_users[3],
171
+ },
172
+ )
173
+ }
174
+ new_user = GitlabUser(user="new_user", access_level=40)
175
+ desired_state: DesiredState = {
176
+ "group1": DesiredStateSpec(
177
+ members={
178
+ "user1": GitlabUser(user="user1", access_level=30),
179
+ "new_user": new_user,
180
+ "user3": GitlabUser(user="user3", access_level=50),
181
+ }
182
+ )
183
+ }
184
+ group = gitlab_groups_map.get("group1")
185
+ gitlab_members.reconcile_gitlab_members(
186
+ current_state_spec=current_state.get("group1"),
187
+ desired_state_spec=desired_state.get("group1"),
188
+ group=group,
189
+ dry_run=False,
190
+ gl=gl_mock,
283
191
  )
284
- gitlab_members.act(diff, gl_mock)
285
- gl_mock.add_group_member.assert_not_called()
286
- gl_mock.remove_group_member.assert_not_called()
287
- gl_mock.change_access.assert_called_once()
192
+ gl_mock.add_group_member.assert_called_once_with(group, new_user)
193
+ gl_mock.change_access.assert_called_once_with(all_users[2], 50)
194
+ gl_mock.remove_group_member.assert_called_once_with(group, all_users[3].id)
288
195
 
289
196
 
290
197
  def test_add_or_update_user_add():
291
- group_members: State = {"t": []}
292
- gu = GitlabUser(user="u", access_level="owner")
293
- add_or_update_user(group_members, "t", gu)
294
- assert group_members == {"t": [gu]}
198
+ desired_state_spec: DesiredStateSpec = DesiredStateSpec(members={})
199
+ gu = GitlabUser(user="u", access_level=50, id="1234")
200
+ add_or_update_user(desired_state_spec, gu)
201
+ assert desired_state_spec == DesiredStateSpec(members={"u": gu})
295
202
 
296
203
 
297
204
  def test_add_or_update_user_update_higher():
298
- group_members: State = {"t": []}
299
- gu1 = GitlabUser(user="u", access_level="maintainer")
300
- gu2 = GitlabUser(user="u", access_level="owner")
301
- add_or_update_user(group_members, "t", gu1)
302
- add_or_update_user(group_members, "t", gu2)
303
- assert group_members == {"t": [gu2]}
205
+ desired_state_spec: DesiredStateSpec = DesiredStateSpec(members={})
206
+ gu1 = GitlabUser(user="u", access_level=40)
207
+ gu2 = GitlabUser(user="u", access_level=50)
208
+ add_or_update_user(desired_state_spec, gu1)
209
+ add_or_update_user(desired_state_spec, gu2)
210
+ assert desired_state_spec == DesiredStateSpec(members={"u": gu2})
304
211
 
305
212
 
306
213
  def test_add_or_update_user_update_lower():
307
- group_members: State = {"t": []}
308
- gu1 = GitlabUser(user="u", access_level="owner")
309
- gu2 = GitlabUser(user="u", access_level="maintainer")
310
- add_or_update_user(group_members, "t", gu1)
311
- add_or_update_user(group_members, "t", gu2)
312
- assert group_members == {"t": [gu1]}
214
+ desired_state_spec: DesiredStateSpec = DesiredStateSpec(members={})
215
+ gu1 = GitlabUser(user="u", access_level=50)
216
+ gu2 = GitlabUser(user="u", access_level=40)
217
+ add_or_update_user(desired_state_spec, gu1)
218
+ add_or_update_user(desired_state_spec, gu2)
219
+ assert desired_state_spec == DesiredStateSpec(members={"u": gu1})
@@ -29,6 +29,7 @@ from gitlab.const import (
29
29
  from gitlab.v4.objects import (
30
30
  CurrentUser,
31
31
  Group,
32
+ GroupMember,
32
33
  PersonalAccessToken,
33
34
  Project,
34
35
  ProjectIssue,
@@ -84,6 +85,7 @@ GROUP_BOT_NAME_REGEX = re.compile(r"group_.+_bot_.+")
84
85
 
85
86
 
86
87
  class GLGroupMember(TypedDict):
88
+ id: str
87
89
  user: str
88
90
  access_level: str
89
91
 
@@ -288,17 +290,13 @@ class GitLabApi: # pylint: disable=too-many-public-methods
288
290
  """
289
291
  return GROUP_BOT_NAME_REGEX.match(username) is not None
290
292
 
291
- def get_group_members(self, group_name: str) -> list[GLGroupMember]:
292
- group = self.get_group_if_exists(group_name)
293
+ def get_group_members(self, group: Group | None) -> list[GroupMember]:
293
294
  if group is None:
294
- logging.error(group_name + " group not found")
295
+ logging.error("no group provided")
295
296
  return []
296
297
  else:
297
298
  return [
298
- {
299
- "user": m.username,
300
- "access_level": self.get_access_level_string(m.access_level),
301
- }
299
+ m
302
300
  for m in self.get_items(group.members.list)
303
301
  if not self._is_bot_username(m.username)
304
302
  ]
@@ -315,40 +313,27 @@ class GitLabApi: # pylint: disable=too-many-public-methods
315
313
  member.access_level = access_level
316
314
  member.save()
317
315
 
318
- def add_group_member(self, group_name, username, access):
319
- group = self.get_group_if_exists(group_name)
320
- if not group:
321
- logging.error(group_name + " group not found")
322
- else:
323
- user = self.get_user(username)
324
- access_level = self.get_access_level(access)
325
- if user is not None:
326
- gitlab_request.labels(integration=INTEGRATION_NAME).inc()
327
- try:
328
- group.members.create({
329
- "user_id": user.id,
330
- "access_level": access_level,
331
- })
332
- except gitlab.exceptions.GitlabCreateError:
333
- gitlab_request.labels(integration=INTEGRATION_NAME).inc()
334
- member = group.members.get(user.id)
335
- member.access_level = access_level
336
-
337
- def remove_group_member(self, group_name, username):
338
- gitlab_request.labels(integration=INTEGRATION_NAME).inc()
339
- group = self.gl.groups.get(group_name)
340
- user = self.get_user(username)
341
- if user is not None:
316
+ def add_group_member(self, group, user):
317
+ gitlab_user = self.get_user(user.user)
318
+ if gitlab_user is not None:
342
319
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()
343
- group.members.delete(user.id)
320
+ try:
321
+ group.members.create({
322
+ "user_id": gitlab_user.id,
323
+ "access_level": user.access_level,
324
+ })
325
+ except gitlab.exceptions.GitlabCreateError:
326
+ gitlab_request.labels(integration=INTEGRATION_NAME).inc()
327
+ member = group.members.get(user.user)
328
+ member.access_level = user.access_level
329
+ member.save()
344
330
 
345
- def change_access(self, group, username, access):
346
- gitlab_request.labels(integration=INTEGRATION_NAME).inc()
347
- group = self.gl.groups.get(group)
348
- user = self.get_user(username)
331
+ def remove_group_member(self, group, user_id):
349
332
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()
350
- member = group.members.get(user.id)
351
- member.access_level = self.get_access_level(access)
333
+ group.members.delete(user_id)
334
+
335
+ def change_access(self, member, access_level):
336
+ member.access_level = access_level
352
337
  gitlab_request.labels(integration=INTEGRATION_NAME).inc()
353
338
  member.save()
354
339
 
@@ -0,0 +1,129 @@
1
+ import re
2
+ from collections import defaultdict
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel
7
+ from sretoolbox.utils import threaded
8
+
9
+ from reconcile.gql_definitions.common.namespaces_minimal import NamespaceV1
10
+ from reconcile.typed_queries.app_interface_vault_settings import (
11
+ get_app_interface_vault_settings,
12
+ )
13
+ from reconcile.typed_queries.namespaces_minimal import get_namespaces_minimal
14
+ from reconcile.utils.oc_filters import filter_namespaces_by_cluster_and_namespace
15
+ from reconcile.utils.oc_map import OCMap, init_oc_map_from_namespaces
16
+ from reconcile.utils.secret_reader import create_secret_reader
17
+
18
+ IMAGE_NAME_REGEX = re.compile(r"^(?P<name>[a-zA-Z0-9][a-zA-Z0-9/_.-]+)(?:@sha256)?:.+$")
19
+
20
+
21
+ class NamespaceImages(BaseModel):
22
+ namespace_name: str
23
+ image_names: list[str]
24
+
25
+
26
+ def get_all_pods_images(
27
+ cluster_name: Sequence[str] | None = None,
28
+ namespace_name: Sequence[str] | None = None,
29
+ thread_pool_size: int = 10,
30
+ use_jump_host: bool = True,
31
+ include_pattern: str | None = None,
32
+ exclude_pattern: str | None = None,
33
+ ) -> list[dict[str, Any]]:
34
+ """Gets all the images in the clusters/namespaces given. Returns a list of dicts
35
+ with the following keys:
36
+ * name: image name
37
+ * namespaces: a comma separated list of namespaces where the instance is used
38
+ * count: number of uses of the image
39
+ """
40
+ all_namespaces = get_namespaces_minimal()
41
+ namespaces = filter_namespaces_by_cluster_and_namespace(
42
+ namespaces=all_namespaces,
43
+ cluster_names=cluster_name,
44
+ namespace_names=namespace_name,
45
+ )
46
+ vault_settings = get_app_interface_vault_settings()
47
+ secret_reader = create_secret_reader(use_vault=vault_settings.vault)
48
+ oc_map = init_oc_map_from_namespaces(
49
+ namespaces=namespaces,
50
+ integration="qontract-cli-get-namespace_images",
51
+ secret_reader=secret_reader,
52
+ use_jump_host=use_jump_host,
53
+ thread_pool_size=thread_pool_size,
54
+ init_projects=True,
55
+ )
56
+
57
+ return fetch_pods_images_from_namespaces(
58
+ namespaces=namespaces,
59
+ oc_map=oc_map,
60
+ exclude_pattern=exclude_pattern,
61
+ include_pattern=include_pattern,
62
+ thread_pool_size=thread_pool_size,
63
+ )
64
+
65
+
66
+ def fetch_pods_images_from_namespaces(
67
+ namespaces: list[NamespaceV1],
68
+ oc_map: OCMap,
69
+ include_pattern: str | None = None,
70
+ exclude_pattern: str | None = None,
71
+ thread_pool_size: int = 10,
72
+ ) -> list[dict[str, Any]]:
73
+ all_namespace_images = threaded.run(
74
+ func=_get_namespace_images,
75
+ iterable=namespaces,
76
+ thread_pool_size=thread_pool_size,
77
+ oc_map=oc_map,
78
+ )
79
+
80
+ result: defaultdict = defaultdict(_get_all_images_default)
81
+ for ni in all_namespace_images:
82
+ for name in ni.image_names:
83
+ result[name]["namespaces"].add(ni.namespace_name)
84
+ result[name]["count"] += 1
85
+
86
+ exclude_pattern_compiled: re.Pattern | None = None
87
+ if exclude_pattern:
88
+ exclude_pattern_compiled = re.compile(exclude_pattern)
89
+
90
+ include_pattern_compiled: re.Pattern | None = None
91
+ if include_pattern:
92
+ include_pattern_compiled = re.compile(include_pattern)
93
+
94
+ result_filtered_flattened: list[dict[str, Any]] = []
95
+ for name, value in result.items():
96
+ if include_pattern_compiled and not include_pattern_compiled.match(name):
97
+ continue
98
+ if exclude_pattern_compiled and exclude_pattern_compiled.match(name):
99
+ continue
100
+
101
+ result_filtered_flattened.append({
102
+ "name": name,
103
+ "namespaces": ",".join(sorted(value["namespaces"])),
104
+ "count": value["count"],
105
+ })
106
+
107
+ return result_filtered_flattened
108
+
109
+
110
+ def _get_all_images_default() -> dict[str, Any]:
111
+ return {"namespaces": set(), "count": 0}
112
+
113
+
114
+ def _get_namespace_images(ns: NamespaceV1, oc_map: OCMap) -> NamespaceImages:
115
+ image_names = []
116
+ oc = oc_map.get_cluster(ns.cluster.name)
117
+ pod_items = oc.get_items("Pod", namespace=ns.name)
118
+ for pod in pod_items:
119
+ containers = pod.get("spec", {}).get("containers", [])
120
+ containers.extend(pod.get("spec", {}).get("initContainers", []))
121
+
122
+ for c in containers:
123
+ if m := IMAGE_NAME_REGEX.match(c["image"]):
124
+ image_names.append(m.group("name"))
125
+
126
+ return NamespaceImages(
127
+ namespace_name=ns.name,
128
+ image_names=image_names,
129
+ )
tools/qontract_cli.py CHANGED
@@ -63,9 +63,14 @@ from reconcile.checkpoint import report_invalid_metadata
63
63
  from reconcile.cli import (
64
64
  TERRAFORM_VERSION,
65
65
  TERRAFORM_VERSION_REGEX,
66
+ cluster_name,
66
67
  config_file,
68
+ namespace_name,
67
69
  use_jump_host,
68
70
  )
71
+ from reconcile.cli import (
72
+ threaded as thread_pool_size,
73
+ )
69
74
  from reconcile.gql_definitions.advanced_upgrade_service.aus_clusters import (
70
75
  query as aus_clusters_query,
71
76
  )
@@ -136,7 +141,9 @@ from reconcile.utils.oc import (
136
141
  OC_Map,
137
142
  OCLogMsg,
138
143
  )
139
- from reconcile.utils.oc_map import init_oc_map_from_clusters
144
+ from reconcile.utils.oc_map import (
145
+ init_oc_map_from_clusters,
146
+ )
140
147
  from reconcile.utils.ocm import OCM_PRODUCT_ROSA, OCMMap
141
148
  from reconcile.utils.ocm_base_client import init_ocm_base_client
142
149
  from reconcile.utils.output import print_output
@@ -4313,5 +4320,68 @@ def migrate(ctx, dry_run: bool, skip_build: bool) -> None:
4313
4320
  rich_print(f"[b red]Please remove the temporary directory ({tempdir}) manually!")
4314
4321
 
4315
4322
 
4323
+ @get.command(help="Get all container images in app-interface defined namespaces")
4324
+ @cluster_name
4325
+ @namespace_name
4326
+ @thread_pool_size()
4327
+ @use_jump_host()
4328
+ @click.option("--exclude-pattern", help="Exclude images that match this pattern")
4329
+ @click.option("--include-pattern", help="Only include images that match this pattern")
4330
+ @click.pass_context
4331
+ def container_images(
4332
+ ctx,
4333
+ cluster_name,
4334
+ namespace_name,
4335
+ thread_pool_size,
4336
+ use_jump_host,
4337
+ exclude_pattern,
4338
+ include_pattern,
4339
+ ):
4340
+ from tools.cli_commands.container_images_report import get_all_pods_images
4341
+
4342
+ results = get_all_pods_images(
4343
+ cluster_name=cluster_name,
4344
+ namespace_name=namespace_name,
4345
+ thread_pool_size=thread_pool_size,
4346
+ use_jump_host=use_jump_host,
4347
+ exclude_pattern=exclude_pattern,
4348
+ include_pattern=include_pattern,
4349
+ )
4350
+
4351
+ if ctx.obj["options"]["output"] == "md":
4352
+ json_table = {
4353
+ "filter": True,
4354
+ "fields": [
4355
+ {"key": "name", "sortable": True},
4356
+ {"key": "namespaces", "sortable": True},
4357
+ {"key": "count", "sortable": True},
4358
+ ],
4359
+ "items": results,
4360
+ }
4361
+
4362
+ print(
4363
+ f"""
4364
+ You can view the source of this Markdown to extract the JSON data.
4365
+
4366
+ {len(results)} container images found.
4367
+
4368
+ exclude-pattern = {exclude_pattern}
4369
+ include-pattern = {include_pattern}
4370
+
4371
+ ```json:table
4372
+ {json.dumps(json_table)}
4373
+ ```
4374
+ """
4375
+ )
4376
+ else:
4377
+ columns = [
4378
+ "name",
4379
+ "namespaces",
4380
+ "count",
4381
+ ]
4382
+ ctx.obj["options"]["sort"] = False
4383
+ print_output(ctx.obj["options"], results, columns)
4384
+
4385
+
4316
4386
  if __name__ == "__main__":
4317
4387
  root() # pylint: disable=no-value-for-parameter
@@ -0,0 +1,187 @@
1
+ import pytest
2
+ from pytest_mock import MockerFixture
3
+
4
+ from reconcile.gql_definitions.common.namespaces_minimal import ClusterV1, NamespaceV1
5
+ from reconcile.gql_definitions.fragments.vault_secret import VaultSecret
6
+ from reconcile.test.fixtures import Fixtures
7
+ from reconcile.utils.oc import OCNative
8
+ from reconcile.utils.oc_map import OCMap
9
+ from tools.cli_commands.container_images_report import (
10
+ fetch_pods_images_from_namespaces,
11
+ )
12
+
13
+ fxt = Fixtures("container_images_report")
14
+
15
+
16
+ @pytest.fixture
17
+ def observability_pods() -> list[dict]:
18
+ return fxt.get_anymarkup("app-sre-observability-stage-pods.yaml")
19
+
20
+
21
+ @pytest.fixture
22
+ def pipeline_pods() -> list[dict]:
23
+ return fxt.get_anymarkup("app-sre-pipelines-pods.yaml")
24
+
25
+
26
+ @pytest.fixture
27
+ def namespaces() -> list[NamespaceV1]:
28
+ return [
29
+ NamespaceV1(
30
+ name="app-sre-observability-stage",
31
+ delete=None,
32
+ labels="{}",
33
+ clusterAdmin=None,
34
+ cluster=ClusterV1(
35
+ name="appsres09ue1",
36
+ serverUrl="https://api.appsres09ue1.24ep.p3.openshiftapps.com:443",
37
+ insecureSkipTLSVerify=None,
38
+ jumpHost=None,
39
+ automationToken=VaultSecret(
40
+ path="app-sre/integrations-output/openshift-cluster-bots/appsres09ue1",
41
+ field="token",
42
+ version=None,
43
+ format=None,
44
+ ),
45
+ clusterAdminAutomationToken=VaultSecret(
46
+ path="app-sre/integrations-output/openshift-cluster-bots/appsres09ue1-cluster-admin",
47
+ field="token",
48
+ version=None,
49
+ format=None,
50
+ ),
51
+ internal=True,
52
+ disable=None,
53
+ ),
54
+ ),
55
+ NamespaceV1(
56
+ name="app-sre-pipelines",
57
+ delete=None,
58
+ labels='{"provider": "tekton"}',
59
+ clusterAdmin=None,
60
+ cluster=ClusterV1(
61
+ name="appsres09ue1",
62
+ serverUrl="https://api.appsres09ue1.24ep.p3.openshiftapps.com:443",
63
+ insecureSkipTLSVerify=None,
64
+ jumpHost=None,
65
+ automationToken=VaultSecret(
66
+ path="app-sre/integrations-output/openshift-cluster-bots/appsres09ue1",
67
+ field="token",
68
+ version=None,
69
+ format=None,
70
+ ),
71
+ clusterAdminAutomationToken=VaultSecret(
72
+ path="app-sre/integrations-output/openshift-cluster-bots/appsres09ue1-cluster-admin",
73
+ field="token",
74
+ version=None,
75
+ format=None,
76
+ ),
77
+ internal=True,
78
+ disable=None,
79
+ ),
80
+ ),
81
+ ]
82
+
83
+
84
+ @pytest.fixture
85
+ def oc(
86
+ mocker: MockerFixture,
87
+ observability_pods: list[dict],
88
+ pipeline_pods: list[dict],
89
+ ) -> OCNative:
90
+ oc = mocker.patch("reconcile.utils.oc.OCNative", autospec=True)
91
+ oc.get_items.side_effect = [observability_pods, pipeline_pods]
92
+ return oc
93
+
94
+
95
+ @pytest.fixture
96
+ def oc_map(mocker: MockerFixture, oc: OCNative) -> OCMap:
97
+ oc_map = mocker.patch("reconcile.utils.oc_map.OCMap", autospec=True)
98
+ oc_map.get_cluster.return_value = oc
99
+ return oc_map
100
+
101
+
102
+ # convert a list of dicts into a set of tuples to use it in assertions
103
+ def _to_set(list_of_dicts: list[dict]) -> set[tuple]:
104
+ return {tuple(d.items()) for d in list_of_dicts}
105
+
106
+
107
+ def testfetch_no_filter(namespaces: list[NamespaceV1], oc_map: OCMap) -> None:
108
+ images = fetch_pods_images_from_namespaces(
109
+ namespaces=namespaces,
110
+ oc_map=oc_map,
111
+ thread_pool_size=2,
112
+ )
113
+
114
+ assert _to_set(images) == _to_set([
115
+ {
116
+ "name": "quay.io/prometheus/blackbox-exporter",
117
+ "namespaces": "app-sre-observability-stage",
118
+ "count": 1,
119
+ },
120
+ {
121
+ "name": "quay.io/redhat-services-prod/app-sre-tenant/gitlab-project-exporter-main/gitlab-project-exporter-main",
122
+ "namespaces": "app-sre-observability-stage",
123
+ "count": 1,
124
+ },
125
+ {
126
+ "name": "quay.io/app-sre/internal-redhat-ca",
127
+ "namespaces": "app-sre-observability-stage,app-sre-pipelines",
128
+ "count": 3,
129
+ },
130
+ {
131
+ "name": "quay.io/app-sre/clamav",
132
+ "namespaces": "app-sre-pipelines",
133
+ "count": 1,
134
+ },
135
+ {
136
+ "name": "quay.io/redhat-appstudio/clamav-db",
137
+ "namespaces": "app-sre-pipelines",
138
+ "count": 1,
139
+ },
140
+ {
141
+ "name": "registry.redhat.io/openshift-pipelines/pipelines-entrypoint-rhel8",
142
+ "namespaces": "app-sre-pipelines",
143
+ "count": 3,
144
+ },
145
+ {
146
+ "name": "quay.io/redhatproductsecurity/rapidast",
147
+ "namespaces": "app-sre-pipelines",
148
+ "count": 1,
149
+ },
150
+ ])
151
+
152
+
153
+ def testfetch_exclude_pattern(namespaces: list[NamespaceV1], oc_map: OCMap) -> None:
154
+ images = fetch_pods_images_from_namespaces(
155
+ namespaces=namespaces,
156
+ oc_map=oc_map,
157
+ thread_pool_size=2,
158
+ exclude_pattern="quay.io/redhat|quay.io/app-sre",
159
+ )
160
+ assert _to_set(images) == _to_set([
161
+ {
162
+ "name": "quay.io/prometheus/blackbox-exporter",
163
+ "namespaces": "app-sre-observability-stage",
164
+ "count": 1,
165
+ },
166
+ {
167
+ "name": "registry.redhat.io/openshift-pipelines/pipelines-entrypoint-rhel8",
168
+ "namespaces": "app-sre-pipelines",
169
+ "count": 3,
170
+ },
171
+ ])
172
+
173
+
174
+ def testfetch_include_pattern(namespaces: list[NamespaceV1], oc_map: OCMap) -> None:
175
+ images = fetch_pods_images_from_namespaces(
176
+ namespaces=namespaces,
177
+ oc_map=oc_map,
178
+ thread_pool_size=2,
179
+ include_pattern="^registry.redhat.io",
180
+ )
181
+ assert images == [
182
+ {
183
+ "name": "registry.redhat.io/openshift-pipelines/pipelines-entrypoint-rhel8",
184
+ "namespaces": "app-sre-pipelines",
185
+ "count": 3,
186
+ },
187
+ ]