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