Adding security_context flag to 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 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 debug_mode: bool
67 security_context: bool
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 @validator("mongodb_uri")
102 def validate_mongodb_uri(cls, v):
103 if v and not v.startswith("mongodb://"):
104 raise ValueError("mongodb_uri is not properly formed")
105 return v
106
107 @validator("image_pull_policy")
108 def validate_image_pull_policy(cls, v):
109 values = {
110 "always": "Always",
111 "ifnotpresent": "IfNotPresent",
112 "never": "Never",
113 }
114 v = v.lower()
115 if v not in values.keys():
116 raise ValueError("value must be always, ifnotpresent or never")
117 return values[v]
118
119
120 class NbiCharm(CharmedOsmBase):
121 def __init__(self, *args) -> NoReturn:
122 super().__init__(
123 *args,
124 oci_image="image",
125 debug_mode_config_key="debug_mode",
126 debug_pubkey_config_key="debug_pubkey",
127 vscode_workspace=VSCODE_WORKSPACE,
128 )
129
130 self.kafka_client = KafkaClient(self, "kafka")
131 self.framework.observe(self.on["kafka"].relation_changed, self.configure_pod)
132 self.framework.observe(self.on["kafka"].relation_broken, self.configure_pod)
133
134 self.mongodb_client = MongoClient(self, "mongodb")
135 self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
136 self.framework.observe(self.on["mongodb"].relation_broken, self.configure_pod)
137
138 self.prometheus_client = PrometheusClient(self, "prometheus")
139 self.framework.observe(
140 self.on["prometheus"].relation_changed, self.configure_pod
141 )
142 self.framework.observe(
143 self.on["prometheus"].relation_broken, self.configure_pod
144 )
145
146 self.keystone_client = KeystoneClient(self, "keystone")
147 self.framework.observe(self.on["keystone"].relation_changed, self.configure_pod)
148 self.framework.observe(self.on["keystone"].relation_broken, self.configure_pod)
149
150 self.http_server = HttpServer(self, "nbi")
151 self.framework.observe(self.on["nbi"].relation_joined, self._publish_nbi_info)
152
153 def _publish_nbi_info(self, event):
154 """Publishes NBI information.
155
156 Args:
157 event (EventBase): RO relation event.
158 """
159 if self.unit.is_leader():
160 self.http_server.publish_info(self.app.name, PORT)
161
162 def _check_missing_dependencies(self, config: ConfigModel):
163 missing_relations = []
164
165 if self.kafka_client.is_missing_data_in_unit():
166 missing_relations.append("kafka")
167 if not config.mongodb_uri and self.mongodb_client.is_missing_data_in_unit():
168 missing_relations.append("mongodb")
169 if self.prometheus_client.is_missing_data_in_app():
170 missing_relations.append("prometheus")
171 if config.auth_backend == "keystone":
172 if self.keystone_client.is_missing_data_in_app():
173 missing_relations.append("keystone")
174
175 if missing_relations:
176 raise RelationsMissing(missing_relations)
177
178 def build_pod_spec(self, image_info):
179 # Validate config
180 config = ConfigModel(**dict(self.config))
181
182 if config.mongodb_uri and not self.mongodb_client.is_missing_data_in_unit():
183 raise Exception("Mongodb data cannot be provided via config and relation")
184
185 # Check relations
186 self._check_missing_dependencies(config)
187
188 security_context_enabled = (
189 config.security_context if not config.debug_mode else False
190 )
191
192 # Create Builder for the PodSpec
193 pod_spec_builder = PodSpecV3Builder(
194 enable_security_context=security_context_enabled
195 )
196
197 # Add secrets to the pod
198 mongodb_secret_name = f"{self.app.name}-mongodb-secret"
199 pod_spec_builder.add_secret(
200 mongodb_secret_name,
201 {
202 "uri": config.mongodb_uri or self.mongodb_client.connection_string,
203 "commonkey": config.database_commonkey,
204 },
205 )
206
207 # Build Init Container
208 pod_spec_builder.add_init_container(
209 {
210 "name": "init-check",
211 "image": "alpine:latest",
212 "command": [
213 "sh",
214 "-c",
215 f"until (nc -zvw1 {self.kafka_client.host} {self.kafka_client.port} ); do sleep 3; done; exit 0",
216 ],
217 }
218 )
219
220 # Build Container
221 container_builder = ContainerV3Builder(
222 self.app.name,
223 image_info,
224 config.image_pull_policy,
225 run_as_non_root=security_context_enabled,
226 )
227 container_builder.add_port(name=self.app.name, port=PORT)
228 container_builder.add_tcpsocket_readiness_probe(
229 PORT,
230 initial_delay_seconds=5,
231 timeout_seconds=5,
232 )
233 container_builder.add_tcpsocket_liveness_probe(
234 PORT,
235 initial_delay_seconds=45,
236 timeout_seconds=10,
237 )
238 container_builder.add_envs(
239 {
240 # General configuration
241 "ALLOW_ANONYMOUS_LOGIN": "yes",
242 "OSMNBI_SERVER_ENABLE_TEST": config.enable_test,
243 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
244 # Kafka configuration
245 "OSMNBI_MESSAGE_HOST": self.kafka_client.host,
246 "OSMNBI_MESSAGE_DRIVER": "kafka",
247 "OSMNBI_MESSAGE_PORT": self.kafka_client.port,
248 # Database configuration
249 "OSMNBI_DATABASE_DRIVER": "mongo",
250 # Storage configuration
251 "OSMNBI_STORAGE_DRIVER": "mongo",
252 "OSMNBI_STORAGE_PATH": "/app/storage",
253 "OSMNBI_STORAGE_COLLECTION": "files",
254 # Prometheus configuration
255 "OSMNBI_PROMETHEUS_HOST": self.prometheus_client.hostname,
256 "OSMNBI_PROMETHEUS_PORT": self.prometheus_client.port,
257 # Log configuration
258 "OSMNBI_LOG_LEVEL": config.log_level,
259 }
260 )
261 container_builder.add_secret_envs(
262 secret_name=mongodb_secret_name,
263 envs={
264 "OSMNBI_DATABASE_URI": "uri",
265 "OSMNBI_DATABASE_COMMONKEY": "commonkey",
266 "OSMNBI_STORAGE_URI": "uri",
267 },
268 )
269 if config.auth_backend == "internal":
270 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "internal")
271 elif config.auth_backend == "keystone":
272 keystone_secret_name = f"{self.app.name}-keystone-secret"
273 pod_spec_builder.add_secret(
274 keystone_secret_name,
275 {
276 "url": self.keystone_client.host,
277 "port": self.keystone_client.port,
278 "user_domain": self.keystone_client.user_domain_name,
279 "project_domain": self.keystone_client.project_domain_name,
280 "service_username": self.keystone_client.username,
281 "service_password": self.keystone_client.password,
282 "service_project": self.keystone_client.service,
283 },
284 )
285 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "keystone")
286 container_builder.add_secret_envs(
287 secret_name=keystone_secret_name,
288 envs={
289 "OSMNBI_AUTHENTICATION_AUTH_URL": "url",
290 "OSMNBI_AUTHENTICATION_AUTH_PORT": "port",
291 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": "user_domain",
292 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": "project_domain",
293 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": "service_username",
294 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": "service_password",
295 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": "service_project",
296 },
297 )
298 container = container_builder.build()
299
300 # Add container to pod spec
301 pod_spec_builder.add_container(container)
302
303 # Add ingress resources to pod spec if site url exists
304 if config.site_url:
305 parsed = urlparse(config.site_url)
306 annotations = {
307 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
308 str(config.max_file_size) + "m"
309 if config.max_file_size > 0
310 else config.max_file_size
311 ),
312 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
313 }
314 if config.ingress_class:
315 annotations["kubernetes.io/ingress.class"] = config.ingress_class
316 ingress_resource_builder = IngressResourceV3Builder(
317 f"{self.app.name}-ingress", annotations
318 )
319
320 if config.ingress_whitelist_source_range:
321 annotations[
322 "nginx.ingress.kubernetes.io/whitelist-source-range"
323 ] = config.ingress_whitelist_source_range
324
325 if config.cluster_issuer:
326 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
327
328 if parsed.scheme == "https":
329 ingress_resource_builder.add_tls(
330 [parsed.hostname], config.tls_secret_name
331 )
332 else:
333 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
334
335 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
336 ingress_resource = ingress_resource_builder.build()
337 pod_spec_builder.add_ingress_resource(ingress_resource)
338
339 # Add restart policy
340 restart_policy = PodRestartPolicy()
341 restart_policy.add_secrets()
342 pod_spec_builder.set_restart_policy(restart_policy)
343
344 return pod_spec_builder.build()
345
346
347 VSCODE_WORKSPACE = {
348 "folders": [
349 {"path": "/usr/lib/python3/dist-packages/osm_nbi"},
350 {"path": "/usr/lib/python3/dist-packages/osm_common"},
351 {"path": "/usr/lib/python3/dist-packages/osm_im"},
352 ],
353 "settings": {},
354 "launch": {
355 "version": "0.2.0",
356 "configurations": [
357 {
358 "name": "NBI",
359 "type": "python",
360 "request": "launch",
361 "module": "osm_nbi.nbi",
362 "justMyCode": False,
363 }
364 ],
365 },
366 }
367
368
369 if __name__ == "__main__":
370 main(NbiCharm)