1
0
Fork 0

Add Certbot and HAProxy management modules, container environment parsing, and plugin management framework

- Introduced `certbot.py` for ACME certificate management, including certificate issuance, renewal, and environment validation.
- Added `haproxy.py` for HAProxy process management, configuration validation, and dynamic command handling.
- Implemented `container_env.py` for reading and parsing environment variables with YAML overrides.
- Created `consts.py` for centralized management of static and dynamic paths.
- Added `manager.py` to enable plugin discovery, initialization, configuration, and execution.
- Refactored logging and configuration handling across modules for consistency.

Enhances modularity and scalability by introducing feature-dedicated modules and structured configuration handling.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-18 12:07:15 -05:00
parent 48f6e1f783
commit d894778ddc
23 changed files with 2255 additions and 2241 deletions

View file

@ -1,758 +1,7 @@
import base64
import socket
from typing import Final
from .docker import Docker
from .interface import ProcessorInterface
from .kubernetes import Kubernetes
from .static import Static
from .swarm import Swarm
import docker
import yaml
from kubernetes import client, config
from kubernetes.client.rest import ApiException
from easymapping import HaproxyConfigGenerator
from functions import Consts, ContainerEnv, Functions, logger_easyhaproxy
class ProcessorInterface:
STATIC: Final[str] = "static"
DOCKER: Final[str] = "docker"
SWARM: Final[str] = "swarm"
KUBERNETES: Final[str] = "kubernetes"
static_file = Consts.easyhaproxy_config
def __init__(self, filename=None):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.cfg = None
self.certbot_hosts = None
self.hosts = None
self.filename = filename
self.label = ContainerEnv.read()['lookup_label']
self.refresh()
@staticmethod
def factory(mode):
if mode == ProcessorInterface.STATIC:
return Static(ProcessorInterface.static_file)
elif mode == ProcessorInterface.DOCKER:
return Docker()
elif mode == ProcessorInterface.SWARM:
return Swarm()
elif mode == ProcessorInterface.KUBERNETES:
return Kubernetes()
else:
logger_easyhaproxy.fatal(f"Expected mode to be 'static', 'docker', 'swarm' or 'kubernetes'. I got '{mode}'")
return None
def refresh(self):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.inspect_network()
self.parse()
def inspect_network(self):
# Abstract
pass
def parse(self):
self.cfg = HaproxyConfigGenerator(ContainerEnv.read())
def get_certbot_hosts(self):
return self.certbot_hosts
def get_hosts(self):
return self.hosts
def get_parsed_object(self):
return self.parsed_object
def get_certs(self, key=None):
if key is None:
return self.cfg.certs
else:
return None if key not in self.cfg.certs else self.cfg.certs[key]
def get_haproxy_conf(self):
conf = self.cfg.generate(self.parsed_object)
self.certbot_hosts = self.cfg.certbot_hosts
self.hosts = self.cfg.serving_hosts
return conf
def save_config(self, filename):
Functions.save(filename, self.get_haproxy_conf())
def save_certs(self, path):
for cert in self.get_certs():
Functions.save(f"{path}/{cert}", self.get_certs(cert))
class Static(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.static_content = None
self.static_content = None
self.cfg = None
super().__init__(filename)
def inspect_network(self):
"""Load YAML and convert containers to Docker-style container metadata"""
# Load YAML
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
# Convert containers to label format
self.parsed_object = self._convert_yaml_to_labels()
def _convert_yaml_to_labels(self):
"""
Convert static YAML containers to Docker label format.
Returns: {IP: {labels}} structure that parse() can process
"""
import json
container_metadata = {}
# Get global plugin configuration
global_plugins = self.static_content.get("plugins", {})
global_enabled = global_plugins.get("enabled", [])
global_plugin_config = global_plugins.get("config", {})
for host_port, config in self.static_content.get("containers", {}).items():
# Parse hostname:port from key
if ":" in host_port:
hostname, port = host_port.rsplit(":", 1)
else:
hostname = host_port
port = "80"
# Create definition: hostname_port (e.g., host1_com_br_80)
definition = hostname.replace(".", "_") + f"_{port}"
# Handle redirect-only entries (no backend)
if "redirect" in config and "ip" not in config:
# Create metadata with redirect but mark as redirect-only to skip backend creation
fake_ip = f"redirect-{hostname}-{port}"
if fake_ip not in container_metadata:
container_metadata[fake_ip] = {}
container_metadata[fake_ip].update({
f"easyhaproxy.{definition}.host": hostname,
f"easyhaproxy.{definition}.port": port,
f"easyhaproxy.{definition}.redirect": json.dumps({hostname: config["redirect"]}),
f"easyhaproxy.{definition}.redirect_only": "true", # Marker to skip backend
})
continue
# Get IPs/containers
ip_list = config.get("ip", [hostname])
# Process each container/IP
for container_spec in ip_list:
# Parse container:localport
if ":" in container_spec:
container_addr, localport = container_spec.rsplit(":", 1)
else:
container_addr = container_spec
localport = "80"
# Use container address as IP (could be IP, DNS, or container name)
ip = container_addr
# Build labels dict
labels = {
f"easyhaproxy.{definition}.host": hostname,
f"easyhaproxy.{definition}.port": port,
f"easyhaproxy.{definition}.localport": localport,
}
# Add optional settings
for key in ["mode", "certbot", "redirect_ssl", "ssl", "balance", "proto", "ssl-check", "clone_to_ssl"]:
if key in config:
value = config[key]
# Convert boolean to string
if isinstance(value, bool):
value = "true" if value else "false"
labels[f"easyhaproxy.{definition}.{key}"] = str(value)
# Handle plugins
host_plugins = config.get("plugins", global_enabled)
if host_plugins:
# Convert list to comma-separated string if needed
if isinstance(host_plugins, list):
plugins_str = ",".join(host_plugins)
else:
plugins_str = host_plugins
labels[f"easyhaproxy.{definition}.plugins"] = plugins_str
# Process plugin configurations
host_plugin_config = config.get("plugin", {})
# Parse plugins list
plugins_list = host_plugins if isinstance(host_plugins, list) else [p.strip() for p in host_plugins.split(",")]
for plugin_name in plugins_list:
# Merge global and host-specific config
merged_config = {}
if plugin_name in global_plugin_config:
merged_config.update(global_plugin_config[plugin_name])
if plugin_name in host_plugin_config:
merged_config.update(host_plugin_config[plugin_name])
# Convert plugin config to labels
for config_key, config_value in merged_config.items():
label_key = f"easyhaproxy.{definition}.plugin.{plugin_name}.{config_key}"
# Convert list values to comma-separated strings
if isinstance(config_value, list):
label_value = ",".join(str(v) for v in config_value)
else:
label_value = str(config_value)
labels[label_key] = label_value
# Initialize container entry if it doesn't exist
if ip not in container_metadata:
container_metadata[ip] = {}
# Merge labels instead of overwriting
container_metadata[ip].update(labels)
return container_metadata
def parse(self):
"""Create HaproxyConfigGenerator with YAML config merged into env vars"""
self.cfg = HaproxyConfigGenerator(ContainerEnv.read(self.static_content))
class Docker(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
try:
ha_proxy_network_name = next(
iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"]))
except Exception:
# HAProxy is not running in a container, get first container network
if len(self.client.containers.list()) == 0:
return
ha_proxy_network_name = next(iter(
self.client.containers.get(self.client.containers.list()[0].name).attrs["NetworkSettings"]["Networks"]))
ha_proxy_network = self.client.networks.get(ha_proxy_network_name)
self.parsed_object = {}
for container in self.client.containers.list():
# Issue 32 - Docker container cannot connect to containers in different network.
if ha_proxy_network_name not in container.attrs["NetworkSettings"]["Networks"].keys():
ha_proxy_network.connect(container.name)
container = self.client.containers.get(container.name) # refresh object
ip_address = container.attrs["NetworkSettings"]["Networks"][ha_proxy_network_name]["IPAddress"]
self.parsed_object[ip_address] = container.labels
class Swarm(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
ha_proxy_service_name = self.client.containers.get(socket.gethostname()).name.split('.')[0]
ha_proxy_network_id = None
swarm_ingress_id = None
# Get the HAProxy network and the ingress network
for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]:
network_name = self.client.networks.get(endpoint["NetworkID"]).name
if swarm_ingress_id is None and network_name == 'ingress':
swarm_ingress_id = endpoint["NetworkID"]
if ha_proxy_network_id is None and network_name != 'ingress':
ha_proxy_network_id = endpoint["NetworkID"]
if ha_proxy_network_id is not None and swarm_ingress_id is not None:
break
# Check if the service is attached to the HAProxy network
self.parsed_object = {}
for service in self.client.services.list():
if not any(self.label in key for key in service.attrs["Spec"]["Labels"]):
continue
ip_address = None
network_list = []
for endpoint in service.attrs["Endpoint"]["VirtualIPs"]:
if ha_proxy_network_id == endpoint["NetworkID"]:
ip_address = endpoint["Addr"].split("/")[0]
break
elif swarm_ingress_id != endpoint["NetworkID"]:
network_list.append(endpoint["NetworkID"])
# Attach the service to the HAProxy network
if ip_address is None:
network_list.append(ha_proxy_network_id)
service.update(networks = network_list)
continue # skip to the next service to give time to update the network
self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"]
class Kubernetes(ProcessorInterface):
def __init__(self, filename=None, api_instance=None, v1=None):
self.parsed_object = None
# Only load config if API clients are not provided (allows dependency injection for testing)
if api_instance is None or v1 is None:
config.load_incluster_config()
config.verify_ssl = False
# Use injected clients or create new ones (dependency injection pattern)
self.api_instance = api_instance or client.CoreV1Api()
self.v1 = v1 or client.NetworkingV1Api()
self.cert_cache = {}
self.deployment_mode_cache = None
self.ingress_addresses_cache = None
self.addresses_cache_time = 0
super().__init__()
def _detect_deployment_mode(self):
"""
Detect the deployment mode (daemonset, nodeport, clusterip).
Returns: tuple (mode: str, service: V1Service or None)
"""
import os
# Return cached if available
if self.deployment_mode_cache:
return self.deployment_mode_cache
env_config = ContainerEnv.read()
# Check for manual override
if env_config['deployment_mode'] != 'auto':
logger_easyhaproxy.info(f"Using manual deployment mode: {env_config['deployment_mode']}")
service = self._get_easyhaproxy_service() if env_config['deployment_mode'] in ['nodeport', 'clusterip'] else None
self.deployment_mode_cache = (env_config['deployment_mode'], service)
return self.deployment_mode_cache
try:
# Get current pod name from hostname
pod_name = socket.gethostname()
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
# Read current pod
pod = self.api_instance.read_namespaced_pod(pod_name, namespace)
# Check owner references to determine if DaemonSet or Deployment
if pod.metadata.owner_references:
owner_kind = pod.metadata.owner_references[0].kind
if owner_kind == 'DaemonSet':
logger_easyhaproxy.info("Detected deployment mode: daemonset")
self.deployment_mode_cache = ('daemonset', None)
return self.deployment_mode_cache
elif owner_kind in ['ReplicaSet', 'Deployment']:
# Check if Service exists
service = self._get_easyhaproxy_service()
if service:
if service.spec.type == 'NodePort':
logger_easyhaproxy.info("Detected deployment mode: nodeport")
self.deployment_mode_cache = ('nodeport', service)
return self.deployment_mode_cache
else:
logger_easyhaproxy.info("Detected deployment mode: clusterip")
self.deployment_mode_cache = ('clusterip', service)
return self.deployment_mode_cache
except Exception as e:
logger_easyhaproxy.warn(f"Failed to detect deployment mode: {e}, defaulting to daemonset")
self.deployment_mode_cache = ('daemonset', None)
return self.deployment_mode_cache
def _get_easyhaproxy_service(self):
"""Get the EasyHAProxy service if it exists."""
import os
try:
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
# Try common service names
service_names = ['easyhaproxy', 'ingress-easyhaproxy']
for service_name in service_names:
try:
service = self.api_instance.read_namespaced_service(service_name, namespace)
return service
except Exception:
continue
return None
except Exception as e:
logger_easyhaproxy.warn(f"Failed to get EasyHAProxy service: {e}")
return None
def _get_ingress_addresses(self, mode, service):
"""
Get IP addresses or hostnames to report in ingress status.
Args:
mode: Deployment mode (daemonset, nodeport, clusterip)
service: V1Service object (for nodeport/clusterip modes)
Returns:
List of dicts: [{"ip": "..."}, {"hostname": "..."}]
"""
import os
import time
env_config = ContainerEnv.read()
cache_ttl = env_config.get('ingress_status_update_interval', 30)
# Return cached if still valid
if self.ingress_addresses_cache and (time.time() - self.addresses_cache_time) < cache_ttl:
return self.ingress_addresses_cache
addresses = []
try:
if mode == 'daemonset':
# Get nodes where DaemonSet pods are running
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
label_selector = "app.kubernetes.io/name=easyhaproxy"
pods = self.api_instance.list_namespaced_pod(namespace, label_selector=label_selector)
node_names = set(pod.spec.node_name for pod in pods.items if pod.spec.node_name)
# Get external IPs from these nodes
for node_name in node_names:
node = self.api_instance.read_node(node_name)
for addr in node.status.addresses:
if addr.type == 'ExternalIP':
addresses.append({"ip": addr.address})
break
else:
# Fallback to InternalIP if no ExternalIP
for addr in node.status.addresses:
if addr.type == 'InternalIP':
addresses.append({"ip": addr.address})
break
elif mode == 'nodeport':
# Get all node IPs (traffic can reach any node via NodePort)
nodes = self.api_instance.list_node()
for node in nodes.items:
for addr in node.status.addresses:
if addr.type == 'ExternalIP':
addresses.append({"ip": addr.address})
break
else:
# Fallback to InternalIP
for addr in node.status.addresses:
if addr.type == 'InternalIP':
addresses.append({"ip": addr.address})
break
elif mode == 'clusterip':
# Check if LoadBalancer status is available
if service and service.status and service.status.load_balancer:
lb_ingress = service.status.load_balancer.ingress or []
for ing in lb_ingress:
if ing.ip:
addresses.append({"ip": ing.ip})
if ing.hostname:
addresses.append({"hostname": ing.hostname})
# If no LoadBalancer, check for external hostname override
if not addresses and env_config['external_hostname']:
addresses.append({"hostname": env_config['external_hostname']})
# Fallback to ClusterIP
if not addresses and service:
addresses.append({"ip": service.spec.cluster_ip})
except Exception as e:
logger_easyhaproxy.warn(f"Failed to get ingress addresses: {e}")
# Cache the result
self.ingress_addresses_cache = addresses
self.addresses_cache_time = time.time()
return addresses
def _update_ingress_status(self, ingress, addresses):
"""
Update the status of an ingress resource.
Args:
ingress: V1Ingress object
addresses: List of address dicts [{"ip": "..."}, {"hostname": "..."}]
"""
if not addresses:
return
try:
# Create status patch
status_body = {
"status": {
"loadBalancer": {
"ingress": addresses
}
}
}
# Update status using patch (not replace)
self.v1.patch_namespaced_ingress_status(
name=ingress.metadata.name,
namespace=ingress.metadata.namespace,
body=status_body,
field_manager="easyhaproxy"
)
logger_easyhaproxy.debug(
f"Updated ingress {ingress.metadata.namespace}/{ingress.metadata.name} "
f"status with {len(addresses)} address(es)"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Failed to update status for ingress "
f"{ingress.metadata.namespace}/{ingress.metadata.name}: {e}"
)
def _check_annotation(self, annotations, key, default=None):
if key not in annotations:
return default
return annotations[key]
def inspect_network(self):
ret = self.v1.list_ingress_for_all_namespaces(watch=False)
# Detect deployment mode once per cycle for ingress status updates
env_config = ContainerEnv.read()
if env_config['update_ingress_status']:
deployment_mode, service = self._detect_deployment_mode()
ingress_addresses = self._get_ingress_addresses(deployment_mode, service)
else:
ingress_addresses = []
self.parsed_object = {}
for ingress in ret.items:
# Support both new spec.ingressClassName and deprecated annotation for backward compatibility
ingress_class = None
is_match = False
# Check new spec.ingressClassName first (preferred)
if hasattr(ingress.spec, 'ingress_class_name') and ingress.spec.ingress_class_name is not None:
ingress_class = ingress.spec.ingress_class_name
# Modern spec uses 'easyhaproxy'
is_match = (ingress_class == "easyhaproxy")
# Fall back to deprecated annotation
elif ingress.metadata.annotations and 'kubernetes.io/ingress.class' in ingress.metadata.annotations:
ingress_class = ingress.metadata.annotations['kubernetes.io/ingress.class']
# Deprecated annotation uses 'easyhaproxy-ingress' for backward compatibility
is_match = (ingress_class == "easyhaproxy-ingress")
# Skip if no ingress class is defined or it doesn't match
if not is_match:
continue
ssl_hosts = []
certbot = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.certbot")
redirect_ssl = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect_ssl")
redirect = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect")
mode = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.mode")
listen_port = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.listen_port", 80)
plugins = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.plugins")
# Extract plugin-specific configurations
plugin_annotations = {}
for annotation_key, annotation_value in ingress.metadata.annotations.items():
if annotation_key.startswith("easyhaproxy.plugin."):
plugin_annotations[annotation_key] = annotation_value
# Get ingress name for logging
ingress_name = f"{ingress.metadata.namespace}/{ingress.metadata.name}"
# Generic k8s_secret annotation processing
# Pattern: easyhaproxy.plugin.X.k8s_secret.KEY: "secret_name" or "secret_name/key_name"
# Result: easyhaproxy.plugin.X.KEY: "<base64-encoded-content>"
k8s_secret_annotations = {}
for annotation_key, secret_value in list(plugin_annotations.items()):
# Check if this annotation contains k8s_secret pattern
if ".k8s_secret." in annotation_key:
try:
# Parse the annotation key
# Example: "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey" -> "pubkey"
parts = annotation_key.split(".k8s_secret.")
if len(parts) != 2:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Malformed k8s_secret annotation: {annotation_key}"
)
continue
prefix = parts[0] # "easyhaproxy.plugin.jwt_validator"
config_key = parts[1] # "pubkey"
target_annotation = f"{prefix}.{config_key}" # "easyhaproxy.plugin.jwt_validator.pubkey"
# Parse secret_value: can be "secret_name" or "secret_name/key_name"
if "/" in secret_value:
secret_name, explicit_key_name = secret_value.split("/", 1)
use_explicit_key = True
else:
secret_name = secret_value
explicit_key_name = None
use_explicit_key = False
# Read the secret
secret = self.api_instance.read_namespaced_secret(
secret_name,
ingress.metadata.namespace
)
# Try to find the key in the secret data
secret_data = None
tried_keys = []
if use_explicit_key:
# User specified exact key name - only try that one
tried_keys = [explicit_key_name]
if explicit_key_name in secret.data:
secret_data = secret.data[explicit_key_name]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found explicit secret key '{explicit_key_name}' "
f"in secret '{secret_name}'"
)
else:
# No explicit key - try config_key and common variations
tried_keys = [config_key]
if config_key in secret.data:
secret_data = secret.data[config_key]
else:
# Try common variations for the requested key
variations = []
if config_key == "pubkey":
variations = ["public-key", "jwt.pub", "tls.crt"]
elif config_key == "password":
variations = ["pass", "pwd"]
elif config_key == "api_key":
variations = ["apikey", "api-key", "key"]
for variation in variations:
tried_keys.append(variation)
if variation in secret.data:
secret_data = secret.data[variation]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found secret key '{variation}' "
f"for requested key '{config_key}'"
)
break
if secret_data:
# Decode from base64 (Kubernetes secrets are base64-encoded)
# Then re-encode to base64 for plugin (plugin expects base64-encoded)
decoded = base64.b64decode(secret_data).decode('ascii')
reencoded = base64.b64encode(decoded.encode('ascii')).decode('ascii')
# Store the processed annotation
k8s_secret_annotations[target_annotation] = reencoded
logger_easyhaproxy.info(
f"Ingress {ingress_name} - Loaded '{config_key}' from secret "
f"'{secret_name}' for annotation '{target_annotation}'"
)
else:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Secret '{secret_name}' found but "
f"no matching key (tried: {', '.join(tried_keys)})"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Failed to process k8s_secret annotation "
f"'{annotation_key}' with value '{secret_value}': {e}"
)
# Merge k8s_secret annotations into plugin_annotations
# k8s_secret annotations will NOT override existing explicit annotations (lower priority)
for key, value in k8s_secret_annotations.items():
if key not in plugin_annotations:
plugin_annotations[key] = value
else:
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Skipping k8s_secret annotation '{key}' "
f"because explicit annotation already exists"
)
data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"),
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
if ingress.spec.tls is not None:
for tls in ingress.spec.tls:
try:
secret = self.api_instance.read_namespaced_secret(tls.secret_name, ingress.metadata.namespace)
if "tls.crt" not in secret.data or "tls.key" not in secret.data:
continue
if tls.secret_name not in self.cert_cache or self.cert_cache[tls.secret_name] != secret.data:
self.cert_cache[tls.secret_name] = secret.data
Functions.save(
f"{Consts.certs_haproxy}/{tls.secret_name}.pem",
base64.b64decode(secret.data["tls.crt"]).decode('ascii') + "\n" + base64.b64decode(
secret.data["tls.key"]).decode('ascii')
)
ssl_hosts.extend(tls.hosts)
except Exception as e:
logger_easyhaproxy.warn(f"Ingress {ingress_name} - Get secret failed: '{e}'")
logger_easyhaproxy.debug(f"Ingress {ingress_name} - SSL Hosts found '{ssl_hosts}'")
for rule in ingress.spec.rules:
rule_data = {}
port_number = rule.http.paths[0].backend.service.port.number
definition = f"easyhaproxy.{rule.host.replace('.', '-')}_{port_number}"
rule_data[f"{definition}.host"] = rule.host
rule_data[f"{definition}.port"] = listen_port
rule_data[f"{definition}.localport"] = port_number
if rule.host in ssl_hosts:
rule_data[f"{definition}.clone_to_ssl"] = 'true'
if redirect_ssl is not None:
rule_data[f"{definition}.redirect_ssl"] = redirect_ssl
if certbot is not None:
rule_data[f"{definition}.certbot"] = certbot
if redirect is not None:
rule_data[f"{definition}.redirect"] = redirect
if mode is not None:
rule_data[f"{definition}.mode"] = mode
rule_data[f"{definition}.balance"] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin")
# Add plugin configuration
if plugins is not None:
rule_data[f"{definition}.plugins"] = plugins
# Add plugin-specific configurations
for plugin_key, plugin_value in plugin_annotations.items():
# Convert easyhaproxy.plugin.X.Y to easyhaproxy.{definition}.plugin.X.Y
plugin_config_key = plugin_key.replace("easyhaproxy.plugin.", f"{definition}.plugin.")
rule_data[plugin_config_key] = plugin_value
service_name = rule.http.paths[0].backend.service.name
try:
api_response = self.api_instance.read_namespaced_service(service_name, ingress.metadata.namespace)
cluster_ip = api_response.spec.cluster_ip
except ApiException as e:
cluster_ip = None
logger_easyhaproxy.warn(f"Ingress {ingress_name} - Service {service_name} - Failed: '{e}'")
if cluster_ip is not None:
if cluster_ip not in self.parsed_object.keys():
self.parsed_object[cluster_ip] = data
self.parsed_object[cluster_ip].update(rule_data)
# Update ingress status if enabled
if env_config['update_ingress_status'] and ingress_addresses:
self._update_ingress_status(ingress, ingress_addresses)
__all__ = ["ProcessorInterface", "Static", "Docker", "Swarm", "Kubernetes"]

