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