2 # Copyright 2022 Canonical Ltd.
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
8 # http://www.apache.org/licenses/LICENSE-2.0
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
16 # For those usages not covered by the Apache License, Version 2.0 please
17 # contact: legal@canonical.com
19 # To get in touch with the maintainers, please contact:
20 # osm-charmers@lists.launchpad.net
23 # Learn more at: https://juju.is/docs/sdk
25 """Juju simpletreams charm."""
29 from dataclasses
import dataclass
30 from pathlib
import Path
31 from typing
import Any
, Dict
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 (
37 check_container_ready
,
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
47 logger
= logging
.getLogger(__name__
)
48 container_name
= "server"
61 class JujuSimplestreamsCharm(CharmBase
):
62 """Simplestreams Kubernetes sidecar charm."""
64 def __init__(self
, *args
):
65 super().__init
__(*args
)
66 self
.ingress
= IngressRequires(
69 "service-hostname": self
.external_hostname
,
70 "service-name": self
.app
.name
,
71 "service-port": SERVICE_PORT
,
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
,
80 self
.on
["add-image-metadata"].action
: self
._on
_add
_image
_metadata
_action
,
83 for event
, handler
in event_handler_mapping
.items():
84 self
.framework
.observe(event
, handler
)
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
)
92 def external_hostname(self
) -> str:
93 """External hostname property.
96 str: the external hostname from config.
97 If not set, return the ClusterIP service name.
99 return self
.config
.get("external-hostname") or self
.app
.name
101 # ---------------------------------------------------------------------------
102 # Handlers for Charm Events
103 # ---------------------------------------------------------------------------
105 def _on_server_pebble_ready(self
, _
) -> None:
106 """Handler for the config-changed event."""
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
117 def _on_update_status(self
, _
=None) -> None:
118 """Handler for the update-status event."""
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
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():
134 "files/juju-metadata",
137 "/tmp/simplestreams",
139 image_metadata
.image_id
,
141 image_metadata
.series
,
143 image_metadata
.region
,
145 image_metadata
.auth_url
,
148 subprocess
.run(["chmod", "555", "-R", "/tmp/simplestreams"])
149 self
.container
.push_path("/tmp/simplestreams", "/app/static")
151 def _on_add_image_metadata_action(self
, event
: ActionEvent
):
152 relation
= self
.model
.get_relation("peer")
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!")
159 prohibited_char
in param_value
160 for prohibited_char
in ",; "
161 for param_value
in event
.params
.values()
163 event
.fail("invalid params")
166 image_metadata_dict
= self
._get
_image
_metadata
_from
_relation
()
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"],
175 image_metadata_dict
[event
.params
["image-id"]] = new_image_metadata
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}"
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}")
187 # ---------------------------------------------------------------------------
188 # Validation and configuration and more
189 # ---------------------------------------------------------------------------
191 def _get_image_metadata_from_relation(self
) -> Dict
[str, ImageMetadata
]:
192 if not (relation
:= self
.model
.get_relation("peer")):
195 image_metadata_dict
: Dict
[str, ImageMetadata
] = {}
197 relation_data
= relation
.data
[self
.app
].get("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(
208 return image_metadata_dict
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()
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)
221 def _update_ingress_config(self
) -> None:
222 """Update ingress config in relation."""
224 "service-hostname": self
.external_hostname
,
225 "max-body-size": self
.config
["max-body-size"],
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
)
232 def _get_layer(self
) -> Dict
[str, Any
]:
233 """Get layer for Pebble."""
235 "summary": "server layer",
236 "description": "pebble config layer for server",
239 "override": "replace",
240 "summary": "server service",
241 "command": 'nginx -g "daemon off;"',
242 "startup": "enabled",
248 if __name__
== "__main__": # pragma: no cover
249 main(JujuSimplestreamsCharm
)