35
src/processor/docker.py Normal file
View file

@ -0,0 +1,35 @@
import socket
import docker
from .interface import ProcessorInterface
class Docker(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
try:
ha_proxy_network_name = next(
iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"]))
except Exception:
# HAProxy is not running in a container, get first container network
if len(self.client.containers.list()) == 0:
return
ha_proxy_network_name = next(iter(
self.client.containers.get(self.client.containers.list()[0].name).attrs["NetworkSettings"]["Networks"]))
ha_proxy_network = self.client.networks.get(ha_proxy_network_name)
self.parsed_object = {}
for container in self.client.containers.list():
# Issue 32 - Docker container cannot connect to containers in different network.
if ha_proxy_network_name not in container.attrs["NetworkSettings"]["Networks"].keys():
ha_proxy_network.connect(container.name)
container = self.client.containers.get(container.name) # refresh object
ip_address = container.attrs["NetworkSettings"]["Networks"][ha_proxy_network_name]["IPAddress"]
self.parsed_object[ip_address] = container.labels

View file

@ -0,0 +1,87 @@
from typing import Final
from easymapping import HaproxyConfigGenerator
from functions import Consts, ContainerEnv, Functions, logger_easyhaproxy
class ProcessorInterface:
STATIC: Final[str] = "static"
DOCKER: Final[str] = "docker"
SWARM: Final[str] = "swarm"
KUBERNETES: Final[str] = "kubernetes"
static_file = Consts.easyhaproxy_config
def __init__(self, filename=None):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.cfg = None
self.certbot_hosts = None
self.hosts = None
self.filename = filename
self.label = ContainerEnv.read()['lookup_label']
self.refresh()
@staticmethod
def factory(mode):
from .static import Static
from .docker import Docker
from .swarm import Swarm
from .kubernetes import Kubernetes
if mode == ProcessorInterface.STATIC:
return Static(ProcessorInterface.static_file)
elif mode == ProcessorInterface.DOCKER:
return Docker()
elif mode == ProcessorInterface.SWARM:
return Swarm()
elif mode == ProcessorInterface.KUBERNETES:
return Kubernetes()
else:
logger_easyhaproxy.fatal(f"Expected mode to be 'static', 'docker', 'swarm' or 'kubernetes'. I got '{mode}'")
return None
def refresh(self):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.inspect_network()
self.parse()
def inspect_network(self):
# Abstract
pass
def parse(self):
self.cfg = HaproxyConfigGenerator(ContainerEnv.read())
def get_certbot_hosts(self):
return self.certbot_hosts
def get_hosts(self):
return self.hosts
def get_parsed_object(self):
return self.parsed_object
def get_certs(self, key=None):
if key is None:
return self.cfg.certs
else:
return None if key not in self.cfg.certs else self.cfg.certs[key]
def get_haproxy_conf(self):
conf = self.cfg.generate(self.parsed_object)
self.certbot_hosts = self.cfg.certbot_hosts
self.hosts = self.cfg.serving_hosts
return conf
def save_config(self, filename):
Functions.save(filename, self.get_haproxy_conf())
def save_certs(self, path):
for cert in self.get_certs():
Functions.save(f"{path}/{cert}", self.get_certs(cert))

461
src/processor/kubernetes.py Normal file
View file

@ -0,0 +1,461 @@
import base64
import os
import socket
import time
from kubernetes import client, config
from kubernetes.client.rest import ApiException
from functions import Consts, ContainerEnv, Functions, logger_easyhaproxy
from .interface import ProcessorInterface
class Kubernetes(ProcessorInterface):
def __init__(self, filename=None, api_instance=None, v1=None):
self.parsed_object = None
# Only load config if API clients are not provided (allows dependency injection for testing)
if api_instance is None or v1 is None:
config.load_incluster_config()
config.verify_ssl = False
# Use injected clients or create new ones (dependency injection pattern)
self.api_instance = api_instance or client.CoreV1Api()
self.v1 = v1 or client.NetworkingV1Api()
self.cert_cache = {}
self.deployment_mode_cache = None
self.ingress_addresses_cache = None
self.addresses_cache_time = 0
super().__init__()
def _detect_deployment_mode(self):
"""
Detect the deployment mode (daemonset, nodeport, clusterip).
Returns: tuple (mode: str, service: V1Service or None)
"""
# Return cached if available
if self.deployment_mode_cache:
return self.deployment_mode_cache
env_config = ContainerEnv.read()
# Check for manual override
if env_config['deployment_mode'] != 'auto':
logger_easyhaproxy.info(f"Using manual deployment mode: {env_config['deployment_mode']}")
service = self._get_easyhaproxy_service() if env_config['deployment_mode'] in ['nodeport', 'clusterip'] else None
self.deployment_mode_cache = (env_config['deployment_mode'], service)
return self.deployment_mode_cache
try:
# Get current pod name from hostname
pod_name = socket.gethostname()
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
# Read current pod
pod = self.api_instance.read_namespaced_pod(pod_name, namespace)
# Check owner references to determine if DaemonSet or Deployment
if pod.metadata.owner_references:
owner_kind = pod.metadata.owner_references[0].kind
if owner_kind == 'DaemonSet':
logger_easyhaproxy.info("Detected deployment mode: daemonset")
self.deployment_mode_cache = ('daemonset', None)
return self.deployment_mode_cache
elif owner_kind in ['ReplicaSet', 'Deployment']:
# Check if Service exists
service = self._get_easyhaproxy_service()
if service:
if service.spec.type == 'NodePort':
logger_easyhaproxy.info("Detected deployment mode: nodeport")
self.deployment_mode_cache = ('nodeport', service)
return self.deployment_mode_cache
else:
logger_easyhaproxy.info("Detected deployment mode: clusterip")
self.deployment_mode_cache = ('clusterip', service)
return self.deployment_mode_cache
except Exception as e:
logger_easyhaproxy.warn(f"Failed to detect deployment mode: {e}, defaulting to daemonset")
self.deployment_mode_cache = ('daemonset', None)
return self.deployment_mode_cache
def _get_easyhaproxy_service(self):
"""Get the EasyHAProxy service if it exists."""
try:
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
# Try common service names
service_names = ['easyhaproxy', 'ingress-easyhaproxy']
for service_name in service_names:
try:
service = self.api_instance.read_namespaced_service(service_name, namespace)
return service
except Exception:
continue
return None
except Exception as e:
logger_easyhaproxy.warn(f"Failed to get EasyHAProxy service: {e}")
return None
def _get_ingress_addresses(self, mode, service):
"""
Get IP addresses or hostnames to report in ingress status.
Args:
mode: Deployment mode (daemonset, nodeport, clusterip)
service: V1Service object (for nodeport/clusterip modes)
Returns:
List of dicts: [{"ip": "..."}, {"hostname": "..."}]
"""
env_config = ContainerEnv.read()
cache_ttl = env_config.get('ingress_status_update_interval', 30)
# Return cached if still valid
if self.ingress_addresses_cache and (time.time() - self.addresses_cache_time) < cache_ttl:
return self.ingress_addresses_cache
addresses = []
try:
if mode == 'daemonset':
# Get nodes where DaemonSet pods are running
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
label_selector = "app.kubernetes.io/name=easyhaproxy"
pods = self.api_instance.list_namespaced_pod(namespace, label_selector=label_selector)
node_names = set(pod.spec.node_name for pod in pods.items if pod.spec.node_name)
# Get external IPs from these nodes
for node_name in node_names:
node = self.api_instance.read_node(node_name)
for addr in node.status.addresses:
if addr.type == 'ExternalIP':
addresses.append({"ip": addr.address})
break
else:
# Fallback to InternalIP if no ExternalIP
for addr in node.status.addresses:
if addr.type == 'InternalIP':
addresses.append({"ip": addr.address})
break
elif mode == 'nodeport':
# Get all node IPs (traffic can reach any node via NodePort)
nodes = self.api_instance.list_node()
for node in nodes.items:
for addr in node.status.addresses:
if addr.type == 'ExternalIP':
addresses.append({"ip": addr.address})
break
else:
# Fallback to InternalIP
for addr in node.status.addresses:
if addr.type == 'InternalIP':
addresses.append({"ip": addr.address})
break
elif mode == 'clusterip':
# Check if LoadBalancer status is available
if service and service.status and service.status.load_balancer:
lb_ingress = service.status.load_balancer.ingress or []
for ing in lb_ingress:
if ing.ip:
addresses.append({"ip": ing.ip})
if ing.hostname:
addresses.append({"hostname": ing.hostname})
# If no LoadBalancer, check for external hostname override
if not addresses and env_config['external_hostname']:
addresses.append({"hostname": env_config['external_hostname']})
# Fallback to ClusterIP
if not addresses and service:
addresses.append({"ip": service.spec.cluster_ip})
except Exception as e:
logger_easyhaproxy.warn(f"Failed to get ingress addresses: {e}")
# Cache the result
self.ingress_addresses_cache = addresses
self.addresses_cache_time = time.time()
return addresses
def _update_ingress_status(self, ingress, addresses):
"""
Update the status of an ingress resource.
Args:
ingress: V1Ingress object
addresses: List of address dicts [{"ip": "..."}, {"hostname": "..."}]
"""
if not addresses:
return
try:
# Create status patch
status_body = {
"status": {
"loadBalancer": {
"ingress": addresses
}
}
}
# Update status using patch (not replace)
self.v1.patch_namespaced_ingress_status(
name=ingress.metadata.name,
namespace=ingress.metadata.namespace,
body=status_body,
field_manager="easyhaproxy"
)
logger_easyhaproxy.debug(
f"Updated ingress {ingress.metadata.namespace}/{ingress.metadata.name} "
f"status with {len(addresses)} address(es)"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Failed to update status for ingress "
f"{ingress.metadata.namespace}/{ingress.metadata.name}: {e}"
)
def _check_annotation(self, annotations, key, default=None):
if key not in annotations:
return default
return annotations[key]
def inspect_network(self):
ret = self.v1.list_ingress_for_all_namespaces(watch=False)
# Detect deployment mode once per cycle for ingress status updates
env_config = ContainerEnv.read()
if env_config['update_ingress_status']:
deployment_mode, service = self._detect_deployment_mode()
ingress_addresses = self._get_ingress_addresses(deployment_mode, service)
else:
ingress_addresses = []
self.parsed_object = {}
for ingress in ret.items:
# Support both new spec.ingressClassName and deprecated annotation for backward compatibility
ingress_class = None
is_match = False
# Check new spec.ingressClassName first (preferred)
if hasattr(ingress.spec, 'ingress_class_name') and ingress.spec.ingress_class_name is not None:
ingress_class = ingress.spec.ingress_class_name
# Modern spec uses 'easyhaproxy'
is_match = (ingress_class == "easyhaproxy")
# Fall back to deprecated annotation
elif ingress.metadata.annotations and 'kubernetes.io/ingress.class' in ingress.metadata.annotations:
ingress_class = ingress.metadata.annotations['kubernetes.io/ingress.class']
# Deprecated annotation uses 'easyhaproxy-ingress' for backward compatibility
is_match = (ingress_class == "easyhaproxy-ingress")
# Skip if no ingress class is defined or it doesn't match
if not is_match:
continue
ssl_hosts = []
certbot = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.certbot")
redirect_ssl = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect_ssl")
redirect = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect")
mode = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.mode")
listen_port = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.listen_port", 80)
plugins = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.plugins")
# Extract plugin-specific configurations
plugin_annotations = {}
for annotation_key, annotation_value in ingress.metadata.annotations.items():
if annotation_key.startswith("easyhaproxy.plugin."):
plugin_annotations[annotation_key] = annotation_value
# Get ingress name for logging
ingress_name = f"{ingress.metadata.namespace}/{ingress.metadata.name}"
# Generic k8s_secret annotation processing
# Pattern: easyhaproxy.plugin.X.k8s_secret.KEY: "secret_name" or "secret_name/key_name"
# Result: easyhaproxy.plugin.X.KEY: "<base64-encoded-content>"
k8s_secret_annotations = {}
for annotation_key, secret_value in list(plugin_annotations.items()):
# Check if this annotation contains k8s_secret pattern
if ".k8s_secret." in annotation_key:
try:
# Parse the annotation key
# Example: "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey" -> "pubkey"
parts = annotation_key.split(".k8s_secret.")
if len(parts) != 2:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Malformed k8s_secret annotation: {annotation_key}"
)
continue
prefix = parts[0] # "easyhaproxy.plugin.jwt_validator"
config_key = parts[1] # "pubkey"
target_annotation = f"{prefix}.{config_key}" # "easyhaproxy.plugin.jwt_validator.pubkey"
# Parse secret_value: can be "secret_name" or "secret_name/key_name"
if "/" in secret_value:
secret_name, explicit_key_name = secret_value.split("/", 1)
use_explicit_key = True
else:
secret_name = secret_value
explicit_key_name = None
use_explicit_key = False
# Read the secret
secret = self.api_instance.read_namespaced_secret(
secret_name,
ingress.metadata.namespace
)
# Try to find the key in the secret data
secret_data = None
tried_keys = []
if use_explicit_key:
# User specified exact key name - only try that one
tried_keys = [explicit_key_name]
if explicit_key_name in secret.data:
secret_data = secret.data[explicit_key_name]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found explicit secret key '{explicit_key_name}' "
f"in secret '{secret_name}'"
)
else:
# No explicit key - try config_key and common variations
tried_keys = [config_key]
if config_key in secret.data:
secret_data = secret.data[config_key]
else:
# Try common variations for the requested key
variations = []
if config_key == "pubkey":
variations = ["public-key", "jwt.pub", "tls.crt"]
elif config_key == "password":
variations = ["pass", "pwd"]
elif config_key == "api_key":
variations = ["apikey", "api-key", "key"]
for variation in variations:
tried_keys.append(variation)
if variation in secret.data:
secret_data = secret.data[variation]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found secret key '{variation}' "
f"for requested key '{config_key}'"
)
break
if secret_data:
# Decode from base64 (Kubernetes secrets are base64-encoded)
# Then re-encode to base64 for plugin (plugin expects base64-encoded)
decoded = base64.b64decode(secret_data).decode('ascii')
reencoded = base64.b64encode(decoded.encode('ascii')).decode('ascii')
# Store the processed annotation
k8s_secret_annotations[target_annotation] = reencoded
logger_easyhaproxy.info(
f"Ingress {ingress_name} - Loaded '{config_key}' from secret "
f"'{secret_name}' for annotation '{target_annotation}'"
)
else:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Secret '{secret_name}' found but "
f"no matching key (tried: {', '.join(tried_keys)})"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Failed to process k8s_secret annotation "
f"'{annotation_key}' with value '{secret_value}': {e}"
)
# Merge k8s_secret annotations into plugin_annotations
# k8s_secret annotations will NOT override existing explicit annotations (lower priority)
for key, value in k8s_secret_annotations.items():
if key not in plugin_annotations:
plugin_annotations[key] = value
else:
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Skipping k8s_secret annotation '{key}' "
f"because explicit annotation already exists"
)
data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"),
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
if ingress.spec.tls is not None:
for tls in ingress.spec.tls:
try:
secret = self.api_instance.read_namespaced_secret(tls.secret_name, ingress.metadata.namespace)
if "tls.crt" not in secret.data or "tls.key" not in secret.data:
continue
if tls.secret_name not in self.cert_cache or self.cert_cache[tls.secret_name] != secret.data:
self.cert_cache[tls.secret_name] = secret.data
Functions.save(
f"{Consts.certs_haproxy}/{tls.secret_name}.pem",
base64.b64decode(secret.data["tls.crt"]).decode('ascii') + "\n" + base64.b64decode(
secret.data["tls.key"]).decode('ascii')
)
ssl_hosts.extend(tls.hosts)
except Exception as e:
logger_easyhaproxy.warn(f"Ingress {ingress_name} - Get secret failed: '{e}'")
logger_easyhaproxy.debug(f"Ingress {ingress_name} - SSL Hosts found '{ssl_hosts}'")
for rule in ingress.spec.rules:
rule_data = {}
port_number = rule.http.paths[0].backend.service.port.number
definition = f"easyhaproxy.{rule.host.replace('.', '-')}_{port_number}"
rule_data[f"{definition}.host"] = rule.host
rule_data[f"{definition}.port"] = listen_port
rule_data[f"{definition}.localport"] = port_number
if rule.host in ssl_hosts:
rule_data[f"{definition}.clone_to_ssl"] = 'true'
if redirect_ssl is not None:
rule_data[f"{definition}.redirect_ssl"] = redirect_ssl
if certbot is not None:
rule_data[f"{definition}.certbot"] = certbot
if redirect is not None:
rule_data[f"{definition}.redirect"] = redirect
if mode is not None:
rule_data[f"{definition}.mode"] = mode
rule_data[f"{definition}.balance"] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin")
# Add plugin configuration
if plugins is not None:
rule_data[f"{definition}.plugins"] = plugins
# Add plugin-specific configurations
for plugin_key, plugin_value in plugin_annotations.items():
# Convert easyhaproxy.plugin.X.Y to easyhaproxy.{definition}.plugin.X.Y
plugin_config_key = plugin_key.replace("easyhaproxy.plugin.", f"{definition}.plugin.")
rule_data[plugin_config_key] = plugin_value
service_name = rule.http.paths[0].backend.service.name
try:
api_response = self.api_instance.read_namespaced_service(service_name, ingress.metadata.namespace)
cluster_ip = api_response.spec.cluster_ip
except ApiException as e:
cluster_ip = None
logger_easyhaproxy.warn(f"Ingress {ingress_name} - Service {service_name} - Failed: '{e}'")
if cluster_ip is not None:
if cluster_ip not in self.parsed_object.keys():
self.parsed_object[cluster_ip] = data
self.parsed_object[cluster_ip].update(rule_data)
# Update ingress status if enabled
if env_config['update_ingress_status'] and ingress_addresses:
self._update_ingress_status(ingress, ingress_addresses)

