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