qontract-reconcile 0.10.2.dev485__py3-none-any.whl → 0.10.2.dev494__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.
@@ -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)