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