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