1
0
Fork 0

Add Kubernetes integration tests for EasyHAProxy examples

- Introduced a suite of pytest-based integration tests for Kubernetes using `kind`.
- Automated local installation of dependencies (`kind`, `kubectl`, and `helm`) when missing.
- Implemented fixtures for Kubernetes resource management and TLS secrets.
- Added end-to-end tests for HTTP and HTTPS ingress functionality.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-11 23:32:44 -05:00
parent 5767e55dea
commit 90b01b1f13
18 changed files with 2978 additions and 24 deletions

View file

@ -5,7 +5,7 @@ import re
from jinja2 import Environment, FileSystemLoader
from functions import logger_easyhaproxy
from functions import Functions, logger_easyhaproxy
class DockerLabelHandler:
@ -289,6 +289,24 @@ class HaproxyConfigGenerator:
r.haproxy_config for r in domain_results if r.haproxy_config
]
# Write JWT public key files from metadata
for result in domain_results:
if result.metadata and "pubkey_content" in result.metadata and "pubkey_file" in result.metadata:
pubkey_file = result.metadata["pubkey_file"]
pubkey_content = result.metadata["pubkey_content"]
# Create jwt_keys directory if it doesn't exist (belt and suspenders)
import os
jwt_keys_dir = os.path.dirname(pubkey_file)
if jwt_keys_dir:
os.makedirs(jwt_keys_dir, exist_ok=True)
# Write the pubkey file
Functions.save(pubkey_file, pubkey_content)
logger_easyhaproxy.debug(
f"Wrote JWT public key to {pubkey_file} for domain {hostname}"
)
# Extract fcgi-app definitions from metadata and add to global configs
for result in domain_results:
if result.metadata and "fcgi_app_definition" in result.metadata:

View file

@ -202,6 +202,7 @@ class Consts:
custom_config_folder = "/etc/haproxy/conf.d"
certs_certbot = "/certs/certbot"
certs_haproxy = "/certs/haproxy"
jwt_keys = "/etc/haproxy/jwt_keys"
class DaemonizeHAProxy:

View file

@ -20,6 +20,7 @@ def start():
os.makedirs(Consts.certs_certbot, exist_ok=True)
os.makedirs(Consts.certs_haproxy, exist_ok=True)
os.makedirs(Consts.jwt_keys, exist_ok=True)
processor_obj.save_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy)

View file

@ -9,12 +9,28 @@ Configuration:
- algorithm: JWT signing algorithm (default: RS256)
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
- audience: Expected JWT audience (optional, set to "none"/"null" to skip validation)
- pubkey_path: Path to public key file (required if pubkey not provided)
- pubkey: Public key content as base64-encoded string (required if pubkey_path not provided)
- pubkey_path: Path to public key file in container (priority: 1)
- pubkey: Public key content as base64-encoded string (priority: 2)
- k8s_secret.pubkey: Kubernetes secret containing public key (priority: 3, Kubernetes only)
- paths: List of paths that require JWT validation (optional, if not set ALL domain is protected)
- only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation
- allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header
Priority Order (first configured option wins):
1. pubkey_path - Direct file path (explicit configuration)
2. pubkey - Base64-encoded key content (inline configuration)
3. k8s_secret.pubkey - Kubernetes secret name (processed by K8s processor into pubkey)
Kubernetes Secret Pattern (Kubernetes only):
For Kubernetes deployments, you can load the public key from a Kubernetes Secret:
- Auto-detect key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name"
- Explicit key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name/key_name"
See documentation for details:
- General k8s_secret pattern: docs/kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets
- JWT Validator with Secrets: docs/Plugins/jwt-validator.md#kubernetes-with-secrets-recommended
Path Validation Logic:
- No paths configured: ALL requests to the domain require JWT validation (default behavior)
- Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through
@ -46,6 +62,16 @@ Example Container Label:
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
easyhaproxy.http.plugin.jwt_validator.only_paths: true
Example Kubernetes Annotations:
# Using k8s_secret pattern (recommended for Kubernetes):
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Using inline pubkey (for testing):
easyhaproxy.plugin.jwt_validator.pubkey: "LS0tLS1CRUdJTi..."
HAProxy Config Generated:
# JWT Validator - Validate JWT tokens
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
@ -74,7 +100,7 @@ import sys
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from functions import logger_easyhaproxy
from functions import Consts, logger_easyhaproxy
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
@ -178,7 +204,7 @@ class JwtValidatorPlugin(PluginInterface):
elif self.pubkey:
# Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_")
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem"
pubkey_file = f"{Consts.jwt_keys}/{domain_safe}_pubkey.pem"
else:
logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult()

View file

@ -218,12 +218,17 @@ class Swarm(ProcessorInterface):
class Kubernetes(ProcessorInterface):
def __init__(self, filename=None):
def __init__(self, filename=None, api_instance=None, v1=None):
self.parsed_object = None
config.load_incluster_config()
config.verify_ssl = False
self.api_instance = client.CoreV1Api()
self.v1 = client.NetworkingV1Api()
# 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
@ -485,11 +490,122 @@ class Kubernetes(ProcessorInterface):
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}
ingress_name = ingress.metadata.namespace
if ingress.spec.tls is not None:
for tls in ingress.spec.tls:
try: