387 lines
12 KiB
Python
387 lines
12 KiB
Python
#!/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")
|