X-Git-Url: https://osm.etsi.org/gitweb/?a=blobdiff_plain;f=installers%2Fcharm%2Fng-ui%2Fsrc%2Fcharm.py;h=4d2bb85d6b6043319cdaea26a359989fcd5db14e;hb=49379ced23b5e344a773ce77ac9cb59c1864e19b;hp=8e21bcd2fb3944d647f29986b8b76d0ce2a8cc2d;hpb=68faf8d30c3e08ca7dc1974281ade474f6f815b7;p=osm%2Fdevops.git diff --git a/installers/charm/ng-ui/src/charm.py b/installers/charm/ng-ui/src/charm.py index 8e21bcd2..4d2bb85d 100755 --- a/installers/charm/ng-ui/src/charm.py +++ b/installers/charm/ng-ui/src/charm.py @@ -1,201 +1,184 @@ #!/usr/bin/env python3 -# Copyright 2020 Canonical Ltd. +# Copyright 2021 Canonical Ltd. # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## -import sys -import logging -import base64 +# pylint: disable=E0213 -sys.path.append("lib") -from ops.charm import CharmBase -from ops.framework import StoredState, Object +import logging +from typing import Optional, NoReturn +from ipaddress import ip_network +from urllib.parse import urlparse + from ops.main import main -from ops.model import ( - ActiveStatus, - MaintenanceStatus, - BlockedStatus, - ModelError, - WaitingStatus, + +from opslib.osm.charm import CharmedOsmBase, RelationsMissing + +from opslib.osm.pod import ( + ContainerV3Builder, + PodSpecV3Builder, + FilesV3Builder, + IngressResourceV3Builder, ) -from glob import glob -from pathlib import Path -from string import Template -logger = logging.getLogger(__name__) +from opslib.osm.validator import ( + ModelValidator, + validator, +) +from opslib.osm.interfaces.http import HttpClient +from string import Template +from pathlib import Path -class NGUICharm(CharmBase): - state = StoredState() +logger = logging.getLogger(__name__) - def __init__(self, framework, key): - super().__init__(framework, key) - self.state.set_default(spec=None) - self.state.set_default(nbi_host=None) - self.state.set_default(nbi_port=None) - # Observe Charm related events - self.framework.observe(self.on.config_changed, self.on_config_changed) - self.framework.observe(self.on.start, self.on_start) - self.framework.observe(self.on.upgrade_charm, self.on_upgrade_charm) - self.framework.observe( - self.on.nbi_relation_changed, self.on_nbi_relation_changed +class ConfigModel(ModelValidator): + port: int + server_name: str + max_file_size: int + site_url: Optional[str] + ingress_whitelist_source_range: Optional[str] + tls_secret_name: Optional[str] + + @validator("port") + def validate_port(cls, v): + if v <= 0: + raise ValueError("value must be greater than 0") + return v + + @validator("max_file_size") + def validate_max_file_size(cls, v): + if v < 0: + raise ValueError("value must be equal or greater than 0") + return v + + @validator("site_url") + def validate_site_url(cls, v): + if v: + parsed = urlparse(v) + if not parsed.scheme.startswith("http"): + raise ValueError("value must start with http") + return v + + @validator("ingress_whitelist_source_range") + def validate_ingress_whitelist_source_range(cls, v): + if v: + ip_network(v) + return v + + +class NgUiCharm(CharmedOsmBase): + def __init__(self, *args) -> NoReturn: + super().__init__(*args, oci_image="image") + + self.nbi_client = HttpClient(self, "nbi") + self.framework.observe(self.on["nbi"].relation_changed, self.configure_pod) + self.framework.observe(self.on["nbi"].relation_broken, self.configure_pod) + + def _check_missing_dependencies(self, config: ConfigModel): + missing_relations = [] + + if self.nbi_client.is_missing_data_in_app(): + missing_relations.append("nbi") + + if missing_relations: + raise RelationsMissing(missing_relations) + + def _build_files(self, config: ConfigModel): + files_builder = FilesV3Builder() + files_builder.add_file( + "default", + Template(Path("files/default").read_text()).substitute( + port=config.port, + server_name=config.server_name, + max_file_size=config.max_file_size, + nbi_host=self.nbi_client.host, + nbi_port=self.nbi_client.port, + ), ) - - # SSL Certificate path - self.ssl_folder = "/certs" - self.ssl_crt_name = "ssl_certificate.crt" - self.ssl_key_name = "ssl_certificate.key" - - def _apply_spec(self): - # Only apply the spec if this unit is a leader. - unit = self.model.unit - if not unit.is_leader(): - unit.status = ActiveStatus("ready") - return - if not self.state.nbi_host or not self.state.nbi_port: - unit.status = WaitingStatus("Waiting for NBI") - return - unit.status = MaintenanceStatus("Applying new pod spec") - - new_spec = self.make_pod_spec() - if new_spec == self.state.spec: - unit.status = ActiveStatus("ready") - return - self.framework.model.pod.set_spec(new_spec) - self.state.spec = new_spec - unit.status = ActiveStatus("ready") - - def make_pod_spec(self): - config = self.framework.model.config - - config_spec = { - "http_port": config["port"], - "https_port": config["https_port"], - "server_name": config["server_name"], - "client_max_body_size": config["client_max_body_size"], - "nbi_host": self.state.nbi_host or config["nbi_host"], - "nbi_port": self.state.nbi_port or config["nbi_port"], - "ssl_crt": "", - "ssl_crt_key": "", - } - - ssl_certificate = None - ssl_certificate_key = None - ssl_enabled = False - - if "ssl_certificate" in config and "ssl_certificate_key" in config: - # Get bytes of cert and key - cert_b = base64.b64decode(config["ssl_certificate"]) - key_b = base64.b64decode(config["ssl_certificate_key"]) - # Decode key and cert - ssl_certificate = cert_b.decode("utf-8") - ssl_certificate_key = key_b.decode("utf-8") - # Get paths - cert_path = "{}/{}".format(self.ssl_folder, self.ssl_crt_name) - key_path = "{}/{}".format(self.ssl_folder, self.ssl_key_name) - - config_spec["port"] = "{} ssl".format(config["https_port"]) - config_spec["ssl_crt"] = "ssl_certificate {};".format(cert_path) - config_spec["ssl_crt_key"] = "ssl_certificate_key {};".format(key_path) - ssl_enabled = True - else: - config_spec["ssl_crt"] = "" - config_spec["ssl_crt_key"] = "" - - files = [ - { - "name": "configuration", - "mountPath": "/etc/nginx/sites-available/", - "files": { - Path(filename) - .name: Template(Path(filename).read_text()) - .substitute(config_spec) - for filename in glob("files/*") - }, + return files_builder.build() + + def build_pod_spec(self, image_info): + # Validate config + config = ConfigModel(**dict(self.config)) + # Check relations + self._check_missing_dependencies(config) + # Create Builder for the PodSpec + pod_spec_builder = PodSpecV3Builder() + # Build Container + container_builder = ContainerV3Builder(self.app.name, image_info) + container_builder.add_port(name=self.app.name, port=config.port) + container = container_builder.build() + container_builder.add_tcpsocket_readiness_probe( + config.port, + initial_delay_seconds=45, + timeout_seconds=5, + ) + container_builder.add_tcpsocket_liveness_probe( + config.port, + initial_delay_seconds=45, + timeout_seconds=15, + ) + container_builder.add_volume_config( + "configuration", + "/etc/nginx/sites-available/", + self._build_files(config), + ) + # Add container to pod spec + pod_spec_builder.add_container(container) + # Add ingress resources to pod spec if site url exists + if config.site_url: + parsed = urlparse(config.site_url) + annotations = { + "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format( + str(config.max_file_size) + "m" + if config.max_file_size > 0 + else config.max_file_size + ), } - ] - port = config["https_port"] if ssl_enabled else config["port"] - ports = [ - {"name": "port", "containerPort": port, "protocol": "TCP", }, - ] - - kubernetes = { - "readinessProbe": { - "tcpSocket": {"port": port}, - "timeoutSeconds": 5, - "periodSeconds": 5, - "initialDelaySeconds": 10, - }, - "livenessProbe": { - "tcpSocket": {"port": port}, - "timeoutSeconds": 5, - "initialDelaySeconds": 45, - }, - } - - if ssl_certificate and ssl_certificate_key: - files.append( - { - "name": "ssl", - "mountPath": self.ssl_folder, - "files": { - self.ssl_crt_name: ssl_certificate, - self.ssl_key_name: ssl_certificate_key, - }, - } + ingress_resource_builder = IngressResourceV3Builder( + f"{self.app.name}-ingress", annotations ) - spec = { - "version": 2, - "containers": [ - { - "name": self.framework.model.app.name, - "image": "{}".format(config["image"]), - "ports": ports, - "kubernetes": kubernetes, - "files": files, - } - ], - } - - return spec - - def on_config_changed(self, event): - """Handle changes in configuration""" - self._apply_spec() - - def on_start(self, event): - """Called when the charm is being installed""" - self._apply_spec() - - def on_upgrade_charm(self, event): - """Upgrade the charm.""" - unit = self.model.unit - unit.status = MaintenanceStatus("Upgrading charm") - self.on_start(event) - - def on_nbi_relation_changed(self, event): - nbi_host = event.relation.data[event.unit].get("host") - nbi_port = event.relation.data[event.unit].get("port") - if nbi_host and self.state.nbi_host != nbi_host: - self.state.nbi_host = nbi_host - if nbi_port and self.state.nbi_port != nbi_port: - self.state.nbi_port = nbi_port - self._apply_spec() + if config.ingress_whitelist_source_range: + annotations[ + "nginx.ingress.kubernetes.io/whitelist-source-range" + ] = config.ingress_whitelist_source_range + + if parsed.scheme == "https": + ingress_resource_builder.add_tls( + [parsed.hostname], config.tls_secret_name + ) + else: + annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false" + + ingress_resource_builder.add_rule( + parsed.hostname, self.app.name, config.port + ) + ingress_resource = ingress_resource_builder.build() + pod_spec_builder.add_ingress_resource(ingress_resource) + return pod_spec_builder.build() if __name__ == "__main__": - main(NGUICharm) + main(NgUiCharm)