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