a47f618b75d06cfc176402062fad2046250fe19b
[osm/devops.git] / installers / charm / nbi / 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
26 from ipaddress import ip_network
27 import logging
28 from typing import NoReturn, Optional
29 from urllib.parse import urlparse
30
31
32 from ops.main import main
33 from opslib.osm.charm import CharmedOsmBase, RelationsMissing
34 from opslib.osm.interfaces.http import HttpServer
35 from opslib.osm.interfaces.kafka import KafkaClient
36 from opslib.osm.interfaces.keystone import KeystoneClient
37 from opslib.osm.interfaces.mongo import MongoClient
38 from opslib.osm.interfaces.prometheus import PrometheusClient
39 from opslib.osm.pod import (
40 ContainerV3Builder,
41 IngressResourceV3Builder,
42 PodRestartPolicy,
43 PodSpecV3Builder,
44 )
45 from opslib.osm.validator import ModelValidator, validator
46
47
48 logger = logging.getLogger(__name__)
49
50 PORT = 9999
51
52
53 class ConfigModel(ModelValidator):
54 enable_test: bool
55 auth_backend: str
56 database_commonkey: str
57 log_level: str
58 max_file_size: int
59 site_url: Optional[str]
60 cluster_issuer: Optional[str]
61 ingress_class: Optional[str]
62 ingress_whitelist_source_range: Optional[str]
63 tls_secret_name: Optional[str]
64 mongodb_uri: Optional[str]
65 image_pull_policy: str
66
67 @validator("auth_backend")
68 def validate_auth_backend(cls, v):
69 if v not in {"internal", "keystone"}:
70 raise ValueError("value must be 'internal' or 'keystone'")
71 return v
72
73 @validator("log_level")
74 def validate_log_level(cls, v):
75 if v not in {"INFO", "DEBUG"}:
76 raise ValueError("value must be INFO or DEBUG")
77 return v
78
79 @validator("max_file_size")
80 def validate_max_file_size(cls, v):
81 if v < 0:
82 raise ValueError("value must be equal or greater than 0")
83 return v
84
85 @validator("site_url")
86 def validate_site_url(cls, v):
87 if v:
88 parsed = urlparse(v)
89 if not parsed.scheme.startswith("http"):
90 raise ValueError("value must start with http")
91 return v
92
93 @validator("ingress_whitelist_source_range")
94 def validate_ingress_whitelist_source_range(cls, v):
95 if v:
96 ip_network(v)
97 return v
98
99 @validator("mongodb_uri")
100 def validate_mongodb_uri(cls, v):
101 if v and not v.startswith("mongodb://"):
102 raise ValueError("mongodb_uri is not properly formed")
103 return v
104
105 @validator("image_pull_policy")
106 def validate_image_pull_policy(cls, v):
107 values = {
108 "always": "Always",
109 "ifnotpresent": "IfNotPresent",
110 "never": "Never",
111 }
112 v = v.lower()
113 if v not in values.keys():
114 raise ValueError("value must be always, ifnotpresent or never")
115 return values[v]
116
117
118 class NbiCharm(CharmedOsmBase):
119 def __init__(self, *args) -> NoReturn:
120 super().__init__(
121 *args,
122 oci_image="image",
123 debug_mode_config_key="debug_mode",
124 debug_pubkey_config_key="debug_pubkey",
125 vscode_workspace=VSCODE_WORKSPACE,
126 )
127
128 self.kafka_client = KafkaClient(self, "kafka")
129 self.framework.observe(self.on["kafka"].relation_changed, self.configure_pod)
130 self.framework.observe(self.on["kafka"].relation_broken, self.configure_pod)
131
132 self.mongodb_client = MongoClient(self, "mongodb")
133 self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
134 self.framework.observe(self.on["mongodb"].relation_broken, self.configure_pod)
135
136 self.prometheus_client = PrometheusClient(self, "prometheus")
137 self.framework.observe(
138 self.on["prometheus"].relation_changed, self.configure_pod
139 )
140 self.framework.observe(
141 self.on["prometheus"].relation_broken, self.configure_pod
142 )
143
144 self.keystone_client = KeystoneClient(self, "keystone")
145 self.framework.observe(self.on["keystone"].relation_changed, self.configure_pod)
146 self.framework.observe(self.on["keystone"].relation_broken, self.configure_pod)
147
148 self.http_server = HttpServer(self, "nbi")
149 self.framework.observe(self.on["nbi"].relation_joined, self._publish_nbi_info)
150
151 def _publish_nbi_info(self, event):
152 """Publishes NBI information.
153
154 Args:
155 event (EventBase): RO relation event.
156 """
157 if self.unit.is_leader():
158 self.http_server.publish_info(self.app.name, PORT)
159
160 def _check_missing_dependencies(self, config: ConfigModel):
161 missing_relations = []
162
163 if self.kafka_client.is_missing_data_in_unit():
164 missing_relations.append("kafka")
165 if not config.mongodb_uri and self.mongodb_client.is_missing_data_in_unit():
166 missing_relations.append("mongodb")
167 if self.prometheus_client.is_missing_data_in_app():
168 missing_relations.append("prometheus")
169 if config.auth_backend == "keystone":
170 if self.keystone_client.is_missing_data_in_app():
171 missing_relations.append("keystone")
172
173 if missing_relations:
174 raise RelationsMissing(missing_relations)
175
176 def build_pod_spec(self, image_info):
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 # Create Builder for the PodSpec
187 pod_spec_builder = PodSpecV3Builder()
188
189 # Add secrets to the pod
190 mongodb_secret_name = f"{self.app.name}-mongodb-secret"
191 pod_spec_builder.add_secret(
192 mongodb_secret_name,
193 {
194 "uri": config.mongodb_uri or self.mongodb_client.connection_string,
195 "commonkey": config.database_commonkey,
196 },
197 )
198
199 # Build Init Container
200 pod_spec_builder.add_init_container(
201 {
202 "name": "init-check",
203 "image": "alpine:latest",
204 "command": [
205 "sh",
206 "-c",
207 f"until (nc -zvw1 {self.kafka_client.host} {self.kafka_client.port} ); do sleep 3; done; exit 0",
208 ],
209 }
210 )
211
212 # Build Container
213 container_builder = ContainerV3Builder(
214 self.app.name, image_info, config.image_pull_policy
215 )
216 container_builder.add_port(name=self.app.name, port=PORT)
217 container_builder.add_tcpsocket_readiness_probe(
218 PORT,
219 initial_delay_seconds=5,
220 timeout_seconds=5,
221 )
222 container_builder.add_tcpsocket_liveness_probe(
223 PORT,
224 initial_delay_seconds=45,
225 timeout_seconds=10,
226 )
227 container_builder.add_envs(
228 {
229 # General configuration
230 "ALLOW_ANONYMOUS_LOGIN": "yes",
231 "OSMNBI_SERVER_ENABLE_TEST": config.enable_test,
232 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
233 # Kafka configuration
234 "OSMNBI_MESSAGE_HOST": self.kafka_client.host,
235 "OSMNBI_MESSAGE_DRIVER": "kafka",
236 "OSMNBI_MESSAGE_PORT": self.kafka_client.port,
237 # Database configuration
238 "OSMNBI_DATABASE_DRIVER": "mongo",
239 # Storage configuration
240 "OSMNBI_STORAGE_DRIVER": "mongo",
241 "OSMNBI_STORAGE_PATH": "/app/storage",
242 "OSMNBI_STORAGE_COLLECTION": "files",
243 # Prometheus configuration
244 "OSMNBI_PROMETHEUS_HOST": self.prometheus_client.hostname,
245 "OSMNBI_PROMETHEUS_PORT": self.prometheus_client.port,
246 # Log configuration
247 "OSMNBI_LOG_LEVEL": config.log_level,
248 }
249 )
250 container_builder.add_secret_envs(
251 secret_name=mongodb_secret_name,
252 envs={
253 "OSMNBI_DATABASE_URI": "uri",
254 "OSMNBI_DATABASE_COMMONKEY": "commonkey",
255 "OSMNBI_STORAGE_URI": "uri",
256 },
257 )
258 if config.auth_backend == "internal":
259 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "internal")
260 elif config.auth_backend == "keystone":
261 keystone_secret_name = f"{self.app.name}-keystone-secret"
262 pod_spec_builder.add_secret(
263 keystone_secret_name,
264 {
265 "url": self.keystone_client.host,
266 "port": self.keystone_client.port,
267 "user_domain": self.keystone_client.user_domain_name,
268 "project_domain": self.keystone_client.project_domain_name,
269 "service_username": self.keystone_client.username,
270 "service_password": self.keystone_client.password,
271 "service_project": self.keystone_client.service,
272 },
273 )
274 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "keystone")
275 container_builder.add_secret_envs(
276 secret_name=keystone_secret_name,
277 envs={
278 "OSMNBI_AUTHENTICATION_AUTH_URL": "url",
279 "OSMNBI_AUTHENTICATION_AUTH_PORT": "port",
280 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": "user_domain",
281 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": "project_domain",
282 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": "service_username",
283 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": "service_password",
284 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": "service_project",
285 },
286 )
287 container = container_builder.build()
288
289 # Add container to pod spec
290 pod_spec_builder.add_container(container)
291
292 # Add ingress resources to pod spec if site url exists
293 if config.site_url:
294 parsed = urlparse(config.site_url)
295 annotations = {
296 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
297 str(config.max_file_size) + "m"
298 if config.max_file_size > 0
299 else config.max_file_size
300 ),
301 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
302 }
303 if config.ingress_class:
304 annotations["kubernetes.io/ingress.class"] = config.ingress_class
305 ingress_resource_builder = IngressResourceV3Builder(
306 f"{self.app.name}-ingress", annotations
307 )
308
309 if config.ingress_whitelist_source_range:
310 annotations[
311 "nginx.ingress.kubernetes.io/whitelist-source-range"
312 ] = config.ingress_whitelist_source_range
313
314 if config.cluster_issuer:
315 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
316
317 if parsed.scheme == "https":
318 ingress_resource_builder.add_tls(
319 [parsed.hostname], config.tls_secret_name
320 )
321 else:
322 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
323
324 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
325 ingress_resource = ingress_resource_builder.build()
326 pod_spec_builder.add_ingress_resource(ingress_resource)
327
328 # Add restart policy
329 restart_policy = PodRestartPolicy()
330 restart_policy.add_secrets()
331 pod_spec_builder.set_restart_policy(restart_policy)
332
333 return pod_spec_builder.build()
334
335
336 VSCODE_WORKSPACE = {
337 "folders": [
338 {"path": "/usr/lib/python3/dist-packages/osm_nbi"},
339 {"path": "/usr/lib/python3/dist-packages/osm_common"},
340 {"path": "/usr/lib/python3/dist-packages/osm_im"},
341 ],
342 "settings": {},
343 "launch": {
344 "version": "0.2.0",
345 "configurations": [
346 {
347 "name": "NBI",
348 "type": "python",
349 "request": "launch",
350 "module": "osm_nbi.nbi",
351 "justMyCode": False,
352 }
353 ],
354 },
355 }
356
357
358 if __name__ == "__main__":
359 main(NbiCharm)