qontract-reconcile 0.10.2.dev484__py3-none-any.whl → 0.10.2.dev493__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.
- {qontract_reconcile-0.10.2.dev484.dist-info → qontract_reconcile-0.10.2.dev493.dist-info}/METADATA +4 -2
- {qontract_reconcile-0.10.2.dev484.dist-info → qontract_reconcile-0.10.2.dev493.dist-info}/RECORD +15 -7
- reconcile/cli.py +25 -1
- reconcile/gql_definitions/common/vcs.py +91 -0
- reconcile/gql_definitions/slack_usergroups_api/__init__.py +0 -0
- reconcile/gql_definitions/slack_usergroups_api/clusters.py +76 -0
- reconcile/gql_definitions/slack_usergroups_api/permissions.py +173 -0
- reconcile/gql_definitions/slack_usergroups_api/roles.py +135 -0
- reconcile/gql_definitions/slack_usergroups_api/users.py +111 -0
- reconcile/slack_usergroups_api.py +672 -0
- reconcile/typed_queries/vcs.py +42 -0
- reconcile/utils/runtime/integration.py +99 -0
- reconcile/utils/runtime/runner.py +28 -7
- {qontract_reconcile-0.10.2.dev484.dist-info → qontract_reconcile-0.10.2.dev493.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.2.dev484.dist-info → qontract_reconcile-0.10.2.dev493.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""Slack usergroups reconciliation via qontract-api.
|
|
2
|
+
|
|
3
|
+
This is a POC client-side integration that calls the qontract-api
|
|
4
|
+
instead of directly managing Slack usergroups.
|
|
5
|
+
|
|
6
|
+
See ADR-002 (Client-Side GraphQL) and ADR-008 (Integration Naming).
|
|
7
|
+
|
|
8
|
+
Differences from reconcile/slack_usergroups.py:
|
|
9
|
+
- Suffix '_api' indicates API-based integration
|
|
10
|
+
- GraphQL queries for desired state happen client-side
|
|
11
|
+
- Business logic (reconciliation) happens server-side (qontract-api)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from pydantic import BaseModel
|
|
23
|
+
from qontract_api_client.api.external.pagerduty_escalation_policy_users import (
|
|
24
|
+
EscalationPolicyUsersResponse,
|
|
25
|
+
)
|
|
26
|
+
from qontract_api_client.api.external.pagerduty_escalation_policy_users import (
|
|
27
|
+
asyncio as get_pagerduty_escalation_policy_users,
|
|
28
|
+
)
|
|
29
|
+
from qontract_api_client.api.external.pagerduty_schedule_users import (
|
|
30
|
+
ScheduleUsersResponse,
|
|
31
|
+
)
|
|
32
|
+
from qontract_api_client.api.external.pagerduty_schedule_users import (
|
|
33
|
+
asyncio as get_pagerduty_schedule_users,
|
|
34
|
+
)
|
|
35
|
+
from qontract_api_client.api.external.vcs_repo_owners import (
|
|
36
|
+
RepoOwnersResponse,
|
|
37
|
+
)
|
|
38
|
+
from qontract_api_client.api.external.vcs_repo_owners import (
|
|
39
|
+
asyncio as get_repo_owners,
|
|
40
|
+
)
|
|
41
|
+
from qontract_api_client.api.integrations.slack_usergroups import (
|
|
42
|
+
SlackUsergroupsReconcileRequest,
|
|
43
|
+
SlackUsergroupsTaskResponse,
|
|
44
|
+
)
|
|
45
|
+
from qontract_api_client.api.integrations.slack_usergroups import (
|
|
46
|
+
asyncio as reconcile_slack_usergroups,
|
|
47
|
+
)
|
|
48
|
+
from qontract_api_client.api.integrations.slack_usergroups_task_status import (
|
|
49
|
+
asyncio as slack_usergroups_task_status,
|
|
50
|
+
)
|
|
51
|
+
from qontract_api_client.models import (
|
|
52
|
+
SlackUsergroupActionUpdateUsers,
|
|
53
|
+
)
|
|
54
|
+
from qontract_api_client.models.secret import Secret
|
|
55
|
+
from qontract_api_client.models.slack_usergroup import SlackUsergroup
|
|
56
|
+
from qontract_api_client.models.slack_usergroup_config import SlackUsergroupConfig
|
|
57
|
+
from qontract_api_client.models.slack_workspace import (
|
|
58
|
+
SlackWorkspace as SlackWorkspaceRequest,
|
|
59
|
+
)
|
|
60
|
+
from qontract_api_client.models.task_status import TaskStatus
|
|
61
|
+
from qontract_api_client.models.vcs_provider import VCSProvider
|
|
62
|
+
from qontract_utils.vcs import VCSProviderRegistry, get_default_registry
|
|
63
|
+
|
|
64
|
+
from reconcile.gql_definitions.slack_usergroups_api.clusters import ClusterV1
|
|
65
|
+
from reconcile.gql_definitions.slack_usergroups_api.clusters import (
|
|
66
|
+
query as clusters_query,
|
|
67
|
+
)
|
|
68
|
+
from reconcile.gql_definitions.slack_usergroups_api.permissions import (
|
|
69
|
+
PagerDutyTargetV1,
|
|
70
|
+
PermissionSlackUsergroupV1,
|
|
71
|
+
RoleV1,
|
|
72
|
+
ScheduleV1,
|
|
73
|
+
VaultSecret,
|
|
74
|
+
)
|
|
75
|
+
from reconcile.gql_definitions.slack_usergroups_api.permissions import (
|
|
76
|
+
query as permissions_query,
|
|
77
|
+
)
|
|
78
|
+
from reconcile.gql_definitions.slack_usergroups_api.roles import (
|
|
79
|
+
RoleV1 as ClusterAccessRole,
|
|
80
|
+
)
|
|
81
|
+
from reconcile.gql_definitions.slack_usergroups_api.roles import (
|
|
82
|
+
UserV1 as ClusterAccessUser,
|
|
83
|
+
)
|
|
84
|
+
from reconcile.gql_definitions.slack_usergroups_api.roles import query as roles_query
|
|
85
|
+
from reconcile.gql_definitions.slack_usergroups_api.users import UserV1
|
|
86
|
+
from reconcile.gql_definitions.slack_usergroups_api.users import query as users_query
|
|
87
|
+
from reconcile.typed_queries.vcs import Vcs, get_vcs_instances
|
|
88
|
+
from reconcile.utils import expiration, gql
|
|
89
|
+
from reconcile.utils.datetime_util import ensure_utc, utc_now
|
|
90
|
+
from reconcile.utils.disabled_integrations import integration_is_enabled
|
|
91
|
+
from reconcile.utils.runtime.integration import (
|
|
92
|
+
PydanticRunParams,
|
|
93
|
+
QontractReconcileApiIntegration,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
QONTRACT_INTEGRATION = "slack-usergroups-api"
|
|
97
|
+
INTEGRATION_VERSION = "0.1.0"
|
|
98
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SlackWorkspace(BaseModel, arbitrary_types_allowed=True):
|
|
102
|
+
"""A Slack workspace with its token and usergroups."""
|
|
103
|
+
|
|
104
|
+
name: str
|
|
105
|
+
usergroups: list[SlackUsergroup]
|
|
106
|
+
managed_usergroups: list[str]
|
|
107
|
+
default_channel: str
|
|
108
|
+
token: VaultSecret
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SlackUsergroupsIntegrationParams(PydanticRunParams):
|
|
112
|
+
"""Parameters for slack-usergroups-api integration."""
|
|
113
|
+
|
|
114
|
+
workspace_name: str | None
|
|
115
|
+
usergroup_name: str | None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_token_from_url(
|
|
119
|
+
vcs_reg: VCSProviderRegistry, vcs_instances: Iterable[Vcs], url: str
|
|
120
|
+
) -> VaultSecret:
|
|
121
|
+
"""Get the token for a given VCS URL from the VCS instances.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
vcs_reg: VCS provider registry
|
|
125
|
+
vcs_instances: List of VCS instances
|
|
126
|
+
url: VCS repository URL
|
|
127
|
+
Returns:
|
|
128
|
+
VaultSecret token if found, else raises ValueError
|
|
129
|
+
"""
|
|
130
|
+
vcs_provider = vcs_reg.detect_provider(url)
|
|
131
|
+
repo = vcs_provider.parse_url(url)
|
|
132
|
+
if not (
|
|
133
|
+
repo_owner_url := getattr(repo, "owner_url", None)
|
|
134
|
+
or getattr(repo, "gitlab_url", None)
|
|
135
|
+
):
|
|
136
|
+
raise ValueError(f"Cannot extract owner URL from repo: {repo}")
|
|
137
|
+
|
|
138
|
+
# Find matching VCS instance by repo owner URL
|
|
139
|
+
for vcs in vcs_instances:
|
|
140
|
+
if vcs.url == repo_owner_url:
|
|
141
|
+
return vcs.token
|
|
142
|
+
|
|
143
|
+
# Fallback to default VCS instance for the Github provider
|
|
144
|
+
if vcs_provider.type == VCSProvider.GITHUB:
|
|
145
|
+
for vcs in vcs_instances:
|
|
146
|
+
if vcs.default:
|
|
147
|
+
return vcs.token
|
|
148
|
+
|
|
149
|
+
raise ValueError(f"No matching VCS instance found for URL: {url}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class SlackUsergroupsIntegration(
|
|
153
|
+
QontractReconcileApiIntegration[SlackUsergroupsIntegrationParams]
|
|
154
|
+
):
|
|
155
|
+
"""Manage Slack usergroups via qontract-api."""
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def name(self) -> str:
|
|
159
|
+
return QONTRACT_INTEGRATION
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def get_permissions(query_func: Any) -> list[PermissionSlackUsergroupV1]:
|
|
163
|
+
"""Query permissions from App-Interface.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
query_func: GraphQL query function
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
List of Slack usergroup permissions
|
|
170
|
+
"""
|
|
171
|
+
result = permissions_query(query_func=query_func)
|
|
172
|
+
if not result.permissions:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
# Filter for PermissionSlackUsergroupV1 to make mypy happy
|
|
176
|
+
return [
|
|
177
|
+
p for p in result.permissions if isinstance(p, PermissionSlackUsergroupV1)
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def get_users(query_func: Callable) -> list[UserV1]:
|
|
182
|
+
"""Return all users from app-interface."""
|
|
183
|
+
return users_query(query_func=query_func).users or []
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def get_clusters(query_func: Callable) -> list[ClusterV1]:
|
|
187
|
+
"""Return all clusters from app-interface."""
|
|
188
|
+
return [
|
|
189
|
+
cluster
|
|
190
|
+
for cluster in clusters_query(query_func=query_func).clusters or []
|
|
191
|
+
if integration_is_enabled(QONTRACT_INTEGRATION, cluster)
|
|
192
|
+
and integration_is_enabled("slack-usergroups", cluster)
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def get_roles(query_func: Callable) -> list[ClusterAccessRole]:
|
|
197
|
+
"""Return all roles from app-interface."""
|
|
198
|
+
roles = roles_query(query_func=query_func).roles
|
|
199
|
+
return expiration.filter(roles)
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def compile_users_from_schedule(
|
|
203
|
+
schedule: ScheduleV1 | None,
|
|
204
|
+
) -> list[str]:
|
|
205
|
+
"""Return list of usernames from active schedule entries.
|
|
206
|
+
|
|
207
|
+
Only includes users from schedule entries that are currently active
|
|
208
|
+
(now is between start and end time).
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
schedule: List of schedule entries with start/end times and users
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of usernames from active schedules
|
|
215
|
+
"""
|
|
216
|
+
if not schedule:
|
|
217
|
+
return []
|
|
218
|
+
now = utc_now()
|
|
219
|
+
all_usernames: list[str] = []
|
|
220
|
+
for entry in schedule.schedule:
|
|
221
|
+
start = ensure_utc(datetime.strptime(entry.start, DATE_FORMAT)) # noqa: DTZ007
|
|
222
|
+
end = ensure_utc(datetime.strptime(entry.end, DATE_FORMAT)) # noqa: DTZ007
|
|
223
|
+
if start <= now <= end:
|
|
224
|
+
all_usernames.extend(u.org_username for u in entry.users)
|
|
225
|
+
return all_usernames
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def compile_users_from_roles(roles: list[RoleV1] | None) -> list[str]:
|
|
229
|
+
"""Extract usernames from roles.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
roles: List of role objects with users
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List of usernames from roles
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
return [user.org_username for role in roles or [] for user in role.users or []]
|
|
239
|
+
|
|
240
|
+
async def fetch_owners(
|
|
241
|
+
self,
|
|
242
|
+
url: str,
|
|
243
|
+
token: VaultSecret,
|
|
244
|
+
gh_to_org_username: Mapping[str, str],
|
|
245
|
+
users_map: Mapping[str, UserV1],
|
|
246
|
+
) -> list[str]:
|
|
247
|
+
"""Fetch and process OWNERS from a single repository URL."""
|
|
248
|
+
logging.debug(f"Fetching OWNERS from {url}")
|
|
249
|
+
ref = "master"
|
|
250
|
+
# allow passing repo_url:ref to select different branch
|
|
251
|
+
if url.count(":") == 2:
|
|
252
|
+
url, ref = url.rsplit(":", 1)
|
|
253
|
+
|
|
254
|
+
response = await get_repo_owners(
|
|
255
|
+
client=self.qontract_api_client,
|
|
256
|
+
repo_url=url,
|
|
257
|
+
ref=ref,
|
|
258
|
+
secret_manager_url=self.secret_manager_url,
|
|
259
|
+
path=token.path,
|
|
260
|
+
field=token.field,
|
|
261
|
+
version=token.version,
|
|
262
|
+
)
|
|
263
|
+
assert isinstance(response, RepoOwnersResponse)
|
|
264
|
+
|
|
265
|
+
# Process owners inline
|
|
266
|
+
result = []
|
|
267
|
+
owners = (response.approvers or []) + (response.reviewers or [])
|
|
268
|
+
for owner in owners:
|
|
269
|
+
org_username = (
|
|
270
|
+
gh_to_org_username.get(owner.lower())
|
|
271
|
+
if response.provider == VCSProvider.GITHUB
|
|
272
|
+
else owner
|
|
273
|
+
)
|
|
274
|
+
if org_username and org_username in users_map:
|
|
275
|
+
user = users_map[org_username]
|
|
276
|
+
if user.tag_on_merge_requests is not False:
|
|
277
|
+
result.append(user.org_username)
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
async def compile_users_from_git_owners(
|
|
281
|
+
self,
|
|
282
|
+
urls: Iterable[str] | None,
|
|
283
|
+
vcs_instances: Iterable[Vcs],
|
|
284
|
+
app_interface_users: list[UserV1],
|
|
285
|
+
) -> list[str]:
|
|
286
|
+
"""Extract usernames from git OWNERS files.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
urls: List of git repo URLs to fetch OWNERS from
|
|
290
|
+
app_interface_users: List of all App-Interface users
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
List of usernames from git OWNERS files
|
|
294
|
+
"""
|
|
295
|
+
if not urls:
|
|
296
|
+
return []
|
|
297
|
+
|
|
298
|
+
# map GitHub usernames to org_usernames
|
|
299
|
+
gh_to_org_username = {
|
|
300
|
+
user.github_username.lower(): user.org_username
|
|
301
|
+
for user in app_interface_users
|
|
302
|
+
}
|
|
303
|
+
users_map = {user.org_username: user for user in app_interface_users}
|
|
304
|
+
vcs_reg = get_default_registry()
|
|
305
|
+
|
|
306
|
+
tasks = [
|
|
307
|
+
self.fetch_owners(
|
|
308
|
+
url,
|
|
309
|
+
get_token_from_url(
|
|
310
|
+
vcs_reg=vcs_reg, vcs_instances=vcs_instances, url=url
|
|
311
|
+
),
|
|
312
|
+
gh_to_org_username,
|
|
313
|
+
users_map,
|
|
314
|
+
)
|
|
315
|
+
for url in urls
|
|
316
|
+
]
|
|
317
|
+
results = await asyncio.gather(*tasks)
|
|
318
|
+
|
|
319
|
+
# Extract usernames
|
|
320
|
+
return list({username for usernames in results for username in usernames})
|
|
321
|
+
|
|
322
|
+
async def compile_users_from_pagerduty_schedules(
|
|
323
|
+
self,
|
|
324
|
+
pagerduties: Iterable[PagerDutyTargetV1] | None,
|
|
325
|
+
) -> list[str]:
|
|
326
|
+
"""Extract usernames from PagerDuty schedules and escalation policies.
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
pagerduties: List of PagerDuty targets (schedules/escalation policies)
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
List of usernames from PagerDuty
|
|
333
|
+
"""
|
|
334
|
+
if not pagerduties:
|
|
335
|
+
return []
|
|
336
|
+
|
|
337
|
+
tasks: list[
|
|
338
|
+
Coroutine[Any, Any, EscalationPolicyUsersResponse | ScheduleUsersResponse]
|
|
339
|
+
] = []
|
|
340
|
+
for pagerduty in pagerduties:
|
|
341
|
+
if pagerduty.schedule_id:
|
|
342
|
+
tasks.append(
|
|
343
|
+
get_pagerduty_schedule_users(
|
|
344
|
+
client=self.qontract_api_client,
|
|
345
|
+
schedule_id=pagerduty.schedule_id,
|
|
346
|
+
secret_manager_url=self.secret_manager_url,
|
|
347
|
+
path=pagerduty.instance.token.path,
|
|
348
|
+
field=pagerduty.instance.token.field,
|
|
349
|
+
version=pagerduty.instance.token.version,
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
if pagerduty.escalation_policy_id:
|
|
353
|
+
tasks.append(
|
|
354
|
+
get_pagerduty_escalation_policy_users(
|
|
355
|
+
client=self.qontract_api_client,
|
|
356
|
+
policy_id=pagerduty.escalation_policy_id,
|
|
357
|
+
secret_manager_url=self.secret_manager_url,
|
|
358
|
+
path=pagerduty.instance.token.path,
|
|
359
|
+
field=pagerduty.instance.token.field,
|
|
360
|
+
version=pagerduty.instance.token.version,
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
responses = await asyncio.gather(*tasks)
|
|
365
|
+
|
|
366
|
+
# Extract usernames
|
|
367
|
+
return [user.username for resp in responses for user in resp.users or []]
|
|
368
|
+
|
|
369
|
+
async def _process_permission(
|
|
370
|
+
self,
|
|
371
|
+
permission: PermissionSlackUsergroupV1,
|
|
372
|
+
app_interface_users: list[UserV1],
|
|
373
|
+
vcs_instances: Iterable[Vcs],
|
|
374
|
+
desired_workspace_name: str | None,
|
|
375
|
+
desired_usergroup_name: str | None,
|
|
376
|
+
) -> tuple[str, SlackUsergroup] | None:
|
|
377
|
+
"""Process a single permission and return (workspace_name, usergroup)."""
|
|
378
|
+
workspace = permission.workspace
|
|
379
|
+
if permission.skip or not workspace.managed_usergroups:
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# Filter by workspace if specified
|
|
383
|
+
if desired_workspace_name and workspace.name != desired_workspace_name:
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
# Get usergroup handle
|
|
387
|
+
usergroup_handle = permission.handle
|
|
388
|
+
|
|
389
|
+
# Filter by usergroup if specified
|
|
390
|
+
if desired_usergroup_name and usergroup_handle != desired_usergroup_name:
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
# Validate usergroup is in managed_usergroups (SECURITY)
|
|
394
|
+
if usergroup_handle not in workspace.managed_usergroups:
|
|
395
|
+
raise KeyError(
|
|
396
|
+
f"[{workspace.name}] usergroup '{usergroup_handle}' not in 'managedUsergroups' of the Slack workspace '{workspace.path}'"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Add users from the permission roles
|
|
400
|
+
users = set(self.compile_users_from_roles(permission.roles))
|
|
401
|
+
# Add users from the permission schedule (time-based on-call rotations)
|
|
402
|
+
users.update(self.compile_users_from_schedule(permission.schedule))
|
|
403
|
+
# Add users from git repo owners file
|
|
404
|
+
users.update(
|
|
405
|
+
await self.compile_users_from_git_owners(
|
|
406
|
+
urls=permission.owners_from_repos,
|
|
407
|
+
app_interface_users=app_interface_users,
|
|
408
|
+
vcs_instances=vcs_instances,
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
# Add users from PagerDuty schedules
|
|
412
|
+
users.update(
|
|
413
|
+
await self.compile_users_from_pagerduty_schedules(
|
|
414
|
+
pagerduties=permission.pagerduty
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Create config and usergroup
|
|
419
|
+
config = SlackUsergroupConfig(
|
|
420
|
+
description=permission.description or "",
|
|
421
|
+
users=sorted(users),
|
|
422
|
+
channels=sorted(set(permission.channels or [])),
|
|
423
|
+
)
|
|
424
|
+
usergroup = SlackUsergroup(handle=usergroup_handle, config=config)
|
|
425
|
+
|
|
426
|
+
return (workspace.name, usergroup)
|
|
427
|
+
|
|
428
|
+
async def compile_desired_state_from_permissions(
|
|
429
|
+
self,
|
|
430
|
+
permissions: list[PermissionSlackUsergroupV1],
|
|
431
|
+
app_interface_users: list[UserV1],
|
|
432
|
+
vcs_instances: Iterable[Vcs],
|
|
433
|
+
desired_workspace_name: str | None = None,
|
|
434
|
+
desired_usergroup_name: str | None = None,
|
|
435
|
+
) -> list[SlackWorkspace]:
|
|
436
|
+
"""Compile the desired slack-usergroups from permissions."""
|
|
437
|
+
# Process all permissions in parallel
|
|
438
|
+
results = await asyncio.gather(*[
|
|
439
|
+
self._process_permission(
|
|
440
|
+
permission,
|
|
441
|
+
app_interface_users,
|
|
442
|
+
vcs_instances,
|
|
443
|
+
desired_workspace_name,
|
|
444
|
+
desired_usergroup_name,
|
|
445
|
+
)
|
|
446
|
+
for permission in permissions
|
|
447
|
+
])
|
|
448
|
+
|
|
449
|
+
# Build workspace -> usergroups mapping
|
|
450
|
+
workspaces_map: dict[str, list[SlackUsergroup]] = defaultdict(list)
|
|
451
|
+
for result in results:
|
|
452
|
+
if result is not None:
|
|
453
|
+
workspace_name, usergroup = result
|
|
454
|
+
workspaces_map[workspace_name].append(usergroup)
|
|
455
|
+
|
|
456
|
+
workspaces_by_name = {p.workspace.name: p.workspace for p in permissions}
|
|
457
|
+
|
|
458
|
+
# Build workspaces
|
|
459
|
+
workspaces = []
|
|
460
|
+
for workspace_name, usergroups in workspaces_map.items():
|
|
461
|
+
workspace = workspaces_by_name[workspace_name]
|
|
462
|
+
# Extract channel from workspace integrations
|
|
463
|
+
default_channel = None
|
|
464
|
+
token = None
|
|
465
|
+
if workspace.integrations:
|
|
466
|
+
for integration in workspace.integrations:
|
|
467
|
+
if integration.name in {"slack-usergroups", QONTRACT_INTEGRATION}:
|
|
468
|
+
default_channel = integration.channel
|
|
469
|
+
token = integration.token
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
if not default_channel or not token:
|
|
473
|
+
logging.error(
|
|
474
|
+
f"Workspace {workspace_name} has no slack-usergroups integration setting, skipping"
|
|
475
|
+
)
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
workspaces.append(
|
|
479
|
+
SlackWorkspace(
|
|
480
|
+
name=workspace_name,
|
|
481
|
+
usergroups=usergroups,
|
|
482
|
+
managed_usergroups=workspace.managed_usergroups
|
|
483
|
+
if not desired_usergroup_name
|
|
484
|
+
else [desired_usergroup_name],
|
|
485
|
+
default_channel=default_channel,
|
|
486
|
+
token=token,
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
return workspaces
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def compute_cluster_user_group(name: str) -> str:
|
|
494
|
+
"""Compute the cluster usergroup name."""
|
|
495
|
+
return f"{name}-cluster"
|
|
496
|
+
|
|
497
|
+
@staticmethod
|
|
498
|
+
def include_user_to_cluster_usergroup(
|
|
499
|
+
user: ClusterAccessUser, role: ClusterAccessRole
|
|
500
|
+
) -> bool:
|
|
501
|
+
"""Check the user should be notified (tag_on_cluster_updates)."""
|
|
502
|
+
if user.tag_on_cluster_updates is not None:
|
|
503
|
+
# if tag_on_cluster_updates is defined
|
|
504
|
+
return user.tag_on_cluster_updates
|
|
505
|
+
|
|
506
|
+
return role.tag_on_cluster_updates is not False
|
|
507
|
+
|
|
508
|
+
def compile_desired_state_cluster_usergroups(
|
|
509
|
+
self,
|
|
510
|
+
workspaces: list[SlackWorkspace],
|
|
511
|
+
clusters: Iterable[ClusterV1],
|
|
512
|
+
roles: Iterable[ClusterAccessRole],
|
|
513
|
+
desired_workspace_name: str | None = None,
|
|
514
|
+
desired_usergroup_name: str | None = None,
|
|
515
|
+
) -> list[SlackWorkspace]:
|
|
516
|
+
"""Compile the desired slack-usergroups for all clusters."""
|
|
517
|
+
cluster_users: dict[str, set[str]] = {}
|
|
518
|
+
|
|
519
|
+
# Collect users per cluster from cluster access
|
|
520
|
+
for role in roles:
|
|
521
|
+
for access in role.access or []:
|
|
522
|
+
if (
|
|
523
|
+
access.namespace
|
|
524
|
+
and bool(access.namespace.managed_roles)
|
|
525
|
+
and not bool(access.namespace.delete)
|
|
526
|
+
):
|
|
527
|
+
# namespace reference
|
|
528
|
+
cluster_name = access.namespace.cluster.name
|
|
529
|
+
|
|
530
|
+
elif access.cluster and access.group:
|
|
531
|
+
# cluster access either via group or cluster role
|
|
532
|
+
cluster_name = access.cluster.name
|
|
533
|
+
|
|
534
|
+
else:
|
|
535
|
+
# not a cluster/namespace access
|
|
536
|
+
continue
|
|
537
|
+
|
|
538
|
+
if (
|
|
539
|
+
desired_usergroup_name
|
|
540
|
+
and self.compute_cluster_user_group(cluster_name)
|
|
541
|
+
!= desired_usergroup_name
|
|
542
|
+
):
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
cluster_users.setdefault(cluster_name, set()).update([
|
|
546
|
+
user.org_username
|
|
547
|
+
for user in role.users
|
|
548
|
+
if self.include_user_to_cluster_usergroup(user, role)
|
|
549
|
+
])
|
|
550
|
+
|
|
551
|
+
# Create usergroups for each cluster based on collected users
|
|
552
|
+
for cluster in clusters:
|
|
553
|
+
usergroup_handle = self.compute_cluster_user_group(cluster.name)
|
|
554
|
+
# Filter by usergroup if specified
|
|
555
|
+
if desired_usergroup_name and usergroup_handle != desired_usergroup_name:
|
|
556
|
+
continue
|
|
557
|
+
|
|
558
|
+
if not (users := set(cluster_users.get(cluster.name, []))):
|
|
559
|
+
# no users for this cluster usergroup
|
|
560
|
+
continue
|
|
561
|
+
|
|
562
|
+
# Create config and usergroup
|
|
563
|
+
config = SlackUsergroupConfig(
|
|
564
|
+
description=f"Users with access to the {cluster.name} cluster",
|
|
565
|
+
users=sorted(users),
|
|
566
|
+
channels=[],
|
|
567
|
+
)
|
|
568
|
+
slack_usergroup = SlackUsergroup(handle=usergroup_handle, config=config)
|
|
569
|
+
|
|
570
|
+
for workspace in workspaces:
|
|
571
|
+
# Filter by workspace if specified
|
|
572
|
+
if desired_workspace_name and workspace.name != desired_workspace_name:
|
|
573
|
+
continue
|
|
574
|
+
assert isinstance(slack_usergroup.config.channels, list) # for mypy
|
|
575
|
+
slack_usergroup.config.channels.append(workspace.default_channel)
|
|
576
|
+
workspace.usergroups.append(slack_usergroup)
|
|
577
|
+
workspace.managed_usergroups = sorted(
|
|
578
|
+
set(workspace.managed_usergroups + [slack_usergroup.handle])
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
return workspaces
|
|
582
|
+
|
|
583
|
+
async def reconcile(
|
|
584
|
+
self,
|
|
585
|
+
workspaces: list[SlackWorkspace],
|
|
586
|
+
dry_run: bool = True,
|
|
587
|
+
) -> SlackUsergroupsTaskResponse:
|
|
588
|
+
"""Call qontract-api to reconcile Slack usergroups.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
workspaces: List of Slack workspaces with usergroups
|
|
592
|
+
dry_run: If True, only calculate actions without executing
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Response from qontract-api
|
|
596
|
+
"""
|
|
597
|
+
request_data = SlackUsergroupsReconcileRequest(
|
|
598
|
+
workspaces=[
|
|
599
|
+
SlackWorkspaceRequest(
|
|
600
|
+
name=workspace.name,
|
|
601
|
+
usergroups=workspace.usergroups,
|
|
602
|
+
managed_usergroups=workspace.managed_usergroups,
|
|
603
|
+
token=Secret(
|
|
604
|
+
secret_manager_url=self.secret_manager_url,
|
|
605
|
+
path=workspace.token.path,
|
|
606
|
+
field=workspace.token.field,
|
|
607
|
+
version=workspace.token.version,
|
|
608
|
+
),
|
|
609
|
+
)
|
|
610
|
+
for workspace in workspaces
|
|
611
|
+
],
|
|
612
|
+
dry_run=dry_run,
|
|
613
|
+
)
|
|
614
|
+
response = await reconcile_slack_usergroups(
|
|
615
|
+
client=self.qontract_api_client, body=request_data
|
|
616
|
+
)
|
|
617
|
+
return response
|
|
618
|
+
|
|
619
|
+
async def async_run(self, dry_run: bool) -> None:
|
|
620
|
+
"""Run the integration"""
|
|
621
|
+
# TODO async gql client?
|
|
622
|
+
gqlapi = gql.get_api()
|
|
623
|
+
permissions = self.get_permissions(query_func=gqlapi.query)
|
|
624
|
+
users = self.get_users(query_func=gqlapi.query)
|
|
625
|
+
clusters = self.get_clusters(query_func=gqlapi.query)
|
|
626
|
+
roles = self.get_roles(query_func=gqlapi.query)
|
|
627
|
+
vcs_instances = get_vcs_instances(query_func=gqlapi.query)
|
|
628
|
+
|
|
629
|
+
workspaces = await self.compile_desired_state_from_permissions(
|
|
630
|
+
permissions=permissions,
|
|
631
|
+
app_interface_users=users,
|
|
632
|
+
vcs_instances=vcs_instances,
|
|
633
|
+
desired_workspace_name=self.params.workspace_name,
|
|
634
|
+
desired_usergroup_name=self.params.usergroup_name,
|
|
635
|
+
)
|
|
636
|
+
workspaces = self.compile_desired_state_cluster_usergroups(
|
|
637
|
+
workspaces=workspaces,
|
|
638
|
+
clusters=clusters,
|
|
639
|
+
roles=roles,
|
|
640
|
+
desired_workspace_name=self.params.workspace_name,
|
|
641
|
+
desired_usergroup_name=self.params.usergroup_name,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
if not workspaces:
|
|
645
|
+
logging.warning("No desired state found, nothing to reconcile")
|
|
646
|
+
return
|
|
647
|
+
|
|
648
|
+
task = await self.reconcile(workspaces=workspaces, dry_run=dry_run)
|
|
649
|
+
if dry_run:
|
|
650
|
+
# wait for task completion and get the action list
|
|
651
|
+
task_result = await slack_usergroups_task_status(
|
|
652
|
+
client=self.qontract_api_client, task_id=task.id, timeout=300
|
|
653
|
+
)
|
|
654
|
+
if task_result.status == TaskStatus.PENDING:
|
|
655
|
+
logging.error("Task did not complete within the timeout period")
|
|
656
|
+
sys.exit(1)
|
|
657
|
+
|
|
658
|
+
if task_result.actions:
|
|
659
|
+
logging.info("Proposed actions:")
|
|
660
|
+
for action in task_result.actions or []:
|
|
661
|
+
if isinstance(action, SlackUsergroupActionUpdateUsers):
|
|
662
|
+
logging.info(
|
|
663
|
+
f"{action.usergroup=} {action.users_to_add=} {action.users_to_remove=}"
|
|
664
|
+
)
|
|
665
|
+
else:
|
|
666
|
+
logging.info(action)
|
|
667
|
+
|
|
668
|
+
if task_result.errors:
|
|
669
|
+
logging.error(f"Errors encountered: {len(task_result.errors)}")
|
|
670
|
+
for error in task_result.errors:
|
|
671
|
+
logging.error(f" - {error}")
|
|
672
|
+
sys.exit(1)
|