blob: 153dbfd97a35d5975ff85c05ba4c15361a1ef2c8 [file] [log] [blame]
sousaedu90d10f52021-02-08 02:14:48 +01001#!/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
sousaedu10721602021-05-18 17:28:17 +020023# pylint: disable=E0213
24
25from ipaddress import ip_network
sousaedu90d10f52021-02-08 02:14:48 +010026import logging
27from pathlib import Path
sousaedu10721602021-05-18 17:28:17 +020028from typing import NoReturn, Optional
sousaedu90d10f52021-02-08 02:14:48 +010029from urllib.parse import urlparse
30
sousaedu90d10f52021-02-08 02:14:48 +010031from ops.main import main
sousaedu10721602021-05-18 17:28:17 +020032from opslib.osm.charm import CharmedOsmBase, RelationsMissing
33from opslib.osm.interfaces.grafana import GrafanaDashboardTarget
34from opslib.osm.interfaces.mysql import MysqlClient
35from opslib.osm.interfaces.prometheus import PrometheusScrapeTarget
36from opslib.osm.pod import (
37 ContainerV3Builder,
38 IngressResourceV3Builder,
David Garcia141d9352021-09-08 17:48:40 +020039 PodRestartPolicy,
sousaedu10721602021-05-18 17:28:17 +020040 PodSpecV3Builder,
41)
42from opslib.osm.validator import ModelValidator, validator
sousaedu90d10f52021-02-08 02:14:48 +010043
sousaedu90d10f52021-02-08 02:14:48 +010044
45logger = logging.getLogger(__name__)
46
sousaedu10721602021-05-18 17:28:17 +020047PORT = 9104
sousaedu90d10f52021-02-08 02:14:48 +010048
49
sousaedu10721602021-05-18 17:28:17 +020050class ConfigModel(ModelValidator):
51 site_url: Optional[str]
52 cluster_issuer: Optional[str]
David Garciad68e0b42021-06-28 16:50:42 +020053 ingress_class: Optional[str]
sousaedu10721602021-05-18 17:28:17 +020054 ingress_whitelist_source_range: Optional[str]
55 tls_secret_name: Optional[str]
56 mysql_uri: Optional[str]
sousaedu0dc25b32021-08-30 16:33:33 +010057 image_pull_policy: str
sousaedu540d9372021-09-29 01:53:30 +010058 security_context: bool
sousaedu10721602021-05-18 17:28:17 +020059
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
sousaedu90d10f52021-02-08 02:14:48 +010079
sousaedu3ddbbd12021-08-24 19:57:24 +010080 @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
sousaedu90d10f52021-02-08 02:14:48 +010092
sousaedu10721602021-05-18 17:28:17 +020093class MysqlExporterCharm(CharmedOsmBase):
sousaedu90d10f52021-02-08 02:14:48 +010094 def __init__(self, *args) -> NoReturn:
sousaedu10721602021-05-18 17:28:17 +020095 super().__init__(*args, oci_image="image")
sousaedu90d10f52021-02-08 02:14:48 +010096
sousaedu10721602021-05-18 17:28:17 +020097 # 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)
sousaedu90d10f52021-02-08 02:14:48 +0100101
sousaedu10721602021-05-18 17:28:17 +0200102 # Register relation to provide a Scraping Target
103 self.scrape_target = PrometheusScrapeTarget(self, "prometheus-scrape")
sousaedu90d10f52021-02-08 02:14:48 +0100104 self.framework.observe(
sousaedu10721602021-05-18 17:28:17 +0200105 self.on["prometheus-scrape"].relation_joined, self._publish_scrape_info
sousaedu90d10f52021-02-08 02:14:48 +0100106 )
107
sousaedu10721602021-05-18 17:28:17 +0200108 # 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.
sousaedu90d10f52021-02-08 02:14:48 +0100116
117 Args:
sousaedu10721602021-05-18 17:28:17 +0200118 event (EventBase): Prometheus relation event.
sousaedu90d10f52021-02-08 02:14:48 +0100119 """
sousaedu10721602021-05-18 17:28:17 +0200120 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
sousaedu90d10f52021-02-08 02:14:48 +0100125 )
sousaedu10721602021-05-18 17:28:17 +0200126 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"
sousaedu90d10f52021-02-08 02:14:48 +0100131
sousaedu10721602021-05-18 17:28:17 +0200132 self.scrape_target.publish_info(
133 hostname=hostname,
134 port=port,
135 metrics_path="/metrics",
136 scrape_interval="30s",
137 scrape_timeout="15s",
138 )
sousaedu90d10f52021-02-08 02:14:48 +0100139
sousaedu10721602021-05-18 17:28:17 +0200140 def _publish_dashboard_info(self, event) -> NoReturn:
141 """Publish dashboards for Grafana.
sousaedu90d10f52021-02-08 02:14:48 +0100142
143 Args:
sousaedu10721602021-05-18 17:28:17 +0200144 event (EventBase): Grafana relation event.
sousaedu90d10f52021-02-08 02:14:48 +0100145 """
sousaedu10721602021-05-18 17:28:17 +0200146 if self.unit.is_leader():
147 self.dashboard_target.publish_info(
148 name="osm-mysql",
David Garciad680be42021-08-17 11:03:55 +0200149 dashboard=Path("templates/mysql_exporter_dashboard.json").read_text(),
sousaedu90d10f52021-02-08 02:14:48 +0100150 )
sousaedu90d10f52021-02-08 02:14:48 +0100151
sousaedu10721602021-05-18 17:28:17 +0200152 def _check_missing_dependencies(self, config: ConfigModel):
153 """Check if there is any relation missing.
sousaedu90d10f52021-02-08 02:14:48 +0100154
sousaedu10721602021-05-18 17:28:17 +0200155 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
David Garcia141d9352021-09-08 17:48:40 +0200187 data_source = (
David Garciacc4378a2021-12-17 08:30:59 +0100188 f'{config.mysql_uri.replace("mysql://", "").replace("@", "@(").split("/")[0]})/'
David Garcia141d9352021-09-08 17:48:40 +0200189 if config.mysql_uri
David Garciacc4378a2021-12-17 08:30:59 +0100190 else f"root:{self.mysql_client.root_password}@({self.mysql_client.host}:{self.mysql_client.port})/"
David Garcia141d9352021-09-08 17:48:40 +0200191 )
192
sousaedu10721602021-05-18 17:28:17 +0200193 # Create Builder for the PodSpec
sousaedu540d9372021-09-29 01:53:30 +0100194 pod_spec_builder = PodSpecV3Builder(
195 enable_security_context=config.security_context
196 )
sousaedu10721602021-05-18 17:28:17 +0200197
David Garcia141d9352021-09-08 17:48:40 +0200198 # 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
sousaedu10721602021-05-18 17:28:17 +0200205 # Build container
sousaedu3ddbbd12021-08-24 19:57:24 +0100206 container_builder = ContainerV3Builder(
sousaedu540d9372021-09-29 01:53:30 +0100207 self.app.name,
208 image_info,
209 config.image_pull_policy,
210 run_as_non_root=config.security_context,
sousaedu3ddbbd12021-08-24 19:57:24 +0100211 )
David Garciaa2ebf4e2022-03-11 16:58:35 +0100212 container_builder.add_port(name="exporter", port=PORT)
sousaedu10721602021-05-18 17:28:17 +0200213 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 )
David Garcia141d9352021-09-08 17:48:40 +0200229 container_builder.add_secret_envs(
230 mysql_secret_name, {"DATA_SOURCE_NAME": "data_source"}
sousaedu10721602021-05-18 17:28:17 +0200231 )
232
sousaedu10721602021-05-18 17:28:17 +0200233 container = container_builder.build()
234
235 # Add container to PodSpec
236 pod_spec_builder.add_container(container)
237
David Garcia141d9352021-09-08 17:48:40 +0200238 # 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
sousaedu10721602021-05-18 17:28:17 +0200243 # Add ingress resources to PodSpec if site url exists
244 if config.site_url:
245 parsed = urlparse(config.site_url)
David Garciad68e0b42021-06-28 16:50:42 +0200246 annotations = {}
247 if config.ingress_class:
248 annotations["kubernetes.io/ingress.class"] = config.ingress_class
sousaedu10721602021-05-18 17:28:17 +0200249 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
sousaedu10721602021-05-18 17:28:17 +0200272 return pod_spec_builder.build()
sousaedu90d10f52021-02-08 02:14:48 +0100273
274
275if __name__ == "__main__":
sousaedu10721602021-05-18 17:28:17 +0200276 main(MysqlExporterCharm)