#!/usr/bin/env python # Copyright 2015 The Kubernetes Authors. # # 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 # # 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. import itertools import os import socket import subprocess from pathlib import Path from charms.reactive import when, when_any, when_not from charms.reactive import set_flag, is_state from charms.reactive import hook from charms.reactive import clear_flag, endpoint_from_flag, endpoint_from_name from charmhelpers.core import hookenv from charmhelpers.core import host from charmhelpers.contrib.charmsupport import nrpe from charms.layer import nginx from charms.layer import tls_client from charms.layer import status from charms.layer import kubernetes_common from charms.layer.hacluster import add_service_to_hacluster from charms.layer.hacluster import remove_service_from_hacluster from subprocess import Popen from subprocess import PIPE from subprocess import STDOUT from subprocess import CalledProcessError from typing import List apilb_nginx = """/var/log/nginx.*.log { daily missingok rotate 14 compress delaycompress notifempty create 0640 www-data adm sharedscripts prerotate if [ -d /etc/logrotate.d/httpd-prerotate ]; then \\ run-parts /etc/logrotate.d/httpd-prerotate; \\ fi \\ endscript postrotate invoke-rc.d nginx rotate >/dev/null 2>&1 endscript }""" cert_dir = Path("/srv/kubernetes/") server_crt_path = cert_dir / "server.crt" server_key_path = cert_dir / "server.key" def _nrpe_external(flagname: str) -> str: # wokeignore:rule=master return f"nrpe-external-master.{flagname}" @when("certificates.available") def request_server_certificates(): """Send the data that is required to create a server certificate for this server.""" # Use the public ip of this unit as the Common Name for the certificate. common_name = hookenv.unit_public_ip() bind_ips = kubernetes_common.get_bind_addrs(ipv4=True, ipv6=True) # Create SANs that the tls layer will add to the server cert. sans = [ # The CN field is checked as a hostname, so if it's an IP, it # won't match unless also included in the SANs as an IP field. common_name, kubernetes_common.get_ingress_address("website"), socket.gethostname(), socket.getfqdn(), ] + bind_ips forced_lb_ips = hookenv.config("loadbalancer-ips").split() if forced_lb_ips: sans.extend(forced_lb_ips) else: hacluster = endpoint_from_flag("ha.connected") if hacluster: vips = hookenv.config("ha-cluster-vip").split() dns_record = hookenv.config("ha-cluster-dns") if vips: sans.extend(vips) elif dns_record: sans.append(dns_record) # maybe they have extra names they want as SANs extra_sans = hookenv.config("extra_sans") if extra_sans and not extra_sans == "": sans.extend(extra_sans.split()) # Request a server cert with this information. tls_client.request_server_cert( common_name, sorted(set(sans)), crt_path=server_crt_path, key_path=server_key_path, ) @when("certificates.server.cert.available", "nginx.available") @when_any("tls_client.certs.changed", "tls_client.ca.written") def kick_nginx(tls): # certificate changed, so sighup nginx hookenv.log("Certificate information changed, sending SIGHUP to nginx") host.service_restart("nginx") clear_flag("tls_client.certs.changed") clear_flag("tls_client.ca.written") @when("config.changed.port") def close_old_port(): config = hookenv.config() old_port = config.previous("port") if not old_port: return try: hookenv.close_port(old_port) except CalledProcessError: hookenv.log("Port %d already closed, skipping." % old_port) def maybe_write_apilb_logrotate_config(): filename = "/etc/logrotate.d/apilb_nginx" if not os.path.exists(filename): # Set log rotation for apilb log file with open(filename, "w+") as fp: fp.write(apilb_nginx) def allow_lb_consumers_to_read_requests(): lb_consumers = endpoint_from_name("lb-consumers") lb_consumers.follower_perms(read=True) return lb_consumers @when("nginx.available", "tls_client.certs.saved") @when_any("endpoint.lb-consumers.joined", "apiserver.available") @when_not("upgrade.series.in-progress") def install_load_balancer(): """Create the default vhost template for load balancing""" apiserver = endpoint_from_name("apiserver") lb_consumers = allow_lb_consumers_to_read_requests() if not (server_crt_path.exists() and server_key_path.exists()): hookenv.log("Skipping due to missing cert") return if not (apiserver.services() or lb_consumers.all_requests): hookenv.log("Skipping due to requests not ready") return # At this point the cert and key exist, and they are owned by root. chown = ["chown", "www-data:www-data", str(server_crt_path)] # Change the owner to www-data so the nginx process can read the cert. subprocess.call(chown) chown = ["chown", "www-data:www-data", str(server_key_path)] # Change the owner to www-data so the nginx process can read the key. subprocess.call(chown) servers = {} if apiserver and apiserver.services(): servers[hookenv.config("port")] = { (h["hostname"], h["port"]) for service in apiserver.services() for h in service["hosts"] } for request in lb_consumers.all_requests: for server_port in request.port_mapping.keys(): service = servers.setdefault(server_port, set()) service.update( (backend, backend_port) for backend, backend_port in itertools.product( request.backends, request.port_mapping.values() ) ) nginx.configure_site( "apilb", "apilb.conf", servers=servers, server_certificate=str(server_crt_path), server_key=str(server_key_path), proxy_read_timeout=hookenv.config("proxy_read_timeout"), ) maybe_write_apilb_logrotate_config() for listen_port in servers.keys(): hookenv.open_port(listen_port) status.active("Loadbalancer ready.") @hook("upgrade-charm") def upgrade_charm(): if is_state("certificates.available") and is_state("website.available"): request_server_certificates() maybe_write_apilb_logrotate_config() @hook("pre-series-upgrade") def pre_series_upgrade(): host.service_pause("nginx") status.blocked("Series upgrade in progress") @hook("post-series-upgrade") def post_series_upgrade(): host.service_resume("nginx") @when("nginx.available") def set_nginx_version(): """Surface the currently deployed version of nginx to Juju""" cmd = "nginx -v" p = Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=STDOUT, close_fds=True) raw = p.stdout.read() # The version comes back as: # nginx version: nginx/1.10.0 (Ubuntu) version = raw.split(b"/")[-1].split(b" ")[0] hookenv.application_version_set(version.rstrip()) def _get_lb_addresses() -> List[str]: forced_lb_ips = hookenv.config("loadbalancer-ips").split() if forced_lb_ips: return forced_lb_ips if endpoint_from_flag("ha.connected"): # in the hacluster world, we dump the vip or the dns # on every unit's data. This is because the # kubernetes-control-plane charm just grabs the first # one it sees and uses that ip/dns. vips = hookenv.config("ha-cluster-vip").split() if vips: return vips dns_records = hookenv.config("ha-cluster-dns").split() if dns_records: return dns_records return [] def _get_lb_port(prefer_private=True): lb_consumers = endpoint_from_name("lb-consumers") # prefer a port from the newer, more explicit relations public = filter(lambda r: r.public, lb_consumers.all_requests) private = filter(lambda r: not r.public, lb_consumers.all_requests) lb_reqs = (private, public) if prefer_private else (public, private) for lb_req in itertools.chain(*lb_reqs): return list(lb_req.port_mapping)[0] # fall back to the config return hookenv.config("port") @when("endpoint.lb-consumers.joined", "leadership.is_leader") def provide_lb_consumers(): """Respond to any LB requests via the lb-consumers relation. This is used in favor for the more complex two relation setup using the website and loadbalancer relations going forward. """ lb_consumers = endpoint_from_name("lb-consumers") lb_addresses = _get_lb_addresses() for request in lb_consumers.all_requests: response = request.response if request.protocol not in ( request.protocols.tcp, request.protocols.http, request.protocols.https, ): response.error_type = response.error_types.unsupported response.error_fields = { "protocol": "Protocol must be one of: tcp, http, https" } lb_consumers.send_response(request) continue if lb_addresses: private_address = lb_addresses[0] public_address = lb_addresses[0] else: network_info = hookenv.network_get("lb-consumers", str(request.relation.id)) private_address = network_info["ingress-addresses"][0] public_address = hookenv.unit_get("public-address") if request.public: response.address = public_address else: response.address = private_address lb_consumers.send_response(request) @when("website.available") def provide_application_details(): """re-use the nginx layer website relation to relay the hostname/port to any consuming kubernetes-workers, or other units that require the kubernetes API""" website = endpoint_from_flag("website.available") lb_addresses = _get_lb_addresses() lb_port = _get_lb_port(prefer_private=True) if lb_addresses: website.configure( port=lb_port, private_address=lb_addresses[0], hostname=lb_addresses[0] ) else: website.configure(port=lb_port) @when("loadbalancer.available") def provide_loadbalancing(): """Send the public address and port to the public-address interface, so the subordinates can get the public address of this loadbalancer.""" loadbalancer = endpoint_from_flag("loadbalancer.available") lb_addresses = _get_lb_addresses() lb_port = _get_lb_port(prefer_private=False) if not lb_addresses: lb_addresses = [hookenv.unit_get("public-address")] loadbalancer.set_address_port(lb_addresses[0], lb_port) @when(_nrpe_external("available")) @when_not(_nrpe_external("initial-config")) def initial_nrpe_config(nagios=None): set_flag(_nrpe_external("initial-config")) update_nrpe_config(nagios) @when("nginx.available") @when(_nrpe_external("available")) @when_any("config.changed.nagios_context", "config.changed.nagios_servicegroups") def update_nrpe_config(unused=None): services = ("nginx",) hostname = nrpe.get_nagios_hostname() current_unit = nrpe.get_nagios_unit_name() nrpe_setup = nrpe.NRPE(hostname=hostname) nrpe.add_init_service_checks(nrpe_setup, services, current_unit) nrpe_setup.write() @when_not(_nrpe_external("available")) @when(_nrpe_external("initial-config")) def remove_nrpe_config(nagios=None): clear_flag(_nrpe_external("initial-config")) # List of systemd services for which the checks will be removed services = ("nginx",) # use the charm-helpers code for now. hostname = nrpe.get_nagios_hostname() nrpe_setup = nrpe.NRPE(hostname=hostname) for service in services: nrpe_setup.remove_check(shortname=service) @when("nginx.available", "ha.connected") def configure_hacluster(): add_service_to_hacluster("nginx", "nginx") set_flag("hacluster-configured") @when_not("ha.connected") @when("hacluster-configured") def remove_hacluster(): remove_service_from_hacluster("nginx", "nginx") clear_flag("hacluster-configured")