blob: 555aab006d3e1d7e8888eb4f096ef6e57d6bd17c [file] [log] [blame]
David Garcia516eb472022-06-30 14:37:46 +02001#!/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
27import logging
28import subprocess
29from dataclasses import dataclass
30from pathlib import Path
31from typing import Any, Dict
32
33from charms.nginx_ingress_integrator.v0.ingress import IngressRequires
34from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
35from charms.osm_libs.v0.utils import (
36 CharmError,
37 check_container_ready,
38 check_service_active,
39)
40from lightkube.models.core_v1 import ServicePort
41from ops.charm import ActionEvent, CharmBase
42from ops.main import main
43from ops.model import ActiveStatus, Container
44
45SERVICE_PORT = 8080
46
47logger = logging.getLogger(__name__)
48container_name = "server"
49
50
51@dataclass
52class ImageMetadata:
53 """Image Metadata."""
54
55 region: str
56 auth_url: str
57 image_id: str
58 series: str
59
60
61class 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
248if __name__ == "__main__": # pragma: no cover
249 main(JujuSimplestreamsCharm)