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:
parent
5767e55dea
commit
90b01b1f13
18 changed files with 2978 additions and 24 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue