Add juju simplestreams charm
[osm/devops.git] / installers / charm / juju-simplestreams-operator / 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 """Juju simpletreams charm."""
26
27 import logging
28 import subprocess
29 from dataclasses import dataclass
30 from pathlib import Path
31 from typing import Any, Dict
32
33 from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
34 from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
35 from charms.osm_libs.v0.utils import (
36 CharmError,
37 check_container_ready,
38 check_service_active,
39 )
40 from lightkube.models.core_v1 import ServicePort
41 from ops.charm import ActionEvent, CharmBase
42 from ops.main import main
43 from ops.model import ActiveStatus, Container
44
45 SERVICE_PORT = 8080
46
47 logger = logging.getLogger(__name__)
48 container_name = "server"
49
50
51 @dataclass
52 class ImageMetadata:
53 """Image Metadata."""
54
55 region: str
56 auth_url: str
57 image_id: str
58 series: str
59
60
61 class JujuSimplestreamsCharm(CharmBase):
62 """Simplestreams Kubernetes sidecar charm."""
63
64 def __init__(self, *args):
65 super().__init__(*args)
66 self.ingress = IngressRequires(
67 self,
68 {
69 "service-hostname": self.external_hostname,
70 "service-name": self.app.name,
71 "service-port": SERVICE_PORT,
72 },
73 )
74 event_handler_mapping = {
75 # Core lifecycle events
76 self.on["server"].pebble_ready: self._on_server_pebble_ready,
77 self.on.update_status: self._on_update_status,
78 self.on["peer"].relation_changed: self._push_image_metadata_from_relation,
79 # Action events
80 self.on["add-image-metadata"].action: self._on_add_image_metadata_action,
81 }
82
83 for event, handler in event_handler_mapping.items():
84 self.framework.observe(event, handler)
85
86 port = ServicePort(SERVICE_PORT, name=f"{self.app.name}")
87 self.service_patcher = KubernetesServicePatch(self, [port])
88 self.container: Container = self.unit.get_container(container_name)
89 self.unit.set_workload_version(self.unit.name)
90
91 @property
92 def external_hostname(self) -> str:
93 """External hostname property.
94
95 Returns:
96 str: the external hostname from config.
97 If not set, return the ClusterIP service name.
98 """
99 return self.config.get("external-hostname") or self.app.name
100
101 # ---------------------------------------------------------------------------
102 # Handlers for Charm Events
103 # ---------------------------------------------------------------------------
104
105 def _on_server_pebble_ready(self, _) -> None:
106 """Handler for the config-changed event."""
107 try:
108 self._push_configuration()
109 self._configure_service()
110 self._push_image_metadata_from_relation()
111 # Update charm status
112 self._on_update_status()
113 except CharmError as e:
114 logger.debug(e.message)
115 self.unit.status = e.status
116
117 def _on_update_status(self, _=None) -> None:
118 """Handler for the update-status event."""
119 try:
120 check_container_ready(self.container)
121 check_service_active(self.container, container_name)
122 self.unit.status = ActiveStatus()
123 except CharmError as e:
124 logger.debug(e.message)
125 self.unit.status = e.status
126
127 def _push_image_metadata_from_relation(self, _=None):
128 subprocess.run(["rm", "-rf", "/tmp/simplestreams"])
129 subprocess.run(["mkdir", "-p", "/tmp/simplestreams"])
130 image_metadata_dict = self._get_image_metadata_from_relation()
131 for image_metadata in image_metadata_dict.values():
132 subprocess.run(
133 [
134 "files/juju-metadata",
135 "generate-image",
136 "-d",
137 "/tmp/simplestreams",
138 "-i",
139 image_metadata.image_id,
140 "-s",
141 image_metadata.series,
142 "-r",
143 image_metadata.region,
144 "-u",
145 image_metadata.auth_url,
146 ]
147 )
148 subprocess.run(["chmod", "555", "-R", "/tmp/simplestreams"])
149 self.container.push_path("/tmp/simplestreams", "/app/static")
150
151 def _on_add_image_metadata_action(self, event: ActionEvent):
152 relation = self.model.get_relation("peer")
153 try:
154 if not relation:
155 raise Exception("charm has not been fully initialized. Try again later.")
156 if not self.unit.is_leader():
157 raise Exception("I am not the leader!")
158 if any(
159 prohibited_char in param_value
160 for prohibited_char in ",; "
161 for param_value in event.params.values()
162 ):
163 event.fail("invalid params")
164 return
165
166 image_metadata_dict = self._get_image_metadata_from_relation()
167
168 new_image_metadata = ImageMetadata(
169 region=event.params["region"],
170 auth_url=event.params["auth-url"],
171 image_id=event.params["image-id"],
172 series=event.params["series"],
173 )
174
175 image_metadata_dict[event.params["image-id"]] = new_image_metadata
176
177 new_relation_data = []
178 for image_metadata in image_metadata_dict.values():
179 new_relation_data.append(
180 f"{image_metadata.image_id};{image_metadata.series};{image_metadata.region};{image_metadata.auth_url}"
181 )
182 relation.data[self.app]["data"] = ",".join(new_relation_data)
183 except Exception as e:
184 event.fail(f"Action failed: {e}")
185 logger.error(f"Action failed: {e}")
186
187 # ---------------------------------------------------------------------------
188 # Validation and configuration and more
189 # ---------------------------------------------------------------------------
190
191 def _get_image_metadata_from_relation(self) -> Dict[str, ImageMetadata]:
192 if not (relation := self.model.get_relation("peer")):
193 return {}
194
195 image_metadata_dict: Dict[str, ImageMetadata] = {}
196
197 relation_data = relation.data[self.app].get("data", "")
198 if relation_data:
199 for image_metadata_string in relation_data.split(","):
200 image_id, series, region, auth_url = image_metadata_string.split(";")
201 image_metadata_dict[image_id] = ImageMetadata(
202 region=region,
203 auth_url=auth_url,
204 image_id=image_id,
205 series=series,
206 )
207
208 return image_metadata_dict
209
210 def _configure_service(self) -> None:
211 """Add Pebble layer with the ro service."""
212 logger.debug(f"configuring {self.app.name} service")
213 self.container.add_layer(container_name, self._get_layer(), combine=True)
214 self.container.replan()
215
216 def _push_configuration(self) -> None:
217 """Push nginx configuration to the container."""
218 self.container.push("/etc/nginx/nginx.conf", Path("files/nginx.conf").read_text())
219 self.container.make_dir("/app/static", make_parents=True)
220
221 def _update_ingress_config(self) -> None:
222 """Update ingress config in relation."""
223 ingress_config = {
224 "service-hostname": self.external_hostname,
225 "max-body-size": self.config["max-body-size"],
226 }
227 if "tls-secret-name" in self.config:
228 ingress_config["tls-secret-name"] = self.config["tls-secret-name"]
229 logger.debug(f"updating ingress-config: {ingress_config}")
230 self.ingress.update_config(ingress_config)
231
232 def _get_layer(self) -> Dict[str, Any]:
233 """Get layer for Pebble."""
234 return {
235 "summary": "server layer",
236 "description": "pebble config layer for server",
237 "services": {
238 container_name: {
239 "override": "replace",
240 "summary": "server service",
241 "command": 'nginx -g "daemon off;"',
242 "startup": "enabled",
243 }
244 },
245 }
246
247
248 if __name__ == "__main__": # pragma: no cover
249 main(JujuSimplestreamsCharm)