0b899317d3aa14c3ab04da0bd181a07c49631422
[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 PodRestartPolicy,
40 PodSpecV3Builder,
41 )
42 from opslib.osm.validator import ModelValidator, validator
43
44
45 logger = logging.getLogger(__name__)
46
47 PORT = 9216
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 mongodb_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("mongodb_uri")
74 def validate_mongodb_uri(cls, v):
75 if v and not v.startswith("mongodb://"):
76 raise ValueError("mongodb_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 MongodbExporterCharm(CharmedOsmBase):
93 def __init__(self, *args) -> NoReturn:
94 super().__init__(*args, oci_image="image")
95
96 # Provision Kafka relation to exchange information
97 self.mongodb_client = MongoClient(self, "mongodb")
98 self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
99 self.framework.observe(self.on["mongodb"].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-mongodb",
148 dashboard=Path("templates/mongodb_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.mongodb_uri and self.mongodb_client.is_missing_data_in_unit():
163 missing_relations.append("mongodb")
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.mongodb_uri and not self.mongodb_client.is_missing_data_in_unit():
181 raise Exception("Mongodb data cannot be provided via config and relation")
182
183 # Check relations
184 self._check_missing_dependencies(config)
185
186 unparsed = (
187 config.mongodb_uri
188 if config.mongodb_uri
189 else self.mongodb_client.connection_string
190 )
191 parsed = urlparse(unparsed)
192 mongodb_uri = f"mongodb://{parsed.netloc.split(',')[0]}{parsed.path}"
193 if parsed.query:
194 mongodb_uri += f"?{parsed.query}"
195
196 # Create Builder for the PodSpec
197 pod_spec_builder = PodSpecV3Builder()
198
199 # Add secrets to the pod
200 mongodb_secret_name = f"{self.app.name}-mongodb-secret"
201 pod_spec_builder.add_secret(mongodb_secret_name, {"uri": mongodb_uri})
202
203 # Build container
204 container_builder = ContainerV3Builder(
205 self.app.name, image_info, config.image_pull_policy
206 )
207 container_builder.add_port(name=self.app.name, port=PORT)
208 container_builder.add_http_readiness_probe(
209 path="/api/health",
210 port=PORT,
211 initial_delay_seconds=10,
212 period_seconds=10,
213 timeout_seconds=5,
214 success_threshold=1,
215 failure_threshold=3,
216 )
217 container_builder.add_http_liveness_probe(
218 path="/api/health",
219 port=PORT,
220 initial_delay_seconds=60,
221 timeout_seconds=30,
222 failure_threshold=10,
223 )
224
225 container_builder.add_secret_envs(mongodb_secret_name, {"MONGODB_URI": "uri"})
226 container = container_builder.build()
227
228 # Add container to PodSpec
229 pod_spec_builder.add_container(container)
230
231 # Add Pod restart policy
232 restart_policy = PodRestartPolicy()
233 restart_policy.add_secrets(secret_names=(mongodb_secret_name,))
234 pod_spec_builder.set_restart_policy(restart_policy)
235
236 # Add ingress resources to PodSpec if site url exists
237 if config.site_url:
238 parsed = urlparse(config.site_url)
239 annotations = {}
240 if config.ingress_class:
241 annotations["kubernetes.io/ingress.class"] = config.ingress_class
242 ingress_resource_builder = IngressResourceV3Builder(
243 f"{self.app.name}-ingress", annotations
244 )
245
246 if config.ingress_whitelist_source_range:
247 annotations[
248 "nginx.ingress.kubernetes.io/whitelist-source-range"
249 ] = config.ingress_whitelist_source_range
250
251 if config.cluster_issuer:
252 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
253
254 if parsed.scheme == "https":
255 ingress_resource_builder.add_tls(
256 [parsed.hostname], config.tls_secret_name
257 )
258 else:
259 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
260
261 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
262 ingress_resource = ingress_resource_builder.build()
263 pod_spec_builder.add_ingress_resource(ingress_resource)
264
265 return pod_spec_builder.build()
266
267
268 if __name__ == "__main__":
269 main(MongodbExporterCharm)