Major improvement in OSM charms
[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 import logging
27 from typing import Optional, NoReturn
28 from ipaddress import ip_network
29 from urllib.parse import urlparse
30
31 from ops.main import main
32
33 from opslib.osm.charm import CharmedOsmBase, RelationsMissing
34
35 from opslib.osm.pod import (
36 ContainerV3Builder,
37 PodSpecV3Builder,
38 IngressResourceV3Builder,
39 )
40
41
42 from opslib.osm.validator import (
43 ModelValidator,
44 validator,
45 )
46
47 from opslib.osm.interfaces.kafka import KafkaClient
48 from opslib.osm.interfaces.mongo import MongoClient
49 from opslib.osm.interfaces.prometheus import PrometheusClient
50 from opslib.osm.interfaces.keystone import KeystoneClient
51 from opslib.osm.interfaces.http import HttpServer
52
53
54 logger = logging.getLogger(__name__)
55
56 PORT = 9999
57
58
59 class ConfigModel(ModelValidator):
60 enable_test: bool
61 auth_backend: str
62 database_commonkey: str
63 log_level: str
64 max_file_size: int
65 site_url: Optional[str]
66 ingress_whitelist_source_range: Optional[str]
67 tls_secret_name: Optional[str]
68
69 @validator("auth_backend")
70 def validate_auth_backend(cls, v):
71 if v not in {"internal", "keystone"}:
72 raise ValueError("value must be 'internal' or 'keystone'")
73 return v
74
75 @validator("log_level")
76 def validate_log_level(cls, v):
77 if v not in {"INFO", "DEBUG"}:
78 raise ValueError("value must be INFO or DEBUG")
79 return v
80
81 @validator("max_file_size")
82 def validate_max_file_size(cls, v):
83 if v < 0:
84 raise ValueError("value must be equal or greater than 0")
85 return v
86
87 @validator("site_url")
88 def validate_site_url(cls, v):
89 if v:
90 parsed = urlparse(v)
91 if not parsed.scheme.startswith("http"):
92 raise ValueError("value must start with http")
93 return v
94
95 @validator("ingress_whitelist_source_range")
96 def validate_ingress_whitelist_source_range(cls, v):
97 if v:
98 ip_network(v)
99 return v
100
101
102 class NbiCharm(CharmedOsmBase):
103 def __init__(self, *args) -> NoReturn:
104 super().__init__(*args, oci_image="image")
105
106 self.kafka_client = KafkaClient(self, "kafka")
107 self.framework.observe(self.on["kafka"].relation_changed, self.configure_pod)
108 self.framework.observe(self.on["kafka"].relation_broken, self.configure_pod)
109
110 self.mongodb_client = MongoClient(self, "mongodb")
111 self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
112 self.framework.observe(self.on["mongodb"].relation_broken, self.configure_pod)
113
114 self.prometheus_client = PrometheusClient(self, "prometheus")
115 self.framework.observe(
116 self.on["prometheus"].relation_changed, self.configure_pod
117 )
118 self.framework.observe(
119 self.on["prometheus"].relation_broken, self.configure_pod
120 )
121
122 self.keystone_client = KeystoneClient(self, "keystone")
123 self.framework.observe(self.on["keystone"].relation_changed, self.configure_pod)
124 self.framework.observe(self.on["keystone"].relation_broken, self.configure_pod)
125
126 self.http_server = HttpServer(self, "nbi")
127 self.framework.observe(self.on["nbi"].relation_joined, self._publish_nbi_info)
128
129 def _publish_nbi_info(self, event):
130 """Publishes NBI information.
131
132 Args:
133 event (EventBase): RO relation event.
134 """
135 if self.unit.is_leader():
136 self.http_server.publish_info(self.app.name, PORT)
137
138 def _check_missing_dependencies(self, config: ConfigModel):
139 missing_relations = []
140
141 if self.kafka_client.is_missing_data_in_unit():
142 missing_relations.append("kafka")
143 if self.mongodb_client.is_missing_data_in_unit():
144 missing_relations.append("mongodb")
145 if self.prometheus_client.is_missing_data_in_app():
146 missing_relations.append("prometheus")
147 if config.auth_backend == "keystone":
148 if self.keystone_client.is_missing_data_in_app():
149 missing_relations.append("keystone")
150
151 if missing_relations:
152 raise RelationsMissing(missing_relations)
153
154 def build_pod_spec(self, image_info):
155 # Validate config
156 config = ConfigModel(**dict(self.config))
157 # Check relations
158 self._check_missing_dependencies(config)
159 # Create Builder for the PodSpec
160 pod_spec_builder = PodSpecV3Builder()
161 # Build Init Container
162 pod_spec_builder.add_init_container(
163 {
164 "name": "init-check",
165 "image": "alpine:latest",
166 "command": [
167 "sh",
168 "-c",
169 f"until (nc -zvw1 {self.kafka_client.host} {self.kafka_client.port} ); do sleep 3; done; exit 0",
170 ],
171 }
172 )
173 # Build Container
174 container_builder = ContainerV3Builder(self.app.name, image_info)
175 container_builder.add_port(name=self.app.name, port=PORT)
176 container_builder.add_tcpsocket_readiness_probe(
177 PORT,
178 initial_delay_seconds=5,
179 timeout_seconds=5,
180 )
181 container_builder.add_tcpsocket_liveness_probe(
182 PORT,
183 initial_delay_seconds=45,
184 timeout_seconds=10,
185 )
186 container_builder.add_envs(
187 {
188 # General configuration
189 "ALLOW_ANONYMOUS_LOGIN": "yes",
190 "OSMNBI_SERVER_ENABLE_TEST": config.enable_test,
191 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
192 # Kafka configuration
193 "OSMNBI_MESSAGE_HOST": self.kafka_client.host,
194 "OSMNBI_MESSAGE_DRIVER": "kafka",
195 "OSMNBI_MESSAGE_PORT": self.kafka_client.port,
196 # Database configuration
197 "OSMNBI_DATABASE_DRIVER": "mongo",
198 "OSMNBI_DATABASE_URI": self.mongodb_client.connection_string,
199 "OSMNBI_DATABASE_COMMONKEY": config.database_commonkey,
200 # Storage configuration
201 "OSMNBI_STORAGE_DRIVER": "mongo",
202 "OSMNBI_STORAGE_PATH": "/app/storage",
203 "OSMNBI_STORAGE_COLLECTION": "files",
204 "OSMNBI_STORAGE_URI": self.mongodb_client.connection_string,
205 # Prometheus configuration
206 "OSMNBI_PROMETHEUS_HOST": self.prometheus_client.hostname,
207 "OSMNBI_PROMETHEUS_PORT": self.prometheus_client.port,
208 # Log configuration
209 "OSMNBI_LOG_LEVEL": config.log_level,
210 }
211 )
212 if config.auth_backend == "internal":
213 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "internal")
214 elif config.auth_backend == "keystone":
215 container_builder.add_envs(
216 {
217 "OSMNBI_AUTHENTICATION_BACKEND": "keystone",
218 "OSMNBI_AUTHENTICATION_AUTH_URL": self.keystone_client.host,
219 "OSMNBI_AUTHENTICATION_AUTH_PORT": self.keystone_client.port,
220 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": self.keystone_client.user_domain_name,
221 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": self.keystone_client.project_domain_name,
222 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": self.keystone_client.username,
223 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": self.keystone_client.password,
224 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": self.keystone_client.service,
225 }
226 )
227 container = container_builder.build()
228 # Add container to pod spec
229 pod_spec_builder.add_container(container)
230 # Add ingress resources to pod spec if site url exists
231 if config.site_url:
232 parsed = urlparse(config.site_url)
233 annotations = {
234 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
235 str(config.max_file_size) + "m"
236 if config.max_file_size > 0
237 else config.max_file_size
238 ),
239 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
240 }
241 ingress_resource_builder = IngressResourceV3Builder(
242 f"{self.app.name}-ingress", annotations
243 )
244
245 if config.ingress_whitelist_source_range:
246 annotations[
247 "nginx.ingress.kubernetes.io/whitelist-source-range"
248 ] = config.ingress_whitelist_source_range
249
250 if parsed.scheme == "https":
251 ingress_resource_builder.add_tls(
252 [parsed.hostname], config.tls_secret_name
253 )
254 else:
255 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
256
257 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
258 ingress_resource = ingress_resource_builder.build()
259 pod_spec_builder.add_ingress_resource(ingress_resource)
260 logger.debug(pod_spec_builder.build())
261 return pod_spec_builder.build()
262
263
264 if __name__ == "__main__":
265 main(NbiCharm)