bunkerweb/autoconf/IngressController.py
2022-11-04 18:14:44 +01:00

292 lines
12 KiB
Python

from os import getenv
from traceback import format_exc
from kubernetes import client, config, watch
from kubernetes.client.exceptions import ApiException
from threading import Thread, Lock
from sys import exit as sys_exit
from Controller import Controller
from ConfigCaller import ConfigCaller
from logger import setup_logger
class IngressController(Controller, ConfigCaller):
def __init__(self):
Controller.__init__(self, "kubernetes")
ConfigCaller.__init__(self)
config.load_incluster_config()
self.__corev1 = client.CoreV1Api()
self.__networkingv1 = client.NetworkingV1Api()
self.__internal_lock = Lock()
self.__logger = setup_logger("Ingress-controller", getenv("LOG_LEVEL", "INFO"))
def _get_controller_instances(self):
controller_instances = []
for pod in self.__corev1.list_pod_for_all_namespaces(watch=False).items:
if (
pod.metadata.annotations != None
and "bunkerweb.io/INSTANCE" in pod.metadata.annotations
):
controller_instances.append(pod)
return controller_instances
def _to_instances(self, controller_instance):
instance = {}
instance["name"] = controller_instance.metadata.name
instance["hostname"] = controller_instance.status.pod_ip
health = False
if controller_instance.status.conditions is not None:
for condition in controller_instance.status.conditions:
if condition.type == "Ready" and condition.status == "True":
health = True
break
instance["health"] = health
instance["env"] = {}
for env in controller_instance.spec.containers[0].env:
if env.value is not None:
instance["env"][env.name] = env.value
else:
instance["env"][env.name] = ""
for controller_service in self._get_controller_services():
if controller_service.metadata.annotations is not None:
for (
annotation,
value,
) in controller_service.metadata.annotations.items():
if not annotation.startswith("bunkerweb.io/"):
continue
variable = annotation.replace("bunkerweb.io/", "", 1)
if self._is_setting(variable):
instance["env"][variable] = value
return [instance]
def _get_controller_services(self):
return self.__networkingv1.list_ingress_for_all_namespaces(watch=False).items
def _to_services(self, controller_service):
if controller_service.spec is None or controller_service.spec.rules is None:
return []
services = []
# parse rules
for rule in controller_service.spec.rules:
if rule.host is None:
self.__logger.warning(
"Ignoring unsupported ingress rule without host.",
)
continue
service = {}
service["SERVER_NAME"] = rule.host
if rule.http is None:
services.append(service)
continue
location = 1
for path in rule.http.paths:
if path.path is None:
self.__logger.warning(
"Ignoring unsupported ingress rule without path.",
)
continue
if path.backend.service is None:
self.__logger.warning(
"Ignoring unsupported ingress rule without backend service.",
)
continue
if path.backend.service.port is None:
self.__logger.warning(
"Ignoring unsupported ingress rule without backend service port.",
)
continue
if path.backend.service.port.number is None:
self.__logger.warning(
"Ignoring unsupported ingress rule without backend service port number.",
)
continue
service_list = self.__corev1.list_service_for_all_namespaces(
watch=False,
field_selector=f"metadata.name={path.backend.service.name}",
).items
if len(service_list) == 0:
self.__logger.warning(
f"Ignoring ingress rule with service {path.backend.service.name} : service not found.",
)
continue
reverse_proxy_host = f"http://{path.backend.service.name}.{service_list[0].metadata.namespace}.svc.cluster.local:{path.backend.service.port.number}"
service["USE_REVERSE_PROXY"] = "yes"
service[f"REVERSE_PROXY_HOST_{location}"] = reverse_proxy_host
service[f"REVERSE_PROXY_URL_{location}"] = path.path
location += 1
services.append(service)
# parse tls
if controller_service.spec.tls is not None:
self.__logger.warning("Ignoring unsupported tls.")
# parse annotations
if controller_service.metadata.annotations is not None:
for service in services:
for (
annotation,
value,
) in controller_service.metadata.annotations.items():
if not annotation.startswith("bunkerweb.io/"):
continue
variable = annotation.replace("bunkerweb.io/", "", 1)
if not variable.startswith(
f"{service['SERVER_NAME'].split(' ')[0]}_"
):
continue
variable = variable.replace(
f"{service['SERVER_NAME'].split(' ')[0]}_", "", 1
)
if self._is_multisite_setting(variable):
service[variable] = value
return services
def _get_static_services(self):
services = []
variables = {}
for instance in self.__corev1.list_pod_for_all_namespaces(watch=False).items:
if (
instance.metadata.annotations is None
or not "bunkerweb.io/INSTANCE" in instance.metadata.annotations
):
continue
for env in instance.spec.containers[0].env:
if env.value is None:
variables[env.name] = ""
else:
variables[env.name] = env.value
server_names = []
if "SERVER_NAME" in variables and variables["SERVER_NAME"] != "":
server_names = variables["SERVER_NAME"].split(" ")
for server_name in server_names:
service = {}
service["SERVER_NAME"] = server_name
for variable, value in variables.items():
prefix = variable.split("_")[0]
real_variable = variable.replace(f"{prefix}_", "", 1)
if prefix == server_name and self._is_multisite_setting(real_variable):
service[real_variable] = value
services.append(service)
return services
def get_configs(self):
configs = {}
supported_config_types = [
"http",
"stream",
"server-http",
"server-stream",
"default-server-http",
"modsec",
"modsec-crs",
]
for config_type in supported_config_types:
configs[config_type] = {}
for configmap in self.__corev1.list_config_map_for_all_namespaces(
watch=False
).items:
if (
configmap.metadata.annotations is None
or "bunkerweb.io/CONFIG_TYPE" not in configmap.metadata.annotations
):
continue
config_type = configmap.metadata.annotations["bunkerweb.io/CONFIG_TYPE"]
if config_type not in supported_config_types:
self.__logger.warning(
f"Ignoring unsupported CONFIG_TYPE {config_type} for ConfigMap {configmap.metadata.name}",
)
continue
if not configmap.data:
self.__logger.warning(
f"Ignoring blank ConfigMap {configmap.metadata.name}",
)
continue
config_site = ""
if "bunkerweb.io/CONFIG_SITE" in configmap.metadata.annotations:
config_site = (
f"{configmap.metadata.annotations['bunkerweb.io/CONFIG_SITE']}/"
)
for config_name, config_data in configmap.data.items():
configs[config_type][f"{config_site}{config_name}"] = config_data
return configs
def __watch(self, watch_type):
w = watch.Watch()
what = None
if watch_type == "pod":
what = self.__corev1.list_pod_for_all_namespaces
elif watch_type == "ingress":
what = self.__networkingv1.list_ingress_for_all_namespaces
elif watch_type == "configmap":
what = self.__corev1.list_config_map_for_all_namespaces
else:
raise Exception(f"unsupported watch_type {watch_type}")
while True:
locked = False
try:
for event in w.stream(what):
self.__internal_lock.acquire()
locked = True
self._instances = self.get_instances()
self._services = self.get_services()
self._configs = self.get_configs()
if not self._config.update_needed(
self._instances, self._services, configs=self._configs
):
self.__internal_lock.release()
locked = False
continue
self.__logger.info(
"Catched kubernetes event, deploying new configuration ...",
)
try:
ret = self.apply_config()
if not ret:
self.__logger.error(
"Error while deploying new configuration ...",
)
else:
self.__logger.info(
"Successfully deployed new configuration 🚀",
)
if not self._config._db.is_autoconf_loaded():
ret = self._config._db.set_autoconf_load(True)
if ret:
self.__logger.error(
f"Can't set autoconf loaded metadata to true in database: {ret}",
)
except:
self.__logger.error(
f"Exception while deploying new configuration :\n{format_exc()}",
)
self.__internal_lock.release()
locked = False
except ApiException as e:
if e.status != 410:
self.__logger.error(
f"Exception while reading k8s event (type = {watch_type}) :\n{format_exc()}",
)
sys_exit(1)
if locked:
self.__internal_lock.release()
except:
self.__logger.error(
f"Unknown exception while reading k8s event (type = {watch_type}) :\n{format_exc()}",
)
sys_exit(2)
def apply_config(self):
ret = self._config.apply(self._instances, self._services, configs=self._configs)
return ret
def process_events(self):
watch_types = ["pod", "ingress", "configmap"]
threads = []
for watch_type in watch_types:
threads.append(Thread(target=self.__watch, args=(watch_type,)))
for thread in threads:
thread.start()
for thread in threads:
thread.join()