--- /dev/null
+# Copyright 2023 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""## Overview.
+
+This document explains how to use the `JujuTopology` class to
+create and consume topology information from Juju in a consistent manner.
+
+The goal of the Juju topology is to uniquely identify a piece
+of software running across any of your Juju-managed deployments.
+This is achieved by combining the following four elements:
+
+- Model name
+- Model UUID
+- Application name
+- Unit identifier
+
+
+For a more in-depth description of the concept, as well as a
+walk-through of it's use-case in observability, see
+[this blog post](https://juju.is/blog/model-driven-observability-part-2-juju-topology-metrics)
+on the Juju blog.
+
+## Library Usage
+
+This library may be used to create and consume `JujuTopology` objects.
+The `JujuTopology` class provides three ways to create instances:
+
+### Using the `from_charm` method
+
+Enables instantiation by supplying the charm as an argument. When
+creating topology objects for the current charm, this is the recommended
+approach.
+
+```python
+topology = JujuTopology.from_charm(self)
+```
+
+### Using the `from_dict` method
+
+Allows for instantion using a dictionary of relation data, like the
+`scrape_metadata` from Prometheus or the labels of an alert rule. When
+creating topology objects for remote charms, this is the recommended
+approach.
+
+```python
+scrape_metadata = json.loads(relation.data[relation.app].get("scrape_metadata", "{}"))
+topology = JujuTopology.from_dict(scrape_metadata)
+```
+
+### Using the class constructor
+
+Enables instantiation using whatever values you want. While this
+is useful in some very specific cases, this is almost certainly not
+what you are looking for as setting these values manually may
+result in observability metrics which do not uniquely identify a
+charm in order to provide accurate usage reporting, alerting,
+horizontal scaling, or other use cases.
+
+```python
+topology = JujuTopology(
+ model="some-juju-model",
+ model_uuid="00000000-0000-0000-0000-000000000001",
+ application="fancy-juju-application",
+ unit="fancy-juju-application/0",
+ charm_name="fancy-juju-application-k8s",
+)
+```
+
+"""
+from collections import OrderedDict
+from typing import Dict, List, Optional
+from uuid import UUID
+
+# The unique Charmhub library identifier, never change it
+LIBID = "bced1658f20f49d28b88f61f83c2d232"
+
+LIBAPI = 0
+LIBPATCH = 6
+
+
+class InvalidUUIDError(Exception):
+ """Invalid UUID was provided."""
+
+ def __init__(self, uuid: str):
+ self.message = "'{}' is not a valid UUID.".format(uuid)
+ super().__init__(self.message)
+
+
+class JujuTopology:
+ """JujuTopology is used for storing, generating and formatting juju topology information.
+
+ DEPRECATED: This class is deprecated. Use `pip install cosl` and
+ `from cosl.juju_topology import JujuTopology` instead.
+ """
+
+ def __init__(
+ self,
+ model: str,
+ model_uuid: str,
+ application: str,
+ unit: Optional[str] = None,
+ charm_name: Optional[str] = None,
+ ):
+ """Build a JujuTopology object.
+
+ A `JujuTopology` object is used for storing and transforming
+ Juju topology information. This information is used to
+ annotate Prometheus scrape jobs and alert rules. Such
+ annotation when applied to scrape jobs helps in identifying
+ the source of the scrapped metrics. On the other hand when
+ applied to alert rules topology information ensures that
+ evaluation of alert expressions is restricted to the source
+ (charm) from which the alert rules were obtained.
+
+ Args:
+ model: a string name of the Juju model
+ model_uuid: a globally unique string identifier for the Juju model
+ application: an application name as a string
+ unit: a unit name as a string
+ charm_name: name of charm as a string
+ """
+ if not self.is_valid_uuid(model_uuid):
+ raise InvalidUUIDError(model_uuid)
+
+ self._model = model
+ self._model_uuid = model_uuid
+ self._application = application
+ self._charm_name = charm_name
+ self._unit = unit
+
+ def is_valid_uuid(self, uuid):
+ """Validate the supplied UUID against the Juju Model UUID pattern.
+
+ Args:
+ uuid: string that needs to be checked if it is valid v4 UUID.
+
+ Returns:
+ True if parameter is a valid v4 UUID, False otherwise.
+ """
+ try:
+ return str(UUID(uuid, version=4)) == uuid
+ except (ValueError, TypeError):
+ return False
+
+ @classmethod
+ def from_charm(cls, charm):
+ """Creates a JujuTopology instance by using the model data available on a charm object.
+
+ Args:
+ charm: a `CharmBase` object for which the `JujuTopology` will be constructed
+ Returns:
+ a `JujuTopology` object.
+ """
+ return cls(
+ model=charm.model.name,
+ model_uuid=charm.model.uuid,
+ application=charm.model.app.name,
+ unit=charm.model.unit.name,
+ charm_name=charm.meta.name,
+ )
+
+ @classmethod
+ def from_dict(cls, data: dict):
+ """Factory method for creating `JujuTopology` children from a dictionary.
+
+ Args:
+ data: a dictionary with five keys providing topology information. The keys are
+ - "model"
+ - "model_uuid"
+ - "application"
+ - "unit"
+ - "charm_name"
+ `unit` and `charm_name` may be empty, but will result in more limited
+ labels. However, this allows us to support charms without workloads.
+
+ Returns:
+ a `JujuTopology` object.
+ """
+ return cls(
+ model=data["model"],
+ model_uuid=data["model_uuid"],
+ application=data["application"],
+ unit=data.get("unit", ""),
+ charm_name=data.get("charm_name", ""),
+ )
+
+ def as_dict(
+ self,
+ *,
+ remapped_keys: Optional[Dict[str, str]] = None,
+ excluded_keys: Optional[List[str]] = None,
+ ) -> OrderedDict:
+ """Format the topology information into an ordered dict.
+
+ Keeping the dictionary ordered is important to be able to
+ compare dicts without having to resort to deep comparisons.
+
+ Args:
+ remapped_keys: A dictionary mapping old key names to new key names,
+ which will be substituted when invoked.
+ excluded_keys: A list of key names to exclude from the returned dict.
+ uuid_length: The length to crop the UUID to.
+ """
+ ret = OrderedDict(
+ [
+ ("model", self.model),
+ ("model_uuid", self.model_uuid),
+ ("application", self.application),
+ ("unit", self.unit),
+ ("charm_name", self.charm_name),
+ ]
+ )
+ if excluded_keys:
+ ret = OrderedDict({k: v for k, v in ret.items() if k not in excluded_keys})
+
+ if remapped_keys:
+ ret = OrderedDict(
+ (remapped_keys.get(k), v) if remapped_keys.get(k) else (k, v) for k, v in ret.items() # type: ignore
+ )
+
+ return ret
+
+ @property
+ def identifier(self) -> str:
+ """Format the topology information into a terse string.
+
+ This crops the model UUID, making it unsuitable for comparisons against
+ anything but other identifiers. Mainly to be used as a display name or file
+ name where long strings might become an issue.
+
+ >>> JujuTopology( \
+ model = "a-model", \
+ model_uuid = "00000000-0000-4000-8000-000000000000", \
+ application = "some-app", \
+ unit = "some-app/1" \
+ ).identifier
+ 'a-model_00000000_some-app'
+ """
+ parts = self.as_dict(
+ excluded_keys=["unit", "charm_name"],
+ )
+
+ parts["model_uuid"] = self.model_uuid_short
+ values = parts.values()
+
+ return "_".join([str(val) for val in values]).replace("/", "_")
+
+ @property
+ def label_matcher_dict(self) -> Dict[str, str]:
+ """Format the topology information into a dict with keys having 'juju_' as prefix.
+
+ Relabelled topology never includes the unit as it would then only match
+ the leader unit (ie. the unit that produced the dict).
+ """
+ items = self.as_dict(
+ remapped_keys={"charm_name": "charm"},
+ excluded_keys=["unit"],
+ ).items()
+
+ return {"juju_{}".format(key): value for key, value in items if value}
+
+ @property
+ def label_matchers(self) -> str:
+ """Format the topology information into a promql/logql label matcher string.
+
+ Topology label matchers should never include the unit as it
+ would then only match the leader unit (ie. the unit that
+ produced the matchers).
+ """
+ items = self.label_matcher_dict.items()
+ return ", ".join(['{}="{}"'.format(key, value) for key, value in items if value])
+
+ @property
+ def model(self) -> str:
+ """Getter for the juju model value."""
+ return self._model
+
+ @property
+ def model_uuid(self) -> str:
+ """Getter for the juju model uuid value."""
+ return self._model_uuid
+
+ @property
+ def model_uuid_short(self) -> str:
+ """Getter for the juju model value, truncated to the first eight letters."""
+ return self._model_uuid[:8]
+
+ @property
+ def application(self) -> str:
+ """Getter for the juju application value."""
+ return self._application
+
+ @property
+ def charm_name(self) -> Optional[str]:
+ """Getter for the juju charm name value."""
+ return self._charm_name
+
+ @property
+ def unit(self) -> Optional[str]:
+ """Getter for the juju unit value."""
+ return self._unit