db97cd06ecaffc7238880447398ac9281f581493
[osm/devops.git] / installers / charm / osm-temporal-ui / 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 Temporal charm.
26
27 See more: https://charmhub.io/osm
28 """
29
30 import logging
31 import os
32 import socket
33
34 from log import log_event_handler
35 from typing import Any, Dict
36
37 from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
38 from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
39 from charms.osm_libs.v0.utils import (
40 CharmError,
41 check_container_ready,
42 check_service_active,
43 )
44 from charms.osm_temporal.v0.temporal import TemporalRequires
45 from lightkube.models.core_v1 import ServicePort
46 from ops.charm import CharmBase
47 from ops.framework import StoredState
48 from ops.main import main
49 from ops.model import ActiveStatus, Container
50
51 logger = logging.getLogger(__name__)
52 SERVICE_PORT = 8080
53
54
55 class OsmTemporalUICharm(CharmBase):
56 """OSM Temporal Kubernetes sidecar charm."""
57
58 _stored = StoredState()
59 container_name = "temporal-ui"
60 service_name = "temporal-ui"
61
62 def __init__(self, *args):
63 super().__init__(*args)
64
65 self.ingress = IngressRequires(
66 self,
67 {
68 "service-hostname": self.external_hostname,
69 "service-name": self.app.name,
70 "service-port": SERVICE_PORT,
71 },
72 )
73 logger.info(f"Ingress = f{self.ingress}")
74 self._observe_charm_events()
75 self.container: Container = self.unit.get_container(self.container_name)
76 self._stored.set_default(leader_ip="")
77 self._stored.set_default(unit_ip="")
78 self.temporal = TemporalRequires(self)
79 self._patch_k8s_service()
80
81 @property
82 def external_hostname(self) -> str:
83 """External hostname property.
84
85 Returns:
86 str: the external hostname from config.
87 If not set, return the ClusterIP service name.
88 """
89 return self.config.get("external-hostname") or self.app.name
90
91 # ---------------------------------------------------------------------------
92 # Handlers for Charm Events
93 # ---------------------------------------------------------------------------
94
95 @log_event_handler(logger)
96 def _on_config_changed(self, event) -> None:
97 """Handler for the config-changed event."""
98 try:
99 self._validate_config()
100 self._check_relations()
101
102 # Check if the container is ready.
103 # Eventually it will become ready after the first pebble-ready event.
104 check_container_ready(self.container)
105 self._configure_service(self.container)
106 self._update_ingress_config()
107 # Update charm status
108 self._on_update_status(event)
109 except CharmError as e:
110 logger.error(e.message)
111 self.unit.status = e.status
112
113 @log_event_handler(logger)
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_service_active(self.container, self.service_name)
120 self.unit.status = ActiveStatus()
121 except CharmError as e:
122 logger.error(e.message)
123 self.unit.status = e.status
124
125 @log_event_handler(logger)
126 def _on_required_relation_broken(self, event) -> None:
127 """Handler for the kafka-broken event."""
128 # Check Pebble has started in the container
129 try:
130 check_container_ready(self.container)
131 check_service_active(self.container, self.service_name)
132 self.container.stop(self.container_name)
133 except CharmError:
134 pass
135 self._on_update_status(event)
136
137 # ---------------------------------------------------------------------------
138 # Validation and configuration and more
139 # ---------------------------------------------------------------------------
140
141 def _patch_k8s_service(self) -> None:
142 port = ServicePort(SERVICE_PORT, name=f"{self.app.name}")
143 self.service_patcher = KubernetesServicePatch(self, [port])
144
145 def _observe_charm_events(self) -> None:
146 event_handler_mapping = {
147 # Core lifecycle events
148 self.on.temporal_ui_pebble_ready: self._on_config_changed,
149 self.on.config_changed: self._on_config_changed,
150 self.on.update_status: self._on_update_status,
151 }
152
153 # Relation events
154 for relation in [self.on[rel_name] for rel_name in ["temporal"]]:
155 event_handler_mapping[relation.relation_changed] = self._on_config_changed
156 event_handler_mapping[relation.relation_broken] = self._on_required_relation_broken
157
158 for event, handler in event_handler_mapping.items():
159 self.framework.observe(event, handler)
160
161 def _validate_config(self) -> None:
162 """Validate charm configuration.
163
164 Raises:
165 CharmError: if charm configuration is invalid.
166 """
167 logger.debug("validating charm config")
168
169 def _check_relations(self) -> None:
170 """Validate charm relations.
171
172 Raises:
173 CharmError: if charm configuration is invalid.
174 """
175 logger.debug("check for missing relations")
176 missing_relations = []
177
178 if not self.temporal.host or not self.temporal.port:
179 missing_relations.append("temporal")
180
181 if missing_relations:
182 relations_str = ", ".join(missing_relations)
183 one_relation_missing = len(missing_relations) == 1
184 error_msg = f'need {relations_str} relation{"" if one_relation_missing else "s"}'
185 logger.warning(error_msg)
186 raise CharmError(error_msg)
187
188 def _update_ingress_config(self) -> None:
189 """Update ingress config in relation."""
190 ingress_config = {
191 "service-hostname": self.external_hostname,
192 }
193 if "tls-secret-name" in self.config:
194 ingress_config["tls-secret-name"] = self.config["tls-secret-name"]
195 logger.debug(f"updating ingress-config: {ingress_config}")
196 self.ingress.update_config(ingress_config)
197
198 def _configure_service(self, container: Container) -> None:
199 """Add Pebble layer with the temporal service."""
200 logger.debug(f"configuring {self.app.name} service")
201 logger.info(f"{self._get_layer()}")
202 container.add_layer("temporal-ui", self._get_layer(), combine=True)
203 container.replan()
204
205 def _get_layer(self) -> Dict[str, Any]:
206 """Get layer for Pebble."""
207 return {
208 "summary": "Temporal layer",
209 "description": "pebble config layer for Temporal",
210 "services": {
211 self.service_name: {
212 "override": "replace",
213 "summary": "temporal web ui service",
214 "command": "/home/ui-server/start-ui-server.sh",
215 "startup": "enabled",
216 "user": "temporal",
217 "group": "temporal",
218 "ports": [8080,],
219 "environment": {
220 "TEMPORAL_ADDRESS": self.temporal.host + ":" +
221 str(self.temporal.port),
222 "TEMPORAL_CORS_ORIGINS": "http://localhost:3000",
223 },
224 },
225 },
226 }
227
228
229 if __name__ == "__main__": # pragma: no cover
230 main(OsmTemporalUICharm)