143
src/processor/static.py Normal file
View file

@ -0,0 +1,143 @@
import json
import yaml
from easymapping import HaproxyConfigGenerator
from functions import ContainerEnv, Functions
from .interface import ProcessorInterface
class Static(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.static_content = None
self.static_content = None
self.cfg = None
super().__init__(filename)
def inspect_network(self):
"""Load YAML and convert containers to Docker-style container metadata"""
# Load YAML
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
# Convert containers to label format
self.parsed_object = self._convert_yaml_to_labels()
def _convert_yaml_to_labels(self):
"""
Convert static YAML containers to Docker label format.
Returns: {IP: {labels}} structure that parse() can process
"""
container_metadata = {}
# Get global plugin configuration
global_plugins = self.static_content.get("plugins", {})
global_enabled = global_plugins.get("enabled", [])
global_plugin_config = global_plugins.get("config", {})
for host_port, config in self.static_content.get("containers", {}).items():
# Parse hostname:port from key
if ":" in host_port:
hostname, port = host_port.rsplit(":", 1)
else:
hostname = host_port
port = "80"
# Create definition: hostname_port (e.g., host1_com_br_80)
definition = hostname.replace(".", "_") + f"_{port}"
# Handle redirect-only entries (no backend)
if "redirect" in config and "ip" not in config:
# Create metadata with redirect but mark as redirect-only to skip backend creation
fake_ip = f"redirect-{hostname}-{port}"
if fake_ip not in container_metadata:
container_metadata[fake_ip] = {}
container_metadata[fake_ip].update({
f"easyhaproxy.{definition}.host": hostname,
f"easyhaproxy.{definition}.port": port,
f"easyhaproxy.{definition}.redirect": json.dumps({hostname: config["redirect"]}),
f"easyhaproxy.{definition}.redirect_only": "true", # Marker to skip backend
})
continue
# Get IPs/containers
ip_list = config.get("ip", [hostname])
# Process each container/IP
for container_spec in ip_list:
# Parse container:localport
if ":" in container_spec:
container_addr, localport = container_spec.rsplit(":", 1)
else:
container_addr = container_spec
localport = "80"
# Use container address as IP (could be IP, DNS, or container name)
ip = container_addr
# Build labels dict
labels = {
f"easyhaproxy.{definition}.host": hostname,
f"easyhaproxy.{definition}.port": port,
f"easyhaproxy.{definition}.localport": localport,
}
# Add optional settings
for key in ["mode", "certbot", "redirect_ssl", "ssl", "balance", "proto", "ssl-check", "clone_to_ssl"]:
if key in config:
value = config[key]
# Convert boolean to string
if isinstance(value, bool):
value = "true" if value else "false"
labels[f"easyhaproxy.{definition}.{key}"] = str(value)
# Handle plugins
host_plugins = config.get("plugins", global_enabled)
if host_plugins:
# Convert list to comma-separated string if needed
if isinstance(host_plugins, list):
plugins_str = ",".join(host_plugins)
else:
plugins_str = host_plugins
labels[f"easyhaproxy.{definition}.plugins"] = plugins_str
# Process plugin configurations
host_plugin_config = config.get("plugin", {})
# Parse plugins list
plugins_list = host_plugins if isinstance(host_plugins, list) else [p.strip() for p in host_plugins.split(",")]
for plugin_name in plugins_list:
# Merge global and host-specific config
merged_config = {}
if plugin_name in global_plugin_config:
merged_config.update(global_plugin_config[plugin_name])
if plugin_name in host_plugin_config:
merged_config.update(host_plugin_config[plugin_name])
# Convert plugin config to labels
for config_key, config_value in merged_config.items():
label_key = f"easyhaproxy.{definition}.plugin.{plugin_name}.{config_key}"
# Convert list values to comma-separated strings
if isinstance(config_value, list):
label_value = ",".join(str(v) for v in config_value)
else:
label_value = str(config_value)
labels[label_key] = label_value
# Initialize container entry if it doesn't exist
if ip not in container_metadata:
container_metadata[ip] = {}
# Merge labels instead of overwriting
container_metadata[ip].update(labels)
return container_metadata
def parse(self):
"""Create HaproxyConfigGenerator with YAML config merged into env vars"""
self.cfg = HaproxyConfigGenerator(ContainerEnv.read(self.static_content))

50
src/processor/swarm.py Normal file
View file

@ -0,0 +1,50 @@
import socket
import docker
from .interface import ProcessorInterface
class Swarm(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
ha_proxy_service_name = self.client.containers.get(socket.gethostname()).name.split('.')[0]
ha_proxy_network_id = None
swarm_ingress_id = None
# Get the HAProxy network and the ingress network
for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]:
network_name = self.client.networks.get(endpoint["NetworkID"]).name
if swarm_ingress_id is None and network_name == 'ingress':
swarm_ingress_id = endpoint["NetworkID"]
if ha_proxy_network_id is None and network_name != 'ingress':
ha_proxy_network_id = endpoint["NetworkID"]
if ha_proxy_network_id is not None and swarm_ingress_id is not None:
break
# Check if the service is attached to the HAProxy network
self.parsed_object = {}
for service in self.client.services.list():
if not any(self.label in key for key in service.attrs["Spec"]["Labels"]):
continue
ip_address = None
network_list = []
for endpoint in service.attrs["Endpoint"]["VirtualIPs"]:
if ha_proxy_network_id == endpoint["NetworkID"]:
ip_address = endpoint["Addr"].split("/")[0]
break
elif swarm_ingress_id != endpoint["NetworkID"]:
network_list.append(endpoint["NetworkID"])
# Attach the service to the HAProxy network
if ip_address is None:
network_list.append(ha_proxy_network_id)
service.update(networks=network_list)
continue # skip to the next service to give time to update the network
self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"]