qontract-reconcile 0.10.1rc349__py3-none-any.whl → 0.10.1rc351__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {qontract_reconcile-0.10.1rc349.dist-info → qontract_reconcile-0.10.1rc351.dist-info}/METADATA +1 -1
  2. {qontract_reconcile-0.10.1rc349.dist-info → qontract_reconcile-0.10.1rc351.dist-info}/RECORD +22 -18
  3. reconcile/aus/advanced_upgrade_service.py +50 -13
  4. reconcile/aus/aus_label_source.py +115 -0
  5. reconcile/cli.py +4 -4
  6. reconcile/gql_definitions/advanced_upgrade_service/aus_clusters.py +11 -1
  7. reconcile/gql_definitions/advanced_upgrade_service/aus_organization.py +9 -1
  8. reconcile/gql_definitions/fragments/aus_organization.py +9 -11
  9. reconcile/gql_definitions/fragments/minimal_ocm_organization.py +29 -0
  10. reconcile/gql_definitions/{ocm_subscription_labels → ocm_labels}/clusters.py +8 -0
  11. reconcile/gql_definitions/ocm_labels/organizations.py +72 -0
  12. reconcile/ocm_labels/integration.py +406 -0
  13. reconcile/ocm_labels/label_sources.py +76 -0
  14. reconcile/terraform_resources.py +2 -0
  15. reconcile/utils/external_resource_spec.py +20 -0
  16. reconcile/utils/external_resources.py +33 -2
  17. reconcile/utils/ocm/labels.py +61 -1
  18. reconcile/ocm_subscription_labels/integration.py +0 -250
  19. {qontract_reconcile-0.10.1rc349.dist-info → qontract_reconcile-0.10.1rc351.dist-info}/WHEEL +0 -0
  20. {qontract_reconcile-0.10.1rc349.dist-info → qontract_reconcile-0.10.1rc351.dist-info}/entry_points.txt +0 -0
  21. {qontract_reconcile-0.10.1rc349.dist-info → qontract_reconcile-0.10.1rc351.dist-info}/top_level.txt +0 -0
  22. /reconcile/gql_definitions/{ocm_subscription_labels → ocm_labels}/__init__.py +0 -0
  23. /reconcile/{ocm_subscription_labels → ocm_labels}/__init__.py +0 -0
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import (
5
+ Callable,
6
+ Iterable,
7
+ )
8
+ from typing import (
9
+ Any,
10
+ Optional,
11
+ )
12
+
13
+ from deepdiff import DeepHash
14
+ from pydantic import validator
15
+
16
+ from reconcile.aus.aus_label_source import (
17
+ init_aus_cluster_label_source,
18
+ init_aus_org_label_source,
19
+ )
20
+ from reconcile.gql_definitions.common.ocm_environments import (
21
+ query as ocm_environment_query,
22
+ )
23
+ from reconcile.gql_definitions.fragments.ocm_environment import OCMEnvironment
24
+ from reconcile.gql_definitions.ocm_labels.clusters import ClusterV1
25
+ from reconcile.gql_definitions.ocm_labels.clusters import query as cluster_query
26
+ from reconcile.gql_definitions.ocm_labels.organizations import OpenShiftClusterManagerV1
27
+ from reconcile.gql_definitions.ocm_labels.organizations import (
28
+ query as organization_query,
29
+ )
30
+ from reconcile.ocm_labels.label_sources import (
31
+ ClusterRef,
32
+ LabelSource,
33
+ LabelState,
34
+ OrgRef,
35
+ )
36
+ from reconcile.utils import gql
37
+ from reconcile.utils.differ import diff_mappings
38
+ from reconcile.utils.disabled_integrations import integration_is_enabled
39
+ from reconcile.utils.helpers import flatten
40
+ from reconcile.utils.ocm.clusters import discover_clusters_for_organizations
41
+ from reconcile.utils.ocm.labels import (
42
+ add_label,
43
+ build_organization_labels_href,
44
+ delete_label,
45
+ get_org_labels,
46
+ update_label,
47
+ )
48
+ from reconcile.utils.ocm.search_filters import Filter
49
+ from reconcile.utils.ocm_base_client import (
50
+ OCMAPIClientConfigurationProtocol,
51
+ OCMBaseClient,
52
+ init_ocm_base_client,
53
+ )
54
+ from reconcile.utils.runtime.integration import (
55
+ PydanticRunParams,
56
+ QontractReconcileIntegration,
57
+ )
58
+ from reconcile.utils.secret_reader import SecretReaderBase
59
+
60
+ QONTRACT_INTEGRATION = "ocm-labels"
61
+
62
+
63
+ class OcmLabelsIntegrationParams(PydanticRunParams):
64
+ managed_label_prefixes: list[str] = []
65
+
66
+ @validator("managed_label_prefixes")
67
+ def must_end_with_dot( # pylint: disable=no-self-argument
68
+ cls, v: list[str]
69
+ ) -> list[str]:
70
+ return [prefix + "." if not prefix.endswith(".") else prefix for prefix in v]
71
+
72
+
73
+ class ManagedLabelConflictError(Exception):
74
+ pass
75
+
76
+
77
+ class OcmLabelsIntegration(QontractReconcileIntegration[OcmLabelsIntegrationParams]):
78
+ """Sync labels to subscription and organizations."""
79
+
80
+ @property
81
+ def name(self) -> str:
82
+ return QONTRACT_INTEGRATION
83
+
84
+ def get_clusters(self, query_func: Callable) -> list[ClusterV1]:
85
+ data = cluster_query(query_func)
86
+ return [
87
+ c
88
+ for c in data.clusters or []
89
+ if c.ocm is not None and integration_is_enabled(self.name, c)
90
+ ]
91
+
92
+ def get_organizations(
93
+ self, query_func: Callable
94
+ ) -> list[OpenShiftClusterManagerV1]:
95
+ return organization_query(query_func).organizations or []
96
+
97
+ def get_environments(self, query_func: Callable) -> list[OCMEnvironment]:
98
+ return ocm_environment_query(query_func).environments
99
+
100
+ def init_ocm_apis(
101
+ self,
102
+ environments: Iterable[OCMEnvironment],
103
+ init_ocm_base_client: Callable[
104
+ [OCMAPIClientConfigurationProtocol, SecretReaderBase], OCMBaseClient
105
+ ] = init_ocm_base_client,
106
+ ) -> dict[str, OCMBaseClient]:
107
+ return {
108
+ env.name: init_ocm_base_client(env, self.secret_reader)
109
+ for env in environments
110
+ }
111
+
112
+ def org_label_sources(self, query_func: Callable) -> list[LabelSource]:
113
+ return [
114
+ init_aus_org_label_source(query_func),
115
+ ]
116
+
117
+ def subscription_label_sources(
118
+ self, clusters: list[ClusterV1], query_func: Callable
119
+ ) -> list[LabelSource]:
120
+ return [
121
+ init_cluster_subscription_label_source(clusters),
122
+ init_aus_cluster_label_source(query_func),
123
+ ]
124
+
125
+ def get_early_exit_desired_state(self) -> Optional[dict[str, Any]]:
126
+ gqlapi = gql.get_api()
127
+ desired = {
128
+ "org_labels": self.fetch_desired_state(
129
+ self.org_label_sources(gqlapi.query)
130
+ ),
131
+ "subs_labels": self.fetch_desired_state(
132
+ self.subscription_label_sources(
133
+ self.get_clusters(gqlapi.query), gqlapi.query
134
+ )
135
+ ),
136
+ }
137
+ # to figure out wheter to run a PR check of to exit early, a hash value
138
+ # of the desired state is sufficient
139
+ return {"hash": DeepHash(desired).get(desired)}
140
+
141
+ def run(self, dry_run: bool) -> None:
142
+ gqlapi = gql.get_api()
143
+ self.get_early_exit_desired_state()
144
+ clusters = self.get_clusters(gqlapi.query)
145
+ organizations = self.get_organizations(gqlapi.query)
146
+ environments = self.get_environments(gqlapi.query)
147
+
148
+ self.ocm_apis = self.init_ocm_apis(environments, init_ocm_base_client)
149
+
150
+ # organization labels
151
+ orgs_current_state, orgs_desired_state = self.fetch_organization_label_states(
152
+ organizations, gqlapi.query
153
+ )
154
+ self.reconcile(
155
+ dry_run=dry_run,
156
+ scope="organization",
157
+ current_state=orgs_current_state,
158
+ desired_state=orgs_desired_state,
159
+ )
160
+
161
+ # subscription labels
162
+ subs_current_state, subs_desired_state = self.fetch_subscription_label_states(
163
+ clusters, gqlapi.query
164
+ )
165
+ self.reconcile(
166
+ dry_run=dry_run,
167
+ scope="cluster",
168
+ current_state=subs_current_state,
169
+ desired_state=subs_desired_state,
170
+ )
171
+
172
+ def fetch_organization_label_states(
173
+ self, organizations: Iterable[OpenShiftClusterManagerV1], query_func: Callable
174
+ ) -> tuple[LabelState, LabelState]:
175
+ """
176
+ Returns the current and desired state of the organizations labels for
177
+ the given organizations.
178
+
179
+ Please note that the current state might not contain all requested organizations,
180
+ e.g. if a organization can't be found in OCM.
181
+ """
182
+ current_state = self.fetch_organization_label_current_state(
183
+ organizations, self.params.managed_label_prefixes
184
+ )
185
+ desired_state = self.fetch_desired_state(self.org_label_sources(query_func))
186
+ return current_state, desired_state
187
+
188
+ def fetch_organization_label_current_state(
189
+ self,
190
+ organizations: Iterable[OpenShiftClusterManagerV1],
191
+ managed_label_prefixes: list[str],
192
+ ) -> LabelState:
193
+ """
194
+ Fetches the current state of organizations labels for the given organizations.
195
+ If an organization can't be found in OCM, the resulting dict will not contain a
196
+ state for it, not even an empty one.
197
+
198
+ Only labels with a prefix in managed_label_prefixes are returned. Not every label
199
+ on an organizations is this integrations business.
200
+ """
201
+ states: LabelState = {
202
+ OrgRef(
203
+ org_id=org.org_id,
204
+ ocm_env=org.environment.name,
205
+ label_container_href=build_organization_labels_href(org.org_id),
206
+ name=org.name,
207
+ ): {}
208
+ for org in organizations
209
+ }
210
+
211
+ # prepare search filters
212
+ managed_label_filter = Filter()
213
+ for prefix in managed_label_prefixes:
214
+ managed_label_filter |= Filter().like("key", f"{prefix}%")
215
+
216
+ for env_name, ocm_api in self.ocm_apis.items():
217
+ env_orgs = {
218
+ o.org_id: o for o in organizations if o.environment.name == env_name
219
+ }
220
+ if not env_orgs:
221
+ continue
222
+ labels_by_org_id = get_org_labels(
223
+ ocm_api=ocm_api,
224
+ org_ids=set(env_orgs.keys()),
225
+ label_filter=managed_label_filter,
226
+ )
227
+ for org_id, labels in labels_by_org_id.items():
228
+ states[
229
+ OrgRef(
230
+ org_id=org_id,
231
+ ocm_env=env_name,
232
+ label_container_href=build_organization_labels_href(org_id),
233
+ name=env_orgs[org_id].name,
234
+ )
235
+ ] = labels.get_values_dict()
236
+
237
+ return states
238
+
239
+ def fetch_subscription_label_states(
240
+ self, clusters: list[ClusterV1], query_func: Callable
241
+ ) -> tuple[LabelState, LabelState]:
242
+ """
243
+ Returns the current and desired state of the subscription labels for
244
+ the given clusters.
245
+
246
+ Please note that the current state might not contain all requested clusters,
247
+ e.g. if a cluster can't be found in OCM or is not considered ready yet.
248
+ """
249
+ current_state = self.fetch_subscription_label_current_state(
250
+ clusters, self.params.managed_label_prefixes
251
+ )
252
+ desired_state = self.fetch_desired_state(
253
+ self.subscription_label_sources(clusters, query_func)
254
+ )
255
+ return current_state, desired_state
256
+
257
+ def fetch_subscription_label_current_state(
258
+ self, clusters: Iterable[ClusterV1], managed_label_prefixes: list[str]
259
+ ) -> LabelState:
260
+ """
261
+ Fetches the current state of subscription labels for the given clusters.
262
+ If a cluster can't be found in OCM, the resulting dict will not contain a
263
+ state for it, not even an empty one.
264
+
265
+ Only labels with a prefix in managed_label_prefixes are returned. Not every label
266
+ on a subscription is this integrations business.
267
+ """
268
+ cluster_ids = {c.spec.q_id for c in clusters if c.spec and c.spec.q_id}
269
+ states: LabelState = {}
270
+ for env_name, ocm_api in self.ocm_apis.items():
271
+ for cluster_details in discover_clusters_for_organizations(
272
+ ocm_api=ocm_api,
273
+ organization_ids=list(
274
+ {
275
+ c.ocm.org_id
276
+ for c in clusters
277
+ if c.ocm and c.ocm.environment.name == env_name
278
+ }
279
+ ),
280
+ ):
281
+ if cluster_details.ocm_cluster.id not in cluster_ids:
282
+ # there might be more clusters in an organization than we care about
283
+ continue
284
+
285
+ filtered_labels = {
286
+ label: value
287
+ for label, value in cluster_details.subscription_labels.get_values_dict().items()
288
+ if label.startswith(tuple(managed_label_prefixes))
289
+ }
290
+ states[
291
+ ClusterRef(
292
+ cluster_id=cluster_details.ocm_cluster.id,
293
+ org_id=cluster_details.organization_id,
294
+ ocm_env=env_name,
295
+ name=cluster_details.ocm_cluster.name,
296
+ label_container_href=f"{cluster_details.ocm_cluster.subscription.href}/labels",
297
+ )
298
+ ] = filtered_labels
299
+ return states
300
+
301
+ def fetch_desired_state(self, sources: list[LabelSource]) -> LabelState:
302
+ states: LabelState = {}
303
+ for s in sources:
304
+ for owner_ref, labels in s.get_labels().items():
305
+ if owner_ref not in states:
306
+ states[owner_ref] = {}
307
+ for label, value in labels.items():
308
+ if label in states[owner_ref]:
309
+ raise ManagedLabelConflictError(
310
+ f"The label {label} on {owner_ref.identity_labels()} is already managed by another label source"
311
+ )
312
+ states[owner_ref][label] = value
313
+
314
+ return states
315
+
316
+ def reconcile(
317
+ self,
318
+ dry_run: bool,
319
+ scope: str,
320
+ current_state: LabelState,
321
+ desired_state: LabelState,
322
+ ) -> None:
323
+ # we iterate via the current state because it refers to the clusters we can act on
324
+ for label_owner_ref, current_labels in current_state.items():
325
+ ocm_api = self.ocm_apis[label_owner_ref.ocm_env]
326
+ desired_labels = desired_state.get(label_owner_ref, {})
327
+ if current_labels == desired_labels:
328
+ continue
329
+
330
+ diff_result = diff_mappings(current_labels, desired_labels)
331
+
332
+ for label_to_add, value in diff_result.add.items():
333
+ logging.info(
334
+ [
335
+ f"create_{scope}_label",
336
+ *label_owner_ref.identity_labels(),
337
+ f"{label_to_add}={value}",
338
+ ]
339
+ )
340
+ if not dry_run:
341
+ add_label(
342
+ ocm_api=ocm_api,
343
+ label_container_href=label_owner_ref.required_label_container_href(),
344
+ label=label_to_add,
345
+ value=value,
346
+ )
347
+ for label_to_rm, value in diff_result.delete.items():
348
+ logging.info(
349
+ [
350
+ f"delete_{scope}_label",
351
+ *label_owner_ref.identity_labels(),
352
+ f"{label_to_rm}={value}",
353
+ ]
354
+ )
355
+ if not dry_run:
356
+ delete_label(
357
+ ocm_api=ocm_api,
358
+ label_container_href=label_owner_ref.required_label_container_href(),
359
+ label=label_to_rm,
360
+ )
361
+ for label_to_update, diff_pair in diff_result.change.items():
362
+ value = diff_pair.desired
363
+ logging.info(
364
+ [
365
+ f"update_{scope}_label",
366
+ *label_owner_ref.identity_labels(),
367
+ f"{label_to_update}={value}",
368
+ ]
369
+ )
370
+ if not dry_run:
371
+ update_label(
372
+ ocm_api=ocm_api,
373
+ label_container_href=label_owner_ref.required_label_container_href(),
374
+ label=label_to_update,
375
+ value=value,
376
+ )
377
+
378
+
379
+ def init_cluster_subscription_label_source(
380
+ clusters: list[ClusterV1],
381
+ ) -> ClusterSubscriptionLabelSource:
382
+ return ClusterSubscriptionLabelSource(
383
+ clusters=[
384
+ c
385
+ for c in clusters or []
386
+ if c.ocm is not None and integration_is_enabled(QONTRACT_INTEGRATION, c)
387
+ ],
388
+ )
389
+
390
+
391
+ class ClusterSubscriptionLabelSource(LabelSource):
392
+ def __init__(self, clusters: Iterable[ClusterV1]) -> None:
393
+ self.clusters = clusters
394
+
395
+ def get_labels(self) -> LabelState:
396
+ return {
397
+ ClusterRef(
398
+ cluster_id=cluster.spec.q_id,
399
+ org_id=cluster.ocm.org_id,
400
+ ocm_env=cluster.ocm.environment.name,
401
+ name=cluster.name,
402
+ label_container_href=None,
403
+ ): flatten(cluster.ocm_subscription_labels or {})
404
+ for cluster in self.clusters
405
+ if cluster.ocm and cluster.spec and cluster.spec.q_id
406
+ }
@@ -0,0 +1,76 @@
1
+ from abc import (
2
+ ABC,
3
+ abstractmethod,
4
+ )
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class LabelOwnerRef(ABC):
11
+ ocm_env: str
12
+ label_container_href: Optional[str]
13
+
14
+ @abstractmethod
15
+ def identity_labels(self) -> list[str]:
16
+ pass
17
+
18
+ def required_label_container_href(self) -> str:
19
+ if self.label_container_href is None:
20
+ raise ValueError(
21
+ "label_container_href is missing - this method should probably not be called in this state"
22
+ )
23
+ return self.label_container_href
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class OrgRef(LabelOwnerRef):
28
+ org_id: str
29
+ name: str
30
+
31
+ def __eq__(self, other: object) -> bool:
32
+ return (
33
+ isinstance(other, OrgRef)
34
+ and self.org_id == other.org_id
35
+ and self.ocm_env == other.ocm_env
36
+ )
37
+
38
+ def __hash__(self) -> int:
39
+ return hash((self.org_id, self.ocm_env))
40
+
41
+ def identity_labels(self) -> list[str]:
42
+ return [f"org_id={self.org_id}", f"org_name={self.name}"]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ClusterRef(LabelOwnerRef):
47
+ cluster_id: str
48
+ org_id: str
49
+ name: str
50
+
51
+ def __eq__(self, other: object) -> bool:
52
+ return (
53
+ isinstance(other, ClusterRef)
54
+ and self.cluster_id == other.cluster_id
55
+ and self.org_id == other.org_id
56
+ and self.ocm_env == other.ocm_env
57
+ )
58
+
59
+ def __hash__(self) -> int:
60
+ return hash((self.cluster_id, self.org_id, self.ocm_env))
61
+
62
+ def identity_labels(self) -> list[str]:
63
+ return [
64
+ f"org_id={self.org_id}",
65
+ f"cluster_id={self.cluster_id}",
66
+ f"cluster_name={self.name}",
67
+ ]
68
+
69
+
70
+ LabelState = dict[LabelOwnerRef, dict[str, str]]
71
+
72
+
73
+ class LabelSource(ABC):
74
+ @abstractmethod
75
+ def get_labels(self) -> LabelState:
76
+ pass
@@ -41,6 +41,7 @@ from reconcile.utils.external_resources import (
41
41
  PROVIDER_AWS,
42
42
  get_external_resource_specs,
43
43
  managed_external_resources,
44
+ publish_metrics,
44
45
  )
45
46
  from reconcile.utils.oc import StatusCodeError
46
47
  from reconcile.utils.oc_map import (
@@ -399,6 +400,7 @@ def run(
399
400
  account_names,
400
401
  exclude_accounts,
401
402
  )
403
+ publish_metrics(resource_specs, QONTRACT_INTEGRATION.replace("_", "-"))
402
404
 
403
405
  if not dry_run and oc_map and defer:
404
406
  defer(oc_map.cleanup)
@@ -12,9 +12,11 @@ from typing import (
12
12
  )
13
13
 
14
14
  import yaml
15
+ from pydantic import BaseModel
15
16
  from pydantic.dataclasses import dataclass
16
17
 
17
18
  from reconcile import openshift_resources_base
19
+ from reconcile.utils.metrics import GaugeMetric
18
20
  from reconcile.utils.openshift_resource import (
19
21
  SECRET_MAX_KEY_LENGTH,
20
22
  OpenshiftResource,
@@ -188,6 +190,24 @@ class ExternalResourceUniqueKey:
188
190
  )
189
191
 
190
192
 
193
+ class ExternalResourceBaseMetric(BaseModel):
194
+ "Base class External Resource metrics"
195
+
196
+ integration: str
197
+
198
+
199
+ class ExternalResourceInventoryGauge(ExternalResourceBaseMetric, GaugeMetric):
200
+ "Inventory Gauge"
201
+
202
+ provision_provider: str
203
+ provisioner_name: str
204
+ provider: str
205
+
206
+ @classmethod
207
+ def name(cls) -> str:
208
+ return "external_resource_inventory"
209
+
210
+
191
211
  ExternalResourceSpecInventory = MutableMapping[
192
212
  ExternalResourceUniqueKey, ExternalResourceSpec
193
213
  ]
@@ -1,4 +1,5 @@
1
1
  import json
2
+ from collections import Counter
2
3
  from collections.abc import (
3
4
  Mapping,
4
5
  MutableMapping,
@@ -10,9 +11,16 @@ from typing import (
10
11
 
11
12
  import anymarkup
12
13
 
13
- from reconcile.utils import gql
14
+ from reconcile.utils import (
15
+ gql,
16
+ metrics,
17
+ )
14
18
  from reconcile.utils.exceptions import FetchResourceError
15
- from reconcile.utils.external_resource_spec import ExternalResourceSpec
19
+ from reconcile.utils.external_resource_spec import (
20
+ ExternalResourceInventoryGauge,
21
+ ExternalResourceSpec,
22
+ ExternalResourceSpecInventory,
23
+ )
16
24
 
17
25
  PROVIDER_AWS = "aws"
18
26
  PROVIDER_CLOUDFLARE = "cloudflare"
@@ -61,6 +69,29 @@ def managed_external_resources(namespace_info: Mapping[str, Any]) -> bool:
61
69
  return False
62
70
 
63
71
 
72
+ def get_inventory_count_combinations(
73
+ inventory: ExternalResourceSpecInventory,
74
+ ) -> Counter[tuple]:
75
+ return Counter(
76
+ (k.provision_provider, k.provisioner_name, k.provider) for k in inventory
77
+ )
78
+
79
+
80
+ def publish_metrics(inventory: ExternalResourceSpecInventory, integration: str) -> None:
81
+ count_combinations = get_inventory_count_combinations(inventory)
82
+ for combination, count in count_combinations.items():
83
+ provision_provider, provisioner_name, provider = combination
84
+ metrics.set_gauge(
85
+ ExternalResourceInventoryGauge(
86
+ integration=integration,
87
+ provision_provider=provision_provider,
88
+ provisioner_name=provisioner_name,
89
+ provider=provider,
90
+ ),
91
+ count,
92
+ )
93
+
94
+
64
95
  class ResourceValueResolver:
65
96
  """
66
97
  ExternalResourceSpec have data that's contained in different fields including the
@@ -1,3 +1,4 @@
1
+ from collections import defaultdict
1
2
  from collections.abc import Generator
2
3
  from typing import (
3
4
  Any,
@@ -11,6 +12,7 @@ from reconcile.utils.ocm.base import (
11
12
  OCMLabel,
12
13
  OCMOrganizationLabel,
13
14
  OCMSubscriptionLabel,
15
+ build_label_container,
14
16
  )
15
17
  from reconcile.utils.ocm.search_filters import Filter
16
18
  from reconcile.utils.ocm_base_client import OCMBaseClient
@@ -34,10 +36,25 @@ def add_subscription_label(
34
36
  ocm_cluster: OCMCluster,
35
37
  label: str,
36
38
  value: str,
39
+ ) -> None:
40
+ """Add the given label to the cluster subscription."""
41
+ add_label(
42
+ ocm_api=ocm_api,
43
+ label_container_href=f"{ocm_cluster.subscription.href}/labels",
44
+ label=label,
45
+ value=value,
46
+ )
47
+
48
+
49
+ def add_label(
50
+ ocm_api: OCMBaseClient,
51
+ label_container_href: str,
52
+ label: str,
53
+ value: str,
37
54
  ) -> None:
38
55
  """Add the given label to the cluster subscription."""
39
56
  ocm_api.post(
40
- api_path=f"{ocm_cluster.subscription.href}/labels",
57
+ api_path=label_container_href,
41
58
  data={"kind": "Label", "key": label, "value": value},
42
59
  )
43
60
 
@@ -54,11 +71,29 @@ def update_ocm_label(
54
71
  )
55
72
 
56
73
 
74
+ def update_label(
75
+ ocm_api: OCMBaseClient,
76
+ label_container_href: str,
77
+ label: str,
78
+ value: str,
79
+ ) -> None:
80
+ """Update the label value in the given OCM label."""
81
+ ocm_api.patch(
82
+ api_path=f"{label_container_href}/{label}",
83
+ data={"kind": "Label", "key": label, "value": value},
84
+ )
85
+
86
+
57
87
  def delete_ocm_label(ocm_api: OCMBaseClient, ocm_label: OCMLabel) -> None:
58
88
  """Delete the given OCM label."""
59
89
  ocm_api.delete(api_path=ocm_label.href)
60
90
 
61
91
 
92
+ def delete_label(ocm_api: OCMBaseClient, label_container_href: str, label: str) -> None:
93
+ """Delete the given OCM label."""
94
+ ocm_api.delete(api_path=f"{label_container_href}/{label}")
95
+
96
+
62
97
  def subscription_label_filter() -> Filter:
63
98
  """
64
99
  Returns a filter that can be used to find only subscription labels.
@@ -145,3 +180,28 @@ def label_filter(key: str, value: Optional[str] = None) -> Filter:
145
180
  if value:
146
181
  return lf.eq("value", value)
147
182
  return lf
183
+
184
+
185
+ def get_org_labels(
186
+ ocm_api: OCMBaseClient, org_ids: set[str], label_filter: Optional[Filter]
187
+ ) -> dict[str, LabelContainer]:
188
+ """
189
+ Fetch all labels from organizations. Optionally, label filtering can be
190
+ performed via the `label_filter` parameter.
191
+
192
+ The result is a dict with organization IDs as keys and label containers as values.
193
+ """
194
+ filter = Filter().is_in("organization_id", org_ids)
195
+ if label_filter:
196
+ filter &= label_filter
197
+ labels_by_org: dict[str, list[OCMOrganizationLabel]] = defaultdict(list)
198
+ for label in get_organization_labels(ocm_api, filter):
199
+ labels_by_org[label.organization_id].append(label)
200
+ return {
201
+ org_id: build_label_container(labels)
202
+ for org_id, labels in labels_by_org.items()
203
+ }
204
+
205
+
206
+ def build_organization_labels_href(org_id: str) -> str:
207
+ return f"/api/accounts_mgmt/v1/organizations/{org_id}/labels"