Integrate MON and Prometheus
[osm/devops.git] / installers / charm / osm-mon / src / charm.py
1 #!/usr/bin/env python3
2 # Copyright 2022 Canonical Ltd.
3 #
4 # Licensed under the Apache License, Version 2.0 (the "License"); you may
5 # not use this file except in compliance with the License. You may obtain
6 # a copy of the License at
7 #
8 # http://www.apache.org/licenses/LICENSE-2.0
9 #
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 # License for the specific language governing permissions and limitations
14 # under the License.
15 #
16 # For those usages not covered by the Apache License, Version 2.0 please
17 # contact: legal@canonical.com
18 #
19 # To get in touch with the maintainers, please contact:
20 # osm-charmers@lists.launchpad.net
21 #
22 #
23 # Learn more at: https://juju.is/docs/sdk
24
25 """OSM MON charm.
26
27 See more: https://charmhub.io/osm
28 """
29
30 import logging
31 from typing import Any, Dict
32 from urllib.parse import urlparse
33
34 from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires
35 from charms.kafka_k8s.v0.kafka import KafkaRequires, _KafkaAvailableEvent
36 from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
37 from charms.osm_libs.v0.utils import (
38 CharmError,
39 DebugMode,
40 HostPath,
41 check_container_ready,
42 check_service_active,
43 )
44 from charms.osm_vca_integrator.v0.vca import VcaDataChangedEvent, VcaRequires
45 from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider
46 from lightkube.models.core_v1 import ServicePort
47 from ops.charm import ActionEvent, CharmBase, CharmEvents
48 from ops.framework import EventSource, StoredState
49 from ops.main import main
50 from ops.model import ActiveStatus, Container
51
52 from grafana_datasource_handler import (
53 DatasourceConfig,
54 GrafanaConfig,
55 GrafanaDataSourceHandler,
56 )
57 from legacy_interfaces import KeystoneClient
58
59 HOSTPATHS = [
60 HostPath(
61 config="mon-hostpath",
62 container_path="/usr/lib/python3/dist-packages/osm_mon",
63 ),
64 HostPath(
65 config="common-hostpath",
66 container_path="/usr/lib/python3/dist-packages/osm_common",
67 ),
68 HostPath(
69 config="n2vc-hostpath",
70 container_path="/usr/lib/python3/dist-packages/n2vc",
71 ),
72 ]
73 SERVICE_PORT = 8000
74 PROMETHEUS_RELATION = "metrics-endpoint"
75
76 logger = logging.getLogger(__name__)
77
78
79 class MonEvents(CharmEvents):
80 """MON events."""
81
82 vca_data_changed = EventSource(VcaDataChangedEvent)
83 kafka_available = EventSource(_KafkaAvailableEvent)
84
85
86 class OsmMonCharm(CharmBase):
87 """OSM MON Kubernetes sidecar charm."""
88
89 on = MonEvents()
90 _stored = StoredState()
91 container_name = "mon"
92 service_name = "mon"
93
94 def __init__(self, *args):
95 super().__init__(*args)
96 self.kafka = KafkaRequires(self)
97 self.mongodb_client = DatabaseRequires(self, "mongodb", database_name="osm")
98 self._set_metrics_endpoint_provider()
99 self.keystone_client = KeystoneClient(self, "keystone")
100 self.vca = VcaRequires(self)
101 self._observe_charm_events()
102 self.container: Container = self.unit.get_container(self.container_name)
103 self.debug_mode = DebugMode(self, self._stored, self.container, HOSTPATHS)
104 self._patch_k8s_service()
105
106 def _set_metrics_endpoint_provider(self):
107 prometheus_jobs = [
108 {
109 "job_name": "mon_exporter",
110 "static_configs": [{"targets": ["*:{}".format(SERVICE_PORT)]}],
111 }
112 ]
113 refresh_events = [
114 self.on.mon_pebble_ready,
115 self.on.update_status,
116 ]
117 self.prometheus_metrics_endpoint = MetricsEndpointProvider(
118 self, jobs=prometheus_jobs, refresh_event=refresh_events
119 )
120
121 @property
122 def external_hostname(self) -> str:
123 """External hostname property.
124
125 Returns:
126 str: the external hostname from config.
127 If not set, return the ClusterIP service name.
128 """
129 return self.config.get("external-hostname") or self.app.name
130
131 # ---------------------------------------------------------------------------
132 # Handlers for Charm Events
133 # ---------------------------------------------------------------------------
134
135 def _on_config_changed(self, _) -> None:
136 """Handler for the config-changed event."""
137 try:
138 self._validate_config()
139 self._check_relations()
140 # Check if the container is ready.
141 # Eventually it will become ready after the first pebble-ready event.
142 check_container_ready(self.container)
143 if not self.debug_mode.started:
144 self._configure_service(self.container)
145 # Update charm status
146 self._on_update_status()
147 except CharmError as e:
148 logger.debug(e.message)
149 self.unit.status = e.status
150
151 def _on_update_status(self, _=None) -> None:
152 """Handler for the update-status event."""
153 try:
154 self._validate_config()
155 self._check_relations()
156 check_container_ready(self.container)
157 if self.debug_mode.started:
158 return
159 check_service_active(self.container, self.service_name)
160 self.unit.status = ActiveStatus()
161 except CharmError as e:
162 logger.debug(e.message)
163 self.unit.status = e.status
164
165 def _on_required_relation_broken(self, _) -> None:
166 """Handler for the relation-broken event."""
167 try:
168 check_container_ready(self.container)
169 check_service_active(self.container, self.service_name)
170 self.container.stop(self.container_name)
171 except CharmError:
172 pass
173 self._on_update_status()
174
175 def _on_get_debug_mode_information_action(self, event: ActionEvent) -> None:
176 """Handler for the get-debug-mode-information action event."""
177 if not self.debug_mode.started:
178 event.fail("debug-mode has not started. Hint: juju config mon debug-mode=true")
179 return
180
181 debug_info = {
182 "command": self.debug_mode.command,
183 "password": self.debug_mode.password,
184 }
185 event.set_results(debug_info)
186
187 def _on_create_datasource_action(self, event: ActionEvent) -> None:
188 """Handler for the create-datasource action event."""
189 url = event.params["url"]
190 if not self._is_valid_url(url):
191 event.fail(f"Invalid datasource url '{url}'")
192 return
193 grafana_config = self._get_grafana_config()
194 datasource_config = DatasourceConfig(event.params["name"], url)
195 response = GrafanaDataSourceHandler.create_datasource(grafana_config, datasource_config)
196 logger.debug(response)
197 if response.is_success:
198 event.set_results(response.results)
199 return
200 event.fail(response.message)
201
202 def _on_list_datasources_action(self, event: ActionEvent) -> None:
203 """Handler for the list-datasource action event."""
204 grafana_config = self._get_grafana_config()
205 response = GrafanaDataSourceHandler.list_datasources(grafana_config)
206 logger.debug(response)
207 if response.is_success:
208 event.set_results(response.results)
209 return
210 event.fail(response.message)
211
212 def _on_delete_datasource_action(self, event: ActionEvent) -> None:
213 """Handler for the delete-datasource action event."""
214 datasource_name = event.params["name"]
215 grafana_config = self._get_grafana_config()
216 response = GrafanaDataSourceHandler.delete_datasource(grafana_config, datasource_name)
217 logger.debug(response)
218 if not response.is_success:
219 event.fail(response.message)
220
221 def _get_grafana_config(self) -> GrafanaConfig:
222 return GrafanaConfig(
223 self.config.get("grafana-user", ""),
224 self.config.get("grafana-password", ""),
225 self.config.get("grafana-url", ""),
226 )
227
228 # ---------------------------------------------------------------------------
229 # Validation and configuration and more
230 # ---------------------------------------------------------------------------
231
232 def _observe_charm_events(self) -> None:
233 event_handler_mapping = {
234 # Core lifecycle events
235 self.on.mon_pebble_ready: self._on_config_changed,
236 self.on.config_changed: self._on_config_changed,
237 self.on.update_status: self._on_update_status,
238 # Relation events
239 self.on.vca_data_changed: self._on_config_changed,
240 self.on.kafka_available: self._on_config_changed,
241 self.on["kafka"].relation_broken: self._on_required_relation_broken,
242 self.mongodb_client.on.database_created: self._on_config_changed,
243 self.on["mongodb"].relation_broken: self._on_required_relation_broken,
244 # Action events
245 self.on.get_debug_mode_information_action: self._on_get_debug_mode_information_action,
246 self.on.create_datasource_action: self._on_create_datasource_action,
247 self.on.list_datasources_action: self._on_list_datasources_action,
248 self.on.delete_datasource_action: self._on_delete_datasource_action,
249 }
250
251 for relation in [self.on[rel_name] for rel_name in [PROMETHEUS_RELATION, "keystone"]]:
252 event_handler_mapping[relation.relation_changed] = self._on_config_changed
253 event_handler_mapping[relation.relation_broken] = self._on_required_relation_broken
254
255 for event, handler in event_handler_mapping.items():
256 self.framework.observe(event, handler)
257
258 def _is_database_available(self) -> bool:
259 try:
260 return self.mongodb_client.is_resource_created()
261 except KeyError:
262 return False
263
264 def _validate_config(self) -> None:
265 """Validate charm configuration.
266
267 Raises:
268 CharmError: if charm configuration is invalid.
269 """
270 logger.debug("validating charm config")
271 self._validate_mandatory_config_is_set()
272 self._validate_urls_in_config()
273
274 def _validate_mandatory_config_is_set(self):
275 missing_configs = []
276 mandatory_configs = ["grafana-url", "grafana-user", "grafana-password", "prometheus-url"]
277 for config in mandatory_configs:
278 if not self.config.get(config):
279 missing_configs.append(config)
280
281 if missing_configs:
282 config_str = ", ".join(missing_configs)
283 error_msg = f"need {config_str} config"
284 logger.warning(error_msg)
285 raise CharmError(error_msg)
286
287 def _validate_urls_in_config(self):
288 urls_to_validate = ["grafana-url", "prometheus-url"]
289 for param in urls_to_validate:
290 url = self.config[param]
291 if not self._is_valid_url(url):
292 raise CharmError(f"Invalid value for {param} config: '{url}'")
293
294 def _is_valid_url(self, url) -> bool:
295 return urlparse(url).hostname is not None
296
297 def _check_relations(self) -> None:
298 """Validate charm relations.
299
300 Raises:
301 CharmError: if charm configuration is invalid.
302 """
303 logger.debug("check for missing relations")
304 missing_relations = []
305
306 if not self.kafka.host or not self.kafka.port:
307 missing_relations.append("kafka")
308 if not self._is_database_available():
309 missing_relations.append("mongodb")
310 if not self.framework.model.get_relation(PROMETHEUS_RELATION):
311 missing_relations.append("prometheus")
312 if self.keystone_client.is_missing_data_in_app():
313 missing_relations.append("keystone")
314
315 if missing_relations:
316 relations_str = ", ".join(missing_relations)
317 one_relation_missing = len(missing_relations) == 1
318 error_msg = f'need {relations_str} relation{"" if one_relation_missing else "s"}'
319 logger.warning(error_msg)
320 raise CharmError(error_msg)
321
322 def _configure_service(self, container: Container) -> None:
323 """Add Pebble layer with the mon service."""
324 logger.debug(f"configuring {self.app.name} service")
325 container.add_layer("mon", self._get_layer(), combine=True)
326 container.replan()
327
328 def _get_layer(self) -> Dict[str, Any]:
329 """Get layer for Pebble."""
330 environment = {
331 # General configuration
332 "OSMMON_GLOBAL_LOGLEVEL": self.config["log-level"],
333 "OSMMON_OPENSTACK_DEFAULT_GRANULARITY": self.config["openstack-default-granularity"],
334 "OSMMON_GLOBAL_REQUEST_TIMEOUT": self.config["global-request-timeout"],
335 "OSMMON_COLLECTOR_INTERVAL": self.config["collector-interval"],
336 "OSMMON_EVALUATOR_INTERVAL": self.config["evaluator-interval"],
337 "OSMMON_COLLECTOR_VM_INFRA_METRICS": self.config["vm-infra-metrics"],
338 # Kafka configuration
339 "OSMMON_MESSAGE_DRIVER": "kafka",
340 "OSMMON_MESSAGE_HOST": self.kafka.host,
341 "OSMMON_MESSAGE_PORT": self.kafka.port,
342 # Database configuration
343 "OSMMON_DATABASE_DRIVER": "mongo",
344 "OSMMON_DATABASE_URI": self._get_mongodb_uri(),
345 "OSMMON_DATABASE_COMMONKEY": self.config["database-commonkey"],
346 # Prometheus configuration
347 "OSMMON_PROMETHEUS_URL": self.config.get("prometheus-url", ""),
348 "OSMMON_PROMETHEUS_USER": "",
349 "OSMMON_PROMETHEUS_PASSWORD": "",
350 # Grafana configuration
351 "OSMMON_GRAFANA_URL": self.config.get("grafana-url", ""),
352 "OSMMON_GRAFANA_USER": self.config.get("grafana-user", ""),
353 "OSMMON_GRAFANA_PASSWORD": self.config.get("grafana-password", ""),
354 "OSMMON_KEYSTONE_ENABLED": self.config["keystone-enabled"],
355 "OSMMON_KEYSTONE_URL": self.keystone_client.host,
356 # Keystone configuration
357 "OSMMON_KEYSTONE_DOMAIN_NAME": self.keystone_client.user_domain_name,
358 "OSMMON_KEYSTONE_SERVICE_PROJECT": self.keystone_client.service,
359 "OSMMON_KEYSTONE_SERVICE_USER": self.keystone_client.username,
360 "OSMMON_KEYSTONE_SERVICE_PASSWORD": self.keystone_client.password,
361 "OSMMON_KEYSTONE_SERVICE_PROJECT_DOMAIN_NAME": self.keystone_client.project_domain_name,
362 }
363 logger.info(f"{environment}")
364 if self.vca.data:
365 environment["OSMMON_VCA_HOST"] = self.vca.data.endpoints
366 environment["OSMMON_VCA_SECRET"] = self.vca.data.secret
367 environment["OSMMON_VCA_USER"] = self.vca.data.user
368 environment["OSMMON_VCA_CACERT"] = self.vca.data.cacert
369 return {
370 "summary": "mon layer",
371 "description": "pebble config layer for mon",
372 "services": {
373 self.service_name: {
374 "override": "replace",
375 "summary": "mon service",
376 "command": "/bin/bash scripts/start.sh",
377 "startup": "enabled",
378 "user": "appuser",
379 "group": "appuser",
380 "environment": environment,
381 }
382 },
383 }
384
385 def _get_mongodb_uri(self):
386 return list(self.mongodb_client.fetch_relation_data().values())[0]["uris"]
387
388 def _patch_k8s_service(self) -> None:
389 port = ServicePort(SERVICE_PORT, name=f"{self.app.name}")
390 self.service_patcher = KubernetesServicePatch(self, [port])
391
392
393 if __name__ == "__main__": # pragma: no cover
394 main(OsmMonCharm)