Integrate MON and Prometheus
[osm/devops.git] / installers / charm / osm-mon / lib / charms / observability_libs / v0 / juju_topology.py
1 # Copyright 2023 Canonical Ltd.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 """## Overview.
16
17 This document explains how to use the `JujuTopology` class to
18 create and consume topology information from Juju in a consistent manner.
19
20 The goal of the Juju topology is to uniquely identify a piece
21 of software running across any of your Juju-managed deployments.
22 This is achieved by combining the following four elements:
23
24 - Model name
25 - Model UUID
26 - Application name
27 - Unit identifier
28
29
30 For a more in-depth description of the concept, as well as a
31 walk-through of it's use-case in observability, see
32 [this blog post](https://juju.is/blog/model-driven-observability-part-2-juju-topology-metrics)
33 on the Juju blog.
34
35 ## Library Usage
36
37 This library may be used to create and consume `JujuTopology` objects.
38 The `JujuTopology` class provides three ways to create instances:
39
40 ### Using the `from_charm` method
41
42 Enables instantiation by supplying the charm as an argument. When
43 creating topology objects for the current charm, this is the recommended
44 approach.
45
46 ```python
47 topology = JujuTopology.from_charm(self)
48 ```
49
50 ### Using the `from_dict` method
51
52 Allows for instantion using a dictionary of relation data, like the
53 `scrape_metadata` from Prometheus or the labels of an alert rule. When
54 creating topology objects for remote charms, this is the recommended
55 approach.
56
57 ```python
58 scrape_metadata = json.loads(relation.data[relation.app].get("scrape_metadata", "{}"))
59 topology = JujuTopology.from_dict(scrape_metadata)
60 ```
61
62 ### Using the class constructor
63
64 Enables instantiation using whatever values you want. While this
65 is useful in some very specific cases, this is almost certainly not
66 what you are looking for as setting these values manually may
67 result in observability metrics which do not uniquely identify a
68 charm in order to provide accurate usage reporting, alerting,
69 horizontal scaling, or other use cases.
70
71 ```python
72 topology = JujuTopology(
73 model="some-juju-model",
74 model_uuid="00000000-0000-0000-0000-000000000001",
75 application="fancy-juju-application",
76 unit="fancy-juju-application/0",
77 charm_name="fancy-juju-application-k8s",
78 )
79 ```
80
81 """
82 from collections import OrderedDict
83 from typing import Dict, List, Optional
84 from uuid import UUID
85
86 # The unique Charmhub library identifier, never change it
87 LIBID = "bced1658f20f49d28b88f61f83c2d232"
88
89 LIBAPI = 0
90 LIBPATCH = 6
91
92
93 class InvalidUUIDError(Exception):
94 """Invalid UUID was provided."""
95
96 def __init__(self, uuid: str):
97 self.message = "'{}' is not a valid UUID.".format(uuid)
98 super().__init__(self.message)
99
100
101 class JujuTopology:
102 """JujuTopology is used for storing, generating and formatting juju topology information.
103
104 DEPRECATED: This class is deprecated. Use `pip install cosl` and
105 `from cosl.juju_topology import JujuTopology` instead.
106 """
107
108 def __init__(
109 self,
110 model: str,
111 model_uuid: str,
112 application: str,
113 unit: Optional[str] = None,
114 charm_name: Optional[str] = None,
115 ):
116 """Build a JujuTopology object.
117
118 A `JujuTopology` object is used for storing and transforming
119 Juju topology information. This information is used to
120 annotate Prometheus scrape jobs and alert rules. Such
121 annotation when applied to scrape jobs helps in identifying
122 the source of the scrapped metrics. On the other hand when
123 applied to alert rules topology information ensures that
124 evaluation of alert expressions is restricted to the source
125 (charm) from which the alert rules were obtained.
126
127 Args:
128 model: a string name of the Juju model
129 model_uuid: a globally unique string identifier for the Juju model
130 application: an application name as a string
131 unit: a unit name as a string
132 charm_name: name of charm as a string
133 """
134 if not self.is_valid_uuid(model_uuid):
135 raise InvalidUUIDError(model_uuid)
136
137 self._model = model
138 self._model_uuid = model_uuid
139 self._application = application
140 self._charm_name = charm_name
141 self._unit = unit
142
143 def is_valid_uuid(self, uuid):
144 """Validate the supplied UUID against the Juju Model UUID pattern.
145
146 Args:
147 uuid: string that needs to be checked if it is valid v4 UUID.
148
149 Returns:
150 True if parameter is a valid v4 UUID, False otherwise.
151 """
152 try:
153 return str(UUID(uuid, version=4)) == uuid
154 except (ValueError, TypeError):
155 return False
156
157 @classmethod
158 def from_charm(cls, charm):
159 """Creates a JujuTopology instance by using the model data available on a charm object.
160
161 Args:
162 charm: a `CharmBase` object for which the `JujuTopology` will be constructed
163 Returns:
164 a `JujuTopology` object.
165 """
166 return cls(
167 model=charm.model.name,
168 model_uuid=charm.model.uuid,
169 application=charm.model.app.name,
170 unit=charm.model.unit.name,
171 charm_name=charm.meta.name,
172 )
173
174 @classmethod
175 def from_dict(cls, data: dict):
176 """Factory method for creating `JujuTopology` children from a dictionary.
177
178 Args:
179 data: a dictionary with five keys providing topology information. The keys are
180 - "model"
181 - "model_uuid"
182 - "application"
183 - "unit"
184 - "charm_name"
185 `unit` and `charm_name` may be empty, but will result in more limited
186 labels. However, this allows us to support charms without workloads.
187
188 Returns:
189 a `JujuTopology` object.
190 """
191 return cls(
192 model=data["model"],
193 model_uuid=data["model_uuid"],
194 application=data["application"],
195 unit=data.get("unit", ""),
196 charm_name=data.get("charm_name", ""),
197 )
198
199 def as_dict(
200 self,
201 *,
202 remapped_keys: Optional[Dict[str, str]] = None,
203 excluded_keys: Optional[List[str]] = None,
204 ) -> OrderedDict:
205 """Format the topology information into an ordered dict.
206
207 Keeping the dictionary ordered is important to be able to
208 compare dicts without having to resort to deep comparisons.
209
210 Args:
211 remapped_keys: A dictionary mapping old key names to new key names,
212 which will be substituted when invoked.
213 excluded_keys: A list of key names to exclude from the returned dict.
214 uuid_length: The length to crop the UUID to.
215 """
216 ret = OrderedDict(
217 [
218 ("model", self.model),
219 ("model_uuid", self.model_uuid),
220 ("application", self.application),
221 ("unit", self.unit),
222 ("charm_name", self.charm_name),
223 ]
224 )
225 if excluded_keys:
226 ret = OrderedDict({k: v for k, v in ret.items() if k not in excluded_keys})
227
228 if remapped_keys:
229 ret = OrderedDict(
230 (remapped_keys.get(k), v) if remapped_keys.get(k) else (k, v) for k, v in ret.items() # type: ignore
231 )
232
233 return ret
234
235 @property
236 def identifier(self) -> str:
237 """Format the topology information into a terse string.
238
239 This crops the model UUID, making it unsuitable for comparisons against
240 anything but other identifiers. Mainly to be used as a display name or file
241 name where long strings might become an issue.
242
243 >>> JujuTopology( \
244 model = "a-model", \
245 model_uuid = "00000000-0000-4000-8000-000000000000", \
246 application = "some-app", \
247 unit = "some-app/1" \
248 ).identifier
249 'a-model_00000000_some-app'
250 """
251 parts = self.as_dict(
252 excluded_keys=["unit", "charm_name"],
253 )
254
255 parts["model_uuid"] = self.model_uuid_short
256 values = parts.values()
257
258 return "_".join([str(val) for val in values]).replace("/", "_")
259
260 @property
261 def label_matcher_dict(self) -> Dict[str, str]:
262 """Format the topology information into a dict with keys having 'juju_' as prefix.
263
264 Relabelled topology never includes the unit as it would then only match
265 the leader unit (ie. the unit that produced the dict).
266 """
267 items = self.as_dict(
268 remapped_keys={"charm_name": "charm"},
269 excluded_keys=["unit"],
270 ).items()
271
272 return {"juju_{}".format(key): value for key, value in items if value}
273
274 @property
275 def label_matchers(self) -> str:
276 """Format the topology information into a promql/logql label matcher string.
277
278 Topology label matchers should never include the unit as it
279 would then only match the leader unit (ie. the unit that
280 produced the matchers).
281 """
282 items = self.label_matcher_dict.items()
283 return ", ".join(['{}="{}"'.format(key, value) for key, value in items if value])
284
285 @property
286 def model(self) -> str:
287 """Getter for the juju model value."""
288 return self._model
289
290 @property
291 def model_uuid(self) -> str:
292 """Getter for the juju model uuid value."""
293 return self._model_uuid
294
295 @property
296 def model_uuid_short(self) -> str:
297 """Getter for the juju model value, truncated to the first eight letters."""
298 return self._model_uuid[:8]
299
300 @property
301 def application(self) -> str:
302 """Getter for the juju application value."""
303 return self._application
304
305 @property
306 def charm_name(self) -> Optional[str]:
307 """Getter for the juju charm name value."""
308 return self._charm_name
309
310 @property
311 def unit(self) -> Optional[str]:
312 """Getter for the juju unit value."""
313 return self._unit