Add RO sidecar charm
[osm/devops.git] / installers / charm / osm-ro / src / charm.py
1 #!/usr/bin/env python3
2 # Copyright 2022 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 # Learn more at: https://juju.is/docs/sdk
24
25 """OSM RO charm.
26
27 See more: https://charmhub.io/osm
28 """
29
30 import base64
31 import logging
32 from typing import Any, Dict
33
34 from charms.kafka_k8s.v0.kafka import KafkaEvents, KafkaRequires
35 from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
36 from charms.osm_libs.v0.utils import (
37 CharmError,
38 DebugMode,
39 HostPath,
40 check_container_ready,
41 check_service_active,
42 )
43 from charms.osm_ro.v0.ro import RoProvides
44 from lightkube.models.core_v1 import ServicePort
45 from ops.charm import ActionEvent, CharmBase, RelationJoinedEvent
46 from ops.framework import StoredState
47 from ops.main import main
48 from ops.model import ActiveStatus, Container
49
50 from legacy_interfaces import MongoClient
51
52 HOSTPATHS = [
53 HostPath(
54 config="ro-hostpath",
55 container_path="/usr/lib/python3/dist-packages/osm_ro",
56 ),
57 HostPath(
58 config="common-hostpath",
59 container_path="/usr/lib/python3/dist-packages/osm_common",
60 ),
61 ]
62 SERVICE_PORT = 9090
63 USER = GROUP = "appuser"
64
65 logger = logging.getLogger(__name__)
66
67
68 def decode(content: str):
69 """Base64 decoding of a string."""
70 return base64.b64decode(content.encode("utf-8")).decode("utf-8")
71
72
73 class OsmRoCharm(CharmBase):
74 """OSM RO Kubernetes sidecar charm."""
75
76 on = KafkaEvents()
77 service_name = "ro"
78 _stored = StoredState()
79
80 def __init__(self, *args):
81 super().__init__(*args)
82 self._stored.set_default(certificates=set())
83 self.kafka = KafkaRequires(self)
84 self.mongodb_client = MongoClient(self, "mongodb")
85 self._observe_charm_events()
86 self._patch_k8s_service()
87 self.ro = RoProvides(self)
88 self.container: Container = self.unit.get_container("ro")
89 self.debug_mode = DebugMode(self, self._stored, self.container, HOSTPATHS)
90
91 # ---------------------------------------------------------------------------
92 # Handlers for Charm Events
93 # ---------------------------------------------------------------------------
94
95 def _on_config_changed(self, _) -> None:
96 """Handler for the config-changed event."""
97 try:
98 self._validate_config()
99 self._check_relations()
100 # Check if the container is ready.
101 # Eventually it will become ready after the first pebble-ready event.
102 check_container_ready(self.container)
103
104 self._configure_certificates()
105 self._configure_service()
106 self._update_ro_relation()
107
108 # Update charm status
109 self._on_update_status()
110 except CharmError as e:
111 logger.debug(e.message)
112 self.unit.status = e.status
113
114 def _on_update_status(self, _=None) -> None:
115 """Handler for the update-status event."""
116 try:
117 self._validate_config()
118 self._check_relations()
119 check_container_ready(self.container)
120 if self.debug_mode.started:
121 return
122 check_service_active(self.container, self.service_name)
123 self.unit.status = ActiveStatus()
124 except CharmError as e:
125 logger.debug(e.message)
126 self.unit.status = e.status
127
128 def _on_required_relation_broken(self, _) -> None:
129 """Handler for the kafka-broken event."""
130 try:
131 check_container_ready(self.container)
132 check_service_active(self.container, "ro")
133 self.container.stop("ro")
134 except CharmError:
135 pass
136
137 self._on_update_status()
138
139 def _update_ro_relation(self, event: RelationJoinedEvent = None) -> None:
140 """Handler for the ro-relation-joined event."""
141 try:
142 if self.unit.is_leader():
143 check_container_ready(self.container)
144 check_service_active(self.container, "ro")
145 self.ro.set_host_info(
146 self.app.name, SERVICE_PORT, event.relation if event else None
147 )
148 except CharmError as e:
149 self.unit.status = e.status
150
151 def _on_get_debug_mode_information_action(self, event: ActionEvent) -> None:
152 """Handler for the get-debug-mode-information action event."""
153 if not self.debug_mode.started:
154 event.fail(
155 f"debug-mode has not started. Hint: juju config {self.app.name} debug-mode=true"
156 )
157 return
158
159 debug_info = {"command": self.debug_mode.command, "password": self.debug_mode.password}
160 event.set_results(debug_info)
161
162 # ---------------------------------------------------------------------------
163 # Validation and configuration and more
164 # ---------------------------------------------------------------------------
165
166 def _patch_k8s_service(self) -> None:
167 port = ServicePort(SERVICE_PORT, name=f"{self.app.name}")
168 self.service_patcher = KubernetesServicePatch(self, [port])
169
170 def _observe_charm_events(self) -> None:
171 event_handler_mapping = {
172 # Core lifecycle events
173 self.on.ro_pebble_ready: self._on_config_changed,
174 self.on.config_changed: self._on_config_changed,
175 self.on.update_status: self._on_update_status,
176 # Relation events
177 self.on.kafka_available: self._on_config_changed,
178 self.on["kafka"].relation_broken: self._on_required_relation_broken,
179 self.on["mongodb"].relation_changed: self._on_config_changed,
180 self.on["mongodb"].relation_broken: self._on_required_relation_broken,
181 self.on.ro_relation_joined: self._update_ro_relation,
182 # Action events
183 self.on.get_debug_mode_information_action: self._on_get_debug_mode_information_action,
184 }
185
186 for event, handler in event_handler_mapping.items():
187 self.framework.observe(event, handler)
188
189 def _validate_config(self) -> None:
190 """Validate charm configuration.
191
192 Raises:
193 CharmError: if charm configuration is invalid.
194 """
195 logger.debug("validating charm config")
196 if self.config["log-level"].upper() not in [
197 "TRACE",
198 "DEBUG",
199 "INFO",
200 "WARN",
201 "ERROR",
202 "FATAL",
203 ]:
204 raise CharmError("invalid value for log-level option")
205
206 def _check_relations(self) -> None:
207 """Validate charm relations.
208
209 Raises:
210 CharmError: if charm configuration is invalid.
211 """
212 logger.debug("check for missing relations")
213 missing_relations = []
214
215 if not self.kafka.host or not self.kafka.port:
216 missing_relations.append("kafka")
217 if self.mongodb_client.is_missing_data_in_unit():
218 missing_relations.append("mongodb")
219
220 if missing_relations:
221 relations_str = ", ".join(missing_relations)
222 one_relation_missing = len(missing_relations) == 1
223 error_msg = f'need {relations_str} relation{"" if one_relation_missing else "s"}'
224 logger.warning(error_msg)
225 raise CharmError(error_msg)
226
227 def _configure_certificates(self) -> None:
228 """Push certificates to the RO container."""
229 if not (certificate_config := self.config.get("certificates")):
230 return
231
232 certificates_list = certificate_config.split(",")
233 updated_certificates = set()
234
235 for certificate in certificates_list:
236 if ":" not in certificate:
237 continue
238 name, content = certificate.split(":")
239 content = decode(content)
240 self.container.push(
241 f"/certs/{name}",
242 content,
243 permissions=0o400,
244 make_dirs=True,
245 user=USER,
246 group=GROUP,
247 )
248 updated_certificates.add(name)
249 self._stored.certificates.add(name)
250 logger.info(f"certificate {name} pushed successfully")
251
252 stored_certificates = {c for c in self._stored.certificates}
253 for certificate_to_remove in stored_certificates.difference(updated_certificates):
254 self.container.remove_path(f"/certs/{certificate_to_remove}")
255 self._stored.certificates.remove(certificate_to_remove)
256 logger.info(f"certificate {certificate_to_remove} removed successfully")
257
258 def _configure_service(self) -> None:
259 """Add Pebble layer with the ro service."""
260 logger.debug(f"configuring {self.app.name} service")
261 self.container.add_layer("ro", self._get_layer(), combine=True)
262 self.container.replan()
263
264 def _get_layer(self) -> Dict[str, Any]:
265 """Get layer for Pebble."""
266 return {
267 "summary": "ro layer",
268 "description": "pebble config layer for ro",
269 "services": {
270 "ro": {
271 "override": "replace",
272 "summary": "ro service",
273 "command": "python3 -u -m osm_ng_ro.ro_main",
274 "startup": "enabled",
275 "user": USER,
276 "group": GROUP,
277 "environment": {
278 # General configuration
279 "OSMRO_LOG_LEVEL": self.config["log-level"].upper(),
280 # Kafka configuration
281 "OSMRO_MESSAGE_HOST": self.kafka.host,
282 "OSMRO_MESSAGE_PORT": self.kafka.port,
283 "OSMRO_MESSAGE_DRIVER": "kafka",
284 # Database configuration
285 "OSMRO_DATABASE_DRIVER": "mongo",
286 "OSMRO_DATABASE_URI": self.mongodb_client.connection_string,
287 "OSMRO_DATABASE_COMMONKEY": self.config["database-commonkey"],
288 # Storage configuration
289 "OSMRO_STORAGE_DRIVER": "mongo",
290 "OSMRO_STORAGE_PATH": "/app/storage",
291 "OSMRO_STORAGE_COLLECTION": "files",
292 "OSMRO_STORAGE_URI": self.mongodb_client.connection_string,
293 },
294 }
295 },
296 }
297
298
299 if __name__ == "__main__": # pragma: no cover
300 main(OsmRoCharm)