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