bcd43b5430e22bfe8ffb3799a0263a6fb81249df
[osm/devops.git] / installers / charm / mysqld-exporter / src / charm.py
1 #!/usr/bin/env python3
2 # Copyright 2021 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 # pylint: disable=E0213
24
25 from ipaddress import ip_network
26 import logging
27 from pathlib import Path
28 from typing import NoReturn, Optional
29 from urllib.parse import urlparse
30
31 from ops.main import main
32 from opslib.osm.charm import CharmedOsmBase, RelationsMissing
33 from opslib.osm.interfaces.grafana import GrafanaDashboardTarget
34 from opslib.osm.interfaces.mysql import MysqlClient
35 from opslib.osm.interfaces.prometheus import PrometheusScrapeTarget
36 from opslib.osm.pod import (
37 ContainerV3Builder,
38 IngressResourceV3Builder,
39 PodSpecV3Builder,
40 )
41 from opslib.osm.validator import ModelValidator, validator
42
43
44 logger = logging.getLogger(__name__)
45
46 PORT = 9104
47
48
49 class ConfigModel(ModelValidator):
50 site_url: Optional[str]
51 cluster_issuer: Optional[str]
52 ingress_class: Optional[str]
53 ingress_whitelist_source_range: Optional[str]
54 tls_secret_name: Optional[str]
55 mysql_uri: Optional[str]
56
57 @validator("site_url")
58 def validate_site_url(cls, v):
59 if v:
60 parsed = urlparse(v)
61 if not parsed.scheme.startswith("http"):
62 raise ValueError("value must start with http")
63 return v
64
65 @validator("ingress_whitelist_source_range")
66 def validate_ingress_whitelist_source_range(cls, v):
67 if v:
68 ip_network(v)
69 return v
70
71 @validator("mysql_uri")
72 def validate_mysql_uri(cls, v):
73 if v and not v.startswith("mysql://"):
74 raise ValueError("mysql_uri is not properly formed")
75 return v
76
77
78 class MysqlExporterCharm(CharmedOsmBase):
79 def __init__(self, *args) -> NoReturn:
80 super().__init__(*args, oci_image="image")
81
82 # Provision Kafka relation to exchange information
83 self.mysql_client = MysqlClient(self, "mysql")
84 self.framework.observe(self.on["mysql"].relation_changed, self.configure_pod)
85 self.framework.observe(self.on["mysql"].relation_broken, self.configure_pod)
86
87 # Register relation to provide a Scraping Target
88 self.scrape_target = PrometheusScrapeTarget(self, "prometheus-scrape")
89 self.framework.observe(
90 self.on["prometheus-scrape"].relation_joined, self._publish_scrape_info
91 )
92
93 # Register relation to provide a Dasboard Target
94 self.dashboard_target = GrafanaDashboardTarget(self, "grafana-dashboard")
95 self.framework.observe(
96 self.on["grafana-dashboard"].relation_joined, self._publish_dashboard_info
97 )
98
99 def _publish_scrape_info(self, event) -> NoReturn:
100 """Publishes scraping information for Prometheus.
101
102 Args:
103 event (EventBase): Prometheus relation event.
104 """
105 if self.unit.is_leader():
106 hostname = (
107 urlparse(self.model.config["site_url"]).hostname
108 if self.model.config["site_url"]
109 else self.model.app.name
110 )
111 port = str(PORT)
112 if self.model.config.get("site_url", "").startswith("https://"):
113 port = "443"
114 elif self.model.config.get("site_url", "").startswith("http://"):
115 port = "80"
116
117 self.scrape_target.publish_info(
118 hostname=hostname,
119 port=port,
120 metrics_path="/metrics",
121 scrape_interval="30s",
122 scrape_timeout="15s",
123 )
124
125 def _publish_dashboard_info(self, event) -> NoReturn:
126 """Publish dashboards for Grafana.
127
128 Args:
129 event (EventBase): Grafana relation event.
130 """
131 if self.unit.is_leader():
132 self.dashboard_target.publish_info(
133 name="osm-mysql",
134 dashboard=Path("files/mysql_exporter_dashboard.json").read_text(),
135 )
136
137 def _check_missing_dependencies(self, config: ConfigModel):
138 """Check if there is any relation missing.
139
140 Args:
141 config (ConfigModel): object with configuration information.
142
143 Raises:
144 RelationsMissing: if kafka is missing.
145 """
146 missing_relations = []
147
148 if not config.mysql_uri and self.mysql_client.is_missing_data_in_unit():
149 missing_relations.append("mysql")
150
151 if missing_relations:
152 raise RelationsMissing(missing_relations)
153
154 def build_pod_spec(self, image_info):
155 """Build the PodSpec to be used.
156
157 Args:
158 image_info (str): container image information.
159
160 Returns:
161 Dict: PodSpec information.
162 """
163 # Validate config
164 config = ConfigModel(**dict(self.config))
165
166 if config.mysql_uri and not self.mysql_client.is_missing_data_in_unit():
167 raise Exception("Mysql data cannot be provided via config and relation")
168
169 # Check relations
170 self._check_missing_dependencies(config)
171
172 # Create Builder for the PodSpec
173 pod_spec_builder = PodSpecV3Builder()
174
175 # Build container
176 container_builder = ContainerV3Builder(self.app.name, image_info)
177 container_builder.add_port(name=self.app.name, port=PORT)
178 container_builder.add_http_readiness_probe(
179 path="/api/health",
180 port=PORT,
181 initial_delay_seconds=10,
182 period_seconds=10,
183 timeout_seconds=5,
184 success_threshold=1,
185 failure_threshold=3,
186 )
187 container_builder.add_http_liveness_probe(
188 path="/api/health",
189 port=PORT,
190 initial_delay_seconds=60,
191 timeout_seconds=30,
192 failure_threshold=10,
193 )
194
195 data_source = (
196 config.mysql_uri.replace("mysql://", "").split("/")[0]
197 if config.mysql_uri
198 else f"root:{self.mysql_client.root_password}@{self.mysql_client.host}:{self.mysql_client.port}"
199 )
200
201 container_builder.add_envs(
202 {
203 "DATA_SOURCE_NAME": data_source,
204 }
205 )
206 container = container_builder.build()
207
208 # Add container to PodSpec
209 pod_spec_builder.add_container(container)
210
211 # Add ingress resources to PodSpec if site url exists
212 if config.site_url:
213 parsed = urlparse(config.site_url)
214 annotations = {}
215 if config.ingress_class:
216 annotations["kubernetes.io/ingress.class"] = config.ingress_class
217 ingress_resource_builder = IngressResourceV3Builder(
218 f"{self.app.name}-ingress", annotations
219 )
220
221 if config.ingress_whitelist_source_range:
222 annotations[
223 "nginx.ingress.kubernetes.io/whitelist-source-range"
224 ] = config.ingress_whitelist_source_range
225
226 if config.cluster_issuer:
227 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
228
229 if parsed.scheme == "https":
230 ingress_resource_builder.add_tls(
231 [parsed.hostname], config.tls_secret_name
232 )
233 else:
234 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
235
236 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
237 ingress_resource = ingress_resource_builder.build()
238 pod_spec_builder.add_ingress_resource(ingress_resource)
239
240 logger.debug(pod_spec_builder.build())
241
242 return pod_spec_builder.build()
243
244
245 if __name__ == "__main__":
246 main(MysqlExporterCharm)