From 90b01b1f133498cf22e56564aca031982eb9e2d1 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Wed, 11 Feb 2026 23:32:44 -0500 Subject: [PATCH] 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. --- docs/Plugins/jwt-validator.md | 83 +- docs/kubernetes.md | 213 +++ examples/kubernetes/.gitignore | 9 + examples/kubernetes/ip-whitelist.yml | 8 +- .../jwt-validator-secret-example.yml | 135 ++ examples/kubernetes/jwt-validator.yml | 18 + examples/kubernetes/service.yml | 10 +- examples/kubernetes/service_tls.yml | 5 +- examples/kubernetes/setup-cluster.sh | 169 ++ examples/kubernetes/teardown-cluster.sh | 39 + examples/kubernetes/test_kubernetes.py | 1697 +++++++++++++++++ pyproject.toml | 11 + src/easymapping/__init__.py | 20 +- src/functions/__init__.py | 1 + src/main.py | 1 + src/plugins/builtin/jwt_validator.py | 34 +- src/processor/__init__.py | 130 +- tests/test_kubernetes_processor.py | 419 ++++ 18 files changed, 2978 insertions(+), 24 deletions(-) create mode 100644 examples/kubernetes/.gitignore create mode 100644 examples/kubernetes/jwt-validator-secret-example.yml create mode 100755 examples/kubernetes/setup-cluster.sh create mode 100755 examples/kubernetes/teardown-cluster.sh create mode 100644 examples/kubernetes/test_kubernetes.py create mode 100644 tests/test_kubernetes_processor.py diff --git a/docs/Plugins/jwt-validator.md b/docs/Plugins/jwt-validator.md index 1b419ea..0dbcab7 100644 --- a/docs/Plugins/jwt-validator.md +++ b/docs/Plugins/jwt-validator.md @@ -31,12 +31,22 @@ Protect APIs and services with JWT authentication without needing application-le | `algorithm` | JWT signing algorithm | `RS256` | | `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) | | `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | -| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | -| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) | +| `pubkey_path` | Path to public key file (priority 1: explicit file path) | (optional) | +| `pubkey` | Public key content as base64-encoded string (priority 2: inline content) | (optional) | +| `k8s_secret.pubkey` | Kubernetes secret containing public key (priority 3: Kubernetes only - see below) | (optional) | | `paths` | List of paths that require JWT validation (optional) | (all paths) | | `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` | | `allow_anonymous` | If `true`, allows requests without Authorization header (validates JWT if present) | `false` | +### Public Key Configuration Priority + +When multiple public key options are configured, they are evaluated in this order: +1. **`pubkey_path`** - Direct file path (explicit configuration) +2. **`pubkey`** - Base64-encoded key content (inline configuration) +3. **`k8s_secret.pubkey`** - Kubernetes secret (recommended for Kubernetes deployments) + +The first configured option is used; others are ignored. + ## Path Validation Logic - **No paths configured:** ALL requests to the domain require JWT validation (default behavior) @@ -120,7 +130,70 @@ services: # Invalid JWTs are rejected ``` -### Kubernetes Annotations +### Kubernetes with Secrets (Recommended) + +The recommended way to configure JWT public keys in Kubernetes is using Kubernetes Secrets with the `k8s_secret` pattern: + +```yaml +--- +# Create a secret with your JWT public key +apiVersion: v1 +kind: Secret +metadata: + name: jwt-pubkey-secret + namespace: production +type: Opaque +stringData: + pubkey: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY----- + +--- +# Reference it in your ingress +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-ingress + namespace: production + annotations: + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + # Load public key from Kubernetes secret + easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret" + easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" + easyhaproxy.plugin.jwt_validator.only_paths: "false" +spec: + ingressClassName: easyhaproxy + rules: + - host: api.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-service + port: + number: 8080 +``` + +**With explicit secret key name:** + +```yaml +metadata: + annotations: + # Use custom key name from the secret + easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret/rsa-public-key" +``` + +For complete details about the `k8s_secret` pattern, including auto-detect vs explicit key names, troubleshooting, and security considerations, see: [Loading Plugin Configuration from Kubernetes Secrets](../kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets) + +### Kubernetes with pubkey_path (Legacy) + +You can also mount the public key file using ConfigMaps or volumes: ```yaml apiVersion: networking.k8s.io/v1 @@ -135,11 +208,13 @@ metadata: easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" easyhaproxy.plugin.jwt_validator.only_paths: "false" spec: + ingressClassName: easyhaproxy rules: - host: api.example.com http: paths: - path: / + pathType: Prefix backend: service: name: api-service @@ -147,6 +222,8 @@ spec: number: 8080 ``` +**Note:** This requires mounting the public key file into the EasyHAProxy pod using ConfigMaps or volumes. Using `k8s_secret.pubkey` is recommended as it's simpler and more secure. + ### Static YAML Configuration ```yaml diff --git a/docs/kubernetes.md b/docs/kubernetes.md index fe8afc1..d220751 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -245,6 +245,219 @@ env: For more information on plugin types and available plugins, see the [Using Plugins](plugins.md) guide. +## Loading Plugin Configuration from Kubernetes Secrets + +EasyHAProxy supports loading sensitive plugin configuration values directly from Kubernetes Secrets using the `k8s_secret` pattern. This is a **generic, plugin-agnostic feature** that works with any plugin. + +### Why Use Kubernetes Secrets? + +- **Security**: Keep sensitive data (API keys, passwords, certificates) out of annotations +- **Best practices**: Follows Kubernetes conventions for managing sensitive data +- **Simplicity**: No need to mount volumes or ConfigMaps for secret data +- **Encryption**: Secrets are encrypted at rest in etcd + +### Annotation Format + +```yaml +# Auto-detect key (tries common variations): +easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name" + +# Explicit key (no variations): +easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name/key_name" +``` + +### How It Works + +1. **You create** a Kubernetes Secret with your sensitive data +2. **You reference** the secret in your ingress annotation using the `k8s_secret` pattern +3. **EasyHAProxy reads** the secret from the same namespace as the ingress +4. **EasyHAProxy transforms** the annotation to inject the secret value +5. **The plugin receives** the value as if it was provided directly in the annotation + +**Example transformation:** + +```yaml +# Input annotation: +easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret" + +# EasyHAProxy reads the secret and transforms to: +easyhaproxy.plugin.jwt_validator.pubkey: "" +``` + +### Auto-Detect vs Explicit Key + +#### Auto-Detect Key Format + +When you use `"secret_name"` (without `/`), EasyHAProxy tries to find the key automatically: + +```yaml +easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret" +``` + +EasyHAProxy will try these keys in order: +1. Exact match: `pubkey` +2. Common variations based on the config key name + +**Auto-detect key variations:** + +| Config Key | Tries (in order) | +|------------|---------------------------------------| +| `pubkey` | `pubkey`, `public-key`, `jwt.pub`, `tls.crt` | +| `password` | `password`, `pass`, `pwd` | +| `api_key` | `api_key`, `apikey`, `api-key`, `key` | + +#### Explicit Key Format + +When you use `"secret_name/key_name"` (with `/`), EasyHAProxy only tries the exact key name: + +```yaml +easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret/rsa-public-key" +``` + +EasyHAProxy will **only** try: `rsa-public-key` (no variations) + +**Use explicit key when:** +- Your secret uses a non-standard key name +- You want to be explicit and avoid ambiguity +- Multiple keys exist in the secret + +### Complete Example + +```yaml +--- +# 1. Create a secret with your JWT public key +apiVersion: v1 +kind: Secret +metadata: + name: jwt-pubkey-secret + namespace: production +type: Opaque +stringData: + pubkey: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY----- + +--- +# 2. Reference it in your ingress +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-ingress + namespace: production + annotations: + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + # Load pubkey from Kubernetes secret (auto-detect key) + easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret" +spec: + ingressClassName: easyhaproxy + rules: + - host: api.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-service + port: + number: 8080 +``` + +### Example with Explicit Key Name + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: app-credentials + namespace: production +type: Opaque +stringData: + # Custom key name + rsa-public-key: | + -----BEGIN PUBLIC KEY----- + ... + -----END PUBLIC KEY----- + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-ingress + namespace: production + annotations: + easyhaproxy.plugins: "jwt_validator" + # Use explicit key name after the slash + easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "app-credentials/rsa-public-key" +spec: + ingressClassName: easyhaproxy + # ... rest of configuration +``` + +### Using with Any Plugin + +The `k8s_secret` pattern works with **any plugin configuration**: + +```yaml +# JWT Validator - load public key +easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-secret" + +# Hypothetical API auth plugin - load API key +easyhaproxy.plugin.api_auth.k8s_secret.api_key: "api-credentials/key" + +# Hypothetical basic auth plugin - load password +easyhaproxy.plugin.basic_auth.k8s_secret.password: "auth-secret/pwd" +``` + +### Priority Order + +When multiple configuration methods are used, this is the priority (highest to lowest): + +1. **Explicit annotation** (e.g., `easyhaproxy.plugin.jwt_validator.pubkey: "value"`) +2. **k8s_secret annotation** (e.g., `easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret"`) + +Explicit annotations always take precedence over `k8s_secret` annotations. + +### Troubleshooting + +**Secret not found:** +``` +WARNING: Ingress production/api-ingress - Failed to process k8s_secret annotation +'easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey' with value 'jwt-secret': ... +``` +- Verify the secret exists: `kubectl get secret jwt-secret -n production` +- Check the secret is in the same namespace as the ingress + +**Key not found in secret:** +``` +WARNING: Ingress production/api-ingress - Secret 'jwt-secret' found but no matching +key (tried: pubkey, public-key, jwt.pub, tls.crt) +``` +- List secret keys: `kubectl get secret jwt-secret -n production -o jsonpath='{.data}'` +- Use explicit key format: `"jwt-secret/actual-key-name"` + +**Check EasyHAProxy logs:** +```bash +kubectl logs -n easyhaproxy -l app=easyhaproxy --tail=100 +``` + +Look for: +- `INFO: Loaded 'pubkey' from secret 'jwt-secret'` (success) +- `WARNING: Secret 'xyz' found but no matching key` (key not found) + +### Security Considerations + +- Secrets are read from the **same namespace** as the ingress (no cross-namespace access) +- EasyHAProxy needs RBAC permissions to read secrets (included in default deployment) +- Secrets are encrypted at rest in etcd +- Secret values are base64-encoded by Kubernetes automatically +- Use Kubernetes RBAC to control which service accounts can read which secrets + ## Certbot / ACME / Letsencrypt It is necessary to add the annotation `easyhaproxy.certbot` to the ingress configuration: diff --git a/examples/kubernetes/.gitignore b/examples/kubernetes/.gitignore new file mode 100644 index 0000000..c731732 --- /dev/null +++ b/examples/kubernetes/.gitignore @@ -0,0 +1,9 @@ +# kind installation directory +.kind/ + +# kubectl config +kubeconfig + +# Test artifacts +*.log +service_tls_generated.yml \ No newline at end of file diff --git a/examples/kubernetes/ip-whitelist.yml b/examples/kubernetes/ip-whitelist.yml index a92334b..f36b2f5 100644 --- a/examples/kubernetes/ip-whitelist.yml +++ b/examples/kubernetes/ip-whitelist.yml @@ -100,7 +100,8 @@ metadata: # Allow specific IPs and networks # UPDATE THIS with your actual office/VPN IPs! - easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.42,10.0.0.0/8" + # For testing: includes localhost and Docker/Kubernetes private networks + easyhaproxy.plugin.ip_whitelist.allowed_ips: "127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,203.0.113.0/24,198.51.100.42" # Status code to return for blocked IPs easyhaproxy.plugin.ip_whitelist.status_code: "403" @@ -114,9 +115,10 @@ spec: - host: admin.example.local http: paths: - - backend: + - path: / + pathType: Prefix + backend: service: name: admin-service port: number: 8080 - pathType: ImplementationSpecific diff --git a/examples/kubernetes/jwt-validator-secret-example.yml b/examples/kubernetes/jwt-validator-secret-example.yml new file mode 100644 index 0000000..d55f2ef --- /dev/null +++ b/examples/kubernetes/jwt-validator-secret-example.yml @@ -0,0 +1,135 @@ +# Example demonstrating JWT validator with Kubernetes secret +# This shows the recommended way to provide JWT public keys in Kubernetes +# +# IMPORTANT: Before applying this manifest, generate JWT keys by running: +# cd /path/to/examples && bash generate-keys.sh +# +# Then create the secrets with your generated keys: +# kubectl create secret generic jwt-pubkey-secret \ +# --from-file=pubkey=docker/jwt_pubkey.pem -n default +# kubectl create secret generic jwt-custom-secret \ +# --from-file=rsa-public-key=docker/jwt_pubkey.pem -n default +# +# TWO ANNOTATION FORMATS: +# 1. Auto-detect key (tries common variations): +# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret" +# Tries keys: pubkey, public-key, jwt.pub, tls.crt +# +# 2. Explicit key (no variations): +# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret/rsa-public-key" +# Only tries key: rsa-public-key + +--- +# NOTE: Secrets should be created separately using your generated JWT keys +# See instructions at the top of this file +# The test fixture creates these secrets automatically + +--- +# Deployment for API service +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "Protected API - JWT Required" + +--- +# Service to be protected with JWT +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: default +spec: + selector: + app: api + ports: + - port: 8080 + targetPort: 8080 + +--- +# Ingress Example 1: Auto-detect key (uses standard key name "pubkey") +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-ingress-jwt-auto + namespace: default + annotations: + # Enable JWT validator plugin + easyhaproxy.plugins: "jwt_validator" + + # JWT validator configuration + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + + # Auto-detect: tries pubkey, public-key, jwt.pub, tls.crt + easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret" +spec: + ingressClassName: easyhaproxy + rules: + - host: api.example.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-service + port: + number: 8080 + +--- +# Ingress Example 2: Explicit key (uses custom key name "rsa-public-key") +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-ingress-jwt-explicit + namespace: default + annotations: + # Enable JWT validator plugin + easyhaproxy.plugins: "jwt_validator" + + # JWT validator configuration + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + + # Explicit key: only tries "rsa-public-key" from the secret + easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-custom-secret/rsa-public-key" + + # Optional: Protect only specific paths + # easyhaproxy.plugin.jwt_validator.paths: "/api,/admin" + + # Optional: Allow anonymous access (JWT validated only if present) + # easyhaproxy.plugin.jwt_validator.allow_anonymous: "true" +spec: + ingressClassName: easyhaproxy + rules: + - host: api-custom.example.local + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-service + port: + number: 8080 diff --git a/examples/kubernetes/jwt-validator.yml b/examples/kubernetes/jwt-validator.yml index f49f0a7..c31077a 100644 --- a/examples/kubernetes/jwt-validator.yml +++ b/examples/kubernetes/jwt-validator.yml @@ -2,6 +2,24 @@ # EXAMPLE: JWT Validator Plugin for Kubernetes # ============================================================================== # +# JWT PUBLIC KEY CONFIGURATION OPTIONS: +# There are three ways to provide the JWT public key: +# +# 1. pubkey_path - Mount a file and reference the path (requires ConfigMap or Volume) +# easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" +# +# 2. k8s_secret.pubkey - Reference a Kubernetes secret (RECOMMENDED) +# Auto-detect key (tries common variations): +# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret" +# Explicit key (no variations): +# easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret/custom-key-name" +# See jwt-validator-secret-example.yml for full example +# +# 3. pubkey - Inline base64-encoded key (for testing only, not recommended for production) +# easyhaproxy.plugin.jwt_validator.pubkey: "LS0tLS1CRUdJTi..." +# +# This example shows option #1 (pubkey_path) for backward compatibility +# # WHAT THIS DEMONSTRATES: # - JWT token validation for API protection in Kubernetes # - RS256 algorithm signature verification diff --git a/examples/kubernetes/service.yml b/examples/kubernetes/service.yml index 9770541..5011132 100644 --- a/examples/kubernetes/service.yml +++ b/examples/kubernetes/service.yml @@ -66,21 +66,23 @@ spec: - host: example.org http: paths: - - backend: + - path: / + pathType: Prefix + backend: service: name: container-example port: number: 8080 - pathType: ImplementationSpecific - host: www.example.org http: paths: - - backend: + - path: / + pathType: Prefix + backend: service: name: container-example port: number: 8080 - pathType: ImplementationSpecific --- apiVersion: v1 diff --git a/examples/kubernetes/service_tls.yml b/examples/kubernetes/service_tls.yml index 11398cd..7644021 100644 --- a/examples/kubernetes/service_tls.yml +++ b/examples/kubernetes/service_tls.yml @@ -71,12 +71,13 @@ spec: - host: host2.local http: paths: - - backend: + - path: / + pathType: Prefix + backend: service: name: tls-example port: number: 8080 - pathType: ImplementationSpecific --- apiVersion: v1 diff --git a/examples/kubernetes/setup-cluster.sh b/examples/kubernetes/setup-cluster.sh new file mode 100755 index 0000000..498fcc9 --- /dev/null +++ b/examples/kubernetes/setup-cluster.sh @@ -0,0 +1,169 @@ +#!/bin/bash +set -e + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +CLUSTER_NAME="easyhaproxy-test" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="${SCRIPT_DIR}/.kind" +KIND_BIN="${BIN_DIR}/kind" +KUBECTL_BIN="${BIN_DIR}/kubectl" +HELM_BIN="${BIN_DIR}/helm" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +# Port configuration (matches test_kubernetes.py) +HTTP_PORT=10080 +HTTPS_PORT=10443 +STATS_PORT=11936 + +echo -e "${BLUE}[1/9] Setting up kind cluster '${CLUSTER_NAME}'...${NC}" + +# Ensure kind is installed +if [ ! -f "${KIND_BIN}" ]; then + echo "Installing kind locally..." + mkdir -p "${BIN_DIR}" + curl -Lo "${KIND_BIN}" "https://kind.sigs.k8s.io/dl/v0.20.0/kind-linux-amd64" + chmod +x "${KIND_BIN}" + echo -e "${GREEN}✓ kind installed to ${KIND_BIN}${NC}" +fi + +# Ensure kubectl is installed +if ! command -v kubectl &> /dev/null; then + if [ ! -f "${KUBECTL_BIN}" ]; then + echo "Installing kubectl locally..." + mkdir -p "${BIN_DIR}" + VERSION=$(curl -L -s https://dl.k8s.io/release/stable.txt) + curl -Lo "${KUBECTL_BIN}" "https://dl.k8s.io/release/${VERSION}/bin/linux/amd64/kubectl" + chmod +x "${KUBECTL_BIN}" + echo -e "${GREEN}✓ kubectl installed to ${KUBECTL_BIN}${NC}" + fi + KUBECTL="${KUBECTL_BIN}" +else + KUBECTL="kubectl" +fi + +# Ensure helm is installed +if ! command -v helm &> /dev/null; then + if [ ! -f "${HELM_BIN}" ]; then + echo "Installing helm locally..." + mkdir -p "${BIN_DIR}" + HELM_VERSION="v3.13.3" + HELM_TAR="${BIN_DIR}/helm.tar.gz" + curl -Lo "${HELM_TAR}" "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" + tar -xzf "${HELM_TAR}" -C "${BIN_DIR}" --strip-components=1 linux-amd64/helm + rm "${HELM_TAR}" + chmod +x "${HELM_BIN}" + echo -e "${GREEN}✓ helm installed to ${HELM_BIN}${NC}" + fi + HELM="${HELM_BIN}" +else + HELM="helm" +fi + +# Check if cluster already exists +echo -e "${BLUE}[1/9] Checking for existing cluster...${NC}" +if ${KIND_BIN} get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + echo -e "${BLUE}Cluster '${CLUSTER_NAME}' already exists, deleting it first...${NC}" + ${KIND_BIN} delete cluster --name "${CLUSTER_NAME}" +fi + +# Create cluster config +echo -e "${BLUE}[1/9] Writing cluster config...${NC}" +CLUSTER_CONFIG="${BIN_DIR}/cluster-config.yaml" +mkdir -p "${BIN_DIR}" +cat > "${CLUSTER_CONFIG}" < "${MANIFEST_PATH}" + +# Install EasyHAProxy +echo -e "${BLUE}[7/9] Creating easyhaproxy namespace...${NC}" +${KUBECTL} create namespace easyhaproxy + +echo -e "${BLUE}[7/9] Applying EasyHAProxy manifest...${NC}" +${KUBECTL} apply -f "${MANIFEST_PATH}" + +# Label the control-plane node +echo -e "${BLUE}[8/9] Labeling control-plane node...${NC}" +${KUBECTL} label nodes "${CLUSTER_NAME}-control-plane" \ + "easyhaproxy/node=master" --overwrite + +# Wait for EasyHAProxy to be ready +echo -e "${BLUE}[9/9] Waiting for EasyHAProxy pods to be ready...${NC}" +if ${KUBECTL} wait --for=condition=Ready pods \ + -n easyhaproxy -l "app.kubernetes.io/name=easyhaproxy" \ + --timeout=30s 2>/dev/null; then + echo -e "${GREEN}✓ EasyHAProxy pods are ready${NC}" +else + echo -e "${RED}✗ Pods not ready within 30s. Checking status...${NC}" + ${KUBECTL} get pods -n easyhaproxy -o wide + echo -e "\n${BLUE}Events:${NC}" + ${KUBECTL} get events -n easyhaproxy --sort-by=.lastTimestamp + exit 1 +fi + +echo -e "${GREEN}✓ All setup complete! Cluster is ready.${NC}" +echo "" +echo -e "${BLUE}Cluster Information:${NC}" +echo -e " Cluster name: ${CLUSTER_NAME}" +echo -e " HTTP port: localhost:${HTTP_PORT}" +echo -e " HTTPS port: localhost:${HTTPS_PORT}" +echo -e " Stats port: localhost:${STATS_PORT}" +echo "" +echo -e "${BLUE}Useful commands:${NC}" +echo -e " Apply example ingress: ${KUBECTL} apply -f ${SCRIPT_DIR}/service.yml" +echo -e " Check EasyHAProxy logs: ${KUBECTL} logs -n easyhaproxy -l app.kubernetes.io/name=easyhaproxy -f" +echo -e " Test with curl: curl -H 'Host: example.org' http://localhost:${HTTP_PORT}" +echo -e " Delete cluster: ${SCRIPT_DIR}/teardown-cluster.sh" +echo "" \ No newline at end of file diff --git a/examples/kubernetes/teardown-cluster.sh b/examples/kubernetes/teardown-cluster.sh new file mode 100755 index 0000000..f364e7a --- /dev/null +++ b/examples/kubernetes/teardown-cluster.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +CLUSTER_NAME="easyhaproxy-test" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="${SCRIPT_DIR}/.kind" +KIND_BIN="${BIN_DIR}/kind" + +# Check if kind binary exists +if [ ! -f "${KIND_BIN}" ]; then + # Try to use system kind + if command -v kind &> /dev/null; then + KIND_BIN="kind" + else + echo -e "${RED}✗ kind binary not found. Cannot delete cluster.${NC}" + echo " Cluster may not exist or kind is not installed." + exit 1 + fi +fi + +# Check if cluster exists +if ! ${KIND_BIN} get clusters 2>/dev/null | grep -q "^${CLUSTER_NAME}$"; then + echo -e "${BLUE}Cluster '${CLUSTER_NAME}' does not exist. Nothing to delete.${NC}" + exit 0 +fi + +echo -e "${BLUE}Deleting kind cluster '${CLUSTER_NAME}'...${NC}" + +if ${KIND_BIN} delete cluster --name "${CLUSTER_NAME}"; then + echo -e "${GREEN}✓ Cluster deleted successfully${NC}" +else + echo -e "${RED}✗ Failed to delete cluster${NC}" + exit 1 +fi \ No newline at end of file diff --git a/examples/kubernetes/test_kubernetes.py b/examples/kubernetes/test_kubernetes.py new file mode 100644 index 0000000..eece6de --- /dev/null +++ b/examples/kubernetes/test_kubernetes.py @@ -0,0 +1,1697 @@ +""" +Kubernetes Integration Tests for EasyHAProxy + +This test suite validates EasyHAProxy Kubernetes examples using kind (Kubernetes IN Docker). + +Requirements: +- kind (Kubernetes IN Docker) installed locally in .kind/ +- kubectl installed +- Docker running +- PyJWT and cryptography libraries (for JWT tests): pip install pyjwt cryptography + +Usage: + pytest test_kubernetes.py -v + pytest test_kubernetes.py::TestBasicService -v + pytest test_kubernetes.py::TestJWTValidatorSecret -v +""" + +import base64 +import json +import os +import re +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Generator +import urllib.request +import pytest +import requests + +# Import JWT libraries for token generation +try: + import jwt + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.backends import default_backend + JWT_AVAILABLE = True +except ImportError: + JWT_AVAILABLE = False + + +# Base directory for Kubernetes manifests +BASE_DIR = Path(__file__).parent.absolute() +BIN_DIR = BASE_DIR / ".kind" +KIND_BIN = BIN_DIR / "kind" +KUBECTL_BIN = BIN_DIR / "kubectl" +HELM_BIN = BIN_DIR / "helm" + +# Port configuration for kind cluster +# These ports map from localhost to the kind cluster +HTTP_PORT = 10080 # HTTP traffic (localhost:10080 -> cluster:80) +HTTPS_PORT = 10443 # HTTPS traffic (localhost:10443 -> cluster:443) +STATS_PORT = 11936 # HAProxy stats (localhost:11936 -> cluster:1936) + + +# ============================================================================= +# Helper Functions +# ============================================================================= +def get_latest_kind_version(): + url = "https://api.github.com/repos/kubernetes-sigs/kind/releases/latest" + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json"}) + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + return data["tag_name"] # e.g. "v0.27.0" + +def get_latest_helm_version(): + url = "https://api.github.com/repos/helm/helm/releases/latest" + req = urllib.request.Request(url, headers={"Accept": "application/vnd.github.v3+json"}) + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + return data["tag_name"] # e.g. "v3.17.3" + +def ensure_kind_installed(): + """Ensure kind is installed locally in .kind/""" + if KIND_BIN.exists(): + return str(KIND_BIN) + + print("Installing kind locally...") + BIN_DIR.mkdir(exist_ok=True) + + # Download kind + version = get_latest_kind_version() + subprocess.run( + ["curl", "-Lo", str(KIND_BIN), + f"https://kind.sigs.k8s.io/dl/{version}/kind-linux-amd64"], + check=True, + capture_output=True + ) + + # Make executable + KIND_BIN.chmod(0o755) + + print(f"✓ kind {version} installed to {KIND_BIN}") + return str(KIND_BIN) + + +def ensure_kubectl_installed(): + """Ensure kubectl is installed locally in .kind/""" + # Check if kubectl exists globally first + try: + subprocess.run( + ["kubectl", "version", "--client"], + check=True, + capture_output=True, + timeout=5 + ) + return "kubectl" # Use global kubectl + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + pass + + # Use local kubectl if it exists + if KUBECTL_BIN.exists(): + return str(KUBECTL_BIN) + + print("Installing kubectl locally...") + BIN_DIR.mkdir(exist_ok=True) + + # Get latest stable version + result = subprocess.run( + ["curl", "-L", "-s", "https://dl.k8s.io/release/stable.txt"], + check=True, + capture_output=True, + text=True + ) + version = result.stdout.strip() + + # Download kubectl + subprocess.run( + ["curl", "-Lo", str(KUBECTL_BIN), + f"https://dl.k8s.io/release/{version}/bin/linux/amd64/kubectl"], + check=True, + capture_output=True + ) + + # Make executable + KUBECTL_BIN.chmod(0o755) + + print(f"✓ kubectl {version} installed to {KUBECTL_BIN}") + return str(KUBECTL_BIN) + + +def ensure_helm_installed(): + """Ensure helm is installed locally in .kind/""" + # Check if helm exists globally first + try: + subprocess.run( + ["helm", "version"], + check=True, + capture_output=True, + timeout=5 + ) + return "helm" # Use global helm + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + pass + + # Use local helm if it exists + if HELM_BIN.exists(): + return str(HELM_BIN) + + print("Installing helm locally...") + BIN_DIR.mkdir(exist_ok=True) + + # Download and extract helm + helm_version = get_latest_helm_version() + helm_tar = BIN_DIR / "helm.tar.gz" + + subprocess.run( + ["curl", "-Lo", str(helm_tar), + f"https://get.helm.sh/helm-{helm_version}-linux-amd64.tar.gz"], + check=True, + capture_output=True + ) + + # Extract helm binary + subprocess.run( + ["tar", "-xzf", str(helm_tar), "-C", str(BIN_DIR), + "--strip-components=1", "linux-amd64/helm"], + check=True, + capture_output=True + ) + + # Remove tar file + helm_tar.unlink() + + # Make executable + HELM_BIN.chmod(0o755) + + print(f"✓ helm {helm_version} installed to {HELM_BIN}") + return str(HELM_BIN) + + +# ============================================================================= +# Session Fixtures - kind Cluster Management +# ============================================================================= + +@pytest.fixture(scope="session") +def generated_certs(): + """ + Generate SSL certificates and JWT keys once for the entire test session. + This runs the generate-keys.sh script from the examples directory. + + Returns: + dict: Paths to generated certificate files + """ + print("\n[Setup] Generating SSL certificates and JWT keys...") + + # Path to generate-keys.sh + examples_dir = BASE_DIR.parent + generate_keys_script = examples_dir / "generate-keys.sh" + + if not generate_keys_script.exists(): + raise FileNotFoundError(f"generate-keys.sh not found at {generate_keys_script}") + + # Run the script + result = subprocess.run( + ["bash", str(generate_keys_script)], + cwd=str(examples_dir), + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode != 0: + print(f"✗ Certificate generation failed: {result.stderr}") + raise RuntimeError(f"Failed to generate certificates: {result.stderr}") + + print("✓ SSL certificates and JWT keys generated") + + # Return paths to generated files + return { + "host1_local": examples_dir / "static" / "host1.local.pem", + "host2_local": examples_dir / "docker" / "host2.local.pem", + "jwt_private": examples_dir / "docker" / "jwt_private.pem", + "jwt_pubkey": examples_dir / "docker" / "jwt_pubkey.pem", + } + + +@pytest.fixture(scope="session") +def kubectl_cmd(): + """Ensure kubectl is installed and return the command""" + return ensure_kubectl_installed() + + +@pytest.fixture(scope="session") +def kind_cmd(): + """Ensure kind is installed and return the command""" + return ensure_kind_installed() + + +@pytest.fixture(scope="session") +def helm_cmd(): + """Ensure helm is installed and return the command""" + return ensure_helm_installed() + + +@pytest.fixture(scope="session") +def kind_cluster(kind_cmd, kubectl_cmd, helm_cmd, generated_certs, request): + """ + Create a kind cluster for the entire test session. + The cluster is shared across all tests for better performance. + + Args: + generated_certs: Fixture that ensures certificates are generated before cluster creation + """ + cluster_name = "easyhaproxy-test" + + # Register cleanup to always run, even on failure + def cleanup(): + print(f"\n[Cleanup] Deleting kind cluster '{cluster_name}'...") + try: + subprocess.run( + [kind_cmd, "delete", "cluster", "--name", cluster_name], + capture_output=True, + timeout=10 + ) + print("✓ Cluster deleted") + except subprocess.TimeoutExpired: + print("✗ Cluster deletion timed out (may still be running)") + except Exception as e: + print(f"✗ Cluster deletion failed: {e}") + + request.addfinalizer(cleanup) + + print(f"\n[1/9] Creating kind cluster '{cluster_name}'...") + + # Check if cluster already exists + print("[1/9] Checking for existing cluster...") + result = subprocess.run( + [kind_cmd, "get", "clusters"], + capture_output=True, + text=True + ) + + if cluster_name in result.stdout: + print(f"[1/9] Cluster '{cluster_name}' already exists, checking if it's healthy...") + # Check if cluster is healthy by trying to get nodes + result = subprocess.run( + [kubectl_cmd, "--context", f"kind-{cluster_name}", "get", "nodes"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + print(f"[1/9] Cluster '{cluster_name}' is healthy, reusing it...") + print("✓ Reusing existing cluster\n") + # Skip to yield, return context + yield {"name": cluster_name, "kubectl": kubectl_cmd, "certs": generated_certs} + return + else: + print(f"[1/9] Cluster '{cluster_name}' is unhealthy, deleting and recreating...") + subprocess.run( + [kind_cmd, "delete", "cluster", "--name", cluster_name], + check=True, + capture_output=True + ) + + # Create cluster with port mappings for HAProxy + print("[1/9] Writing cluster config...") + cluster_config = BIN_DIR / "cluster-config.yaml" + cluster_config.parent.mkdir(exist_ok=True) + + with open(cluster_config, 'w') as f: + f.write(f"""kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + extraPortMappings: + - containerPort: 80 + hostPort: {HTTP_PORT} + protocol: TCP + - containerPort: 443 + hostPort: {HTTPS_PORT} + protocol: TCP + - containerPort: 1936 + hostPort: {STATS_PORT} + protocol: TCP +""") + + print("[2/9] Creating kind cluster (this may take 1-2 minutes)...") + subprocess.run( + [kind_cmd, "create", "cluster", "--name", cluster_name, + "--config", str(cluster_config)], + check=True, + timeout=180 + ) + + print("[3/9] Setting kubectl context...") + subprocess.run( + [kubectl_cmd, "config", "use-context", f"kind-{cluster_name}"], + check=True, + capture_output=True + ) + + print("[3/9] Waiting for cluster nodes to be ready...") + subprocess.run( + [kubectl_cmd, "wait", "--for=condition=Ready", "nodes", "--all", + "--timeout=30s"], + check=True, + timeout=35 + ) + + print(f"✓ kind cluster '{cluster_name}' is ready") + + # Build and load local EasyHAProxy image + print("[4/9] Building local EasyHAProxy image (may take 30-60s)...") + project_root = BASE_DIR.parent.parent + subprocess.run( + ["docker", "build", "-t", "byjg/easy-haproxy:local", + "-f", str(project_root / "build" / "Dockerfile"), + str(project_root)], + check=True, + capture_output=True, + timeout=120 + ) + + print("[5/9] Loading image into kind cluster (may take 10-20s)...") + subprocess.run( + [kind_cmd, "load", "docker-image", "byjg/easy-haproxy:local", + "--name", cluster_name], + check=True, + timeout=30 + ) + + # Generate EasyHAProxy manifest using Helm + print("[6/9] Generating EasyHAProxy manifest from Helm...") + helm_dir = project_root / "helm" + manifest_path = BIN_DIR / "easyhaproxy-local.yml" + + result = subprocess.run( + [helm_cmd, "template", "ingress", str(helm_dir / "easyhaproxy"), + "--namespace", "easyhaproxy", + "--set", "service.create=false", + "--set", "image.tag=local", + "--set", "image.pullPolicy=Never"], + check=True, + capture_output=True, + text=True + ) + + # Write manifest to file + print("[6/9] Writing manifest to file...") + with open(manifest_path, 'w') as f: + f.write(result.stdout) + + # Install EasyHAProxy + print("[7/9] Creating easyhaproxy namespace...") + subprocess.run( + [kubectl_cmd, "create", "namespace", "easyhaproxy"], + check=True + ) + + # Apply manifest + print("[7/9] Applying EasyHAProxy manifest...") + subprocess.run( + [kubectl_cmd, "apply", "-f", str(manifest_path)], + check=True + ) + + # Label the control-plane node + print("[8/9] Labeling control-plane node...") + subprocess.run( + [kubectl_cmd, "label", "nodes", f"{cluster_name}-control-plane", + "easyhaproxy/node=master", "--overwrite"], + check=True + ) + + # Wait for EasyHAProxy to be ready + print("[9/9] Waiting for EasyHAProxy pods to be ready...") + try: + subprocess.run( + [kubectl_cmd, "wait", "--for=condition=Ready", "pods", + "-n", "easyhaproxy", "-l", "app.kubernetes.io/name=easyhaproxy", + "--timeout=10s"], + check=True, + capture_output=True, + text=True + ) + print("✓ EasyHAProxy pods are ready") + except subprocess.CalledProcessError as e: + # Show pod status for debugging + print("✗ Pods not ready within 10s. Checking status...") + result = subprocess.run( + [kubectl_cmd, "get", "pods", "-n", "easyhaproxy", "-o", "wide"], + capture_output=True, + text=True + ) + print(result.stdout) + + # Show pod events + result = subprocess.run( + [kubectl_cmd, "get", "events", "-n", "easyhaproxy", "--sort-by=.lastTimestamp"], + capture_output=True, + text=True + ) + print("Events:") + print(result.stdout) + raise + + print("✓ All setup complete! Cluster is ready for tests.\n") + + yield {"name": cluster_name, "kubectl": kubectl_cmd, "certs": generated_certs} + + +# ============================================================================= +# Test Fixtures - Kubernetes Manifest Deployment +# ============================================================================= + +class KubernetesFixture: + """Helper class to manage Kubernetes manifest lifecycle""" + + def __init__(self, manifest_file: str, kubectl_cmd: str, namespace: str = "default", wait_time: int = 5): + self.manifest_file = str(BASE_DIR / manifest_file) + self.kubectl = kubectl_cmd + self.namespace = namespace + self.wait_time = wait_time + + def apply(self): + """Apply Kubernetes manifest""" + # Create namespace if it doesn't exist + if self.namespace != "default": + subprocess.run( + [self.kubectl, "create", "namespace", self.namespace], + capture_output=True # Ignore if already exists + ) + + # Apply manifest + subprocess.run( + [self.kubectl, "apply", "-f", self.manifest_file, "-n", self.namespace], + check=True, + capture_output=True + ) + + # Wait for pods to be ready + time.sleep(self.wait_time) + + # Wait for all pods to be running + max_wait = 60 + start_time = time.time() + while time.time() - start_time < max_wait: + result = subprocess.run( + [self.kubectl, "get", "pods", "-n", self.namespace, "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + if not pods['items']: + time.sleep(2) + continue + + all_running = all( + pod['status']['phase'] == 'Running' + for pod in pods['items'] + ) + + if all_running: + print(f"✓ All pods running in namespace '{self.namespace}'") + break + + time.sleep(2) + + def delete(self): + """Delete Kubernetes resources""" + subprocess.run( + [self.kubectl, "delete", "-f", self.manifest_file, "-n", self.namespace, + "--ignore-not-found=true"], + check=True, + capture_output=True + ) + + # Delete namespace if not default + if self.namespace != "default": + subprocess.run( + [self.kubectl, "delete", "namespace", self.namespace, + "--ignore-not-found=true"], + capture_output=True + ) + + +def create_tls_secret_from_pem(kubectl_cmd: str, secret_name: str, namespace: str, pem_file: Path): + """ + Create a Kubernetes TLS secret from a PEM file. + + Args: + kubectl_cmd: Path to kubectl command + secret_name: Name for the secret + namespace: Namespace to create the secret in + pem_file: Path to the PEM file containing both certificate and key + """ + print(f" → Creating TLS secret '{secret_name}' from {pem_file.name}...") + + # Read the PEM file + with open(pem_file, 'r') as f: + pem_content = f.read() + + # Split certificate and key (PEM file contains both) + cert_start = pem_content.find('-----BEGIN CERTIFICATE-----') + cert_end = pem_content.find('-----END CERTIFICATE-----') + len('-----END CERTIFICATE-----') + key_start = pem_content.find('-----BEGIN PRIVATE KEY-----') + key_end = pem_content.find('-----END PRIVATE KEY-----') + len('-----END PRIVATE KEY-----') + + # Handle RSA PRIVATE KEY format (openssl genrsa format) + if key_start == -1: + key_start = pem_content.find('-----BEGIN RSA PRIVATE KEY-----') + key_end = pem_content.find('-----END RSA PRIVATE KEY-----') + len('-----END RSA PRIVATE KEY-----') + + if cert_start == -1 or key_start == -1: + raise ValueError(f"Invalid PEM file format in {pem_file}") + + cert = pem_content[cert_start:cert_end] + key = pem_content[key_start:key_end] + + # Create temp files for cert and key + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.crt', delete=False) as cert_file: + cert_file.write(cert) + cert_path = cert_file.name + + with tempfile.NamedTemporaryFile(mode='w', suffix='.key', delete=False) as key_file: + key_file.write(key) + key_path = key_file.name + + try: + # Delete secret if it exists + subprocess.run( + [kubectl_cmd, "delete", "secret", secret_name, "-n", namespace, + "--ignore-not-found=true"], + capture_output=True + ) + + # Create secret using kubectl + subprocess.run( + [kubectl_cmd, "create", "secret", "tls", secret_name, + f"--cert={cert_path}", + f"--key={key_path}", + "-n", namespace], + check=True, + capture_output=True + ) + + print(f" ✓ TLS secret '{secret_name}' created") + finally: + # Clean up temp files + os.unlink(cert_path) + os.unlink(key_path) + + +@pytest.fixture +def k8s_service(kind_cluster) -> Generator[str, None, None]: + """Fixture for service.yml""" + kubectl_cmd = kind_cluster["kubectl"] + fixture = KubernetesFixture("service.yml", kubectl_cmd, namespace="default") + fixture.apply() + yield kubectl_cmd + fixture.delete() + + +@pytest.fixture +def k8s_ip_whitelist(kind_cluster) -> Generator[str, None, None]: + """Fixture for ip-whitelist.yml""" + kubectl_cmd = kind_cluster["kubectl"] + fixture = KubernetesFixture("ip-whitelist.yml", kubectl_cmd, namespace="default") + fixture.apply() + yield kubectl_cmd + fixture.delete() + + +@pytest.fixture +def k8s_service_tls(kind_cluster) -> Generator[str, None, None]: + """Fixture for service_tls.yml with generated certificates""" + kubectl_cmd = kind_cluster["kubectl"] + generated_certs = kind_cluster["certs"] + + # Create namespace if it doesn't exist + subprocess.run( + [kubectl_cmd, "create", "namespace", "default"], + capture_output=True # Ignore if already exists + ) + + # Create TLS secret from generated certificate + create_tls_secret_from_pem( + kubectl_cmd, + secret_name="host2-tls", + namespace="default", + pem_file=generated_certs["host2_local"] + ) + + # Apply the manifest (without the embedded secret, we'll use ours) + # We need to filter out the Secret from service_tls.yml + manifest_path = BASE_DIR / "service_tls.yml" + with open(manifest_path, 'r') as f: + manifest_content = f.read() + + # Remove the Secret section from the manifest + # Remove everything between "kind: Secret" and the next "---" or end of file + manifest_filtered = re.sub( + r'^---\s*\napiVersion: v1\s*\nkind: Secret\s*\n.*?(?=^---|\Z)', + '', + manifest_content, + flags=re.MULTILINE | re.DOTALL + ) + + # Write filtered manifest to temp file in the same directory + temp_manifest_path = BASE_DIR / "service_tls_generated.yml" + with open(temp_manifest_path, 'w') as f: + f.write(manifest_filtered) + + try: + # Apply manifest using kubectl directly since we have a custom path + subprocess.run( + [kubectl_cmd, "apply", "-f", str(temp_manifest_path), "-n", "default"], + check=True, + capture_output=True + ) + + # Wait for pods to be ready + time.sleep(5) + + # Wait for all pods to be running + max_wait = 60 + start_time = time.time() + while time.time() - start_time < max_wait: + result = subprocess.run( + [kubectl_cmd, "get", "pods", "-n", "default", "-l", "app=tls-example", "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + if not pods['items']: + time.sleep(2) + continue + + all_running = all( + pod['status']['phase'] == 'Running' + for pod in pods['items'] + ) + + if all_running: + print("✓ All TLS example pods running") + break + + time.sleep(2) + + yield kubectl_cmd + + # Cleanup + subprocess.run( + [kubectl_cmd, "delete", "-f", str(temp_manifest_path), "-n", "default", + "--ignore-not-found=true"], + check=True, + capture_output=True + ) + finally: + # Clean up temp manifest + if temp_manifest_path.exists(): + os.unlink(temp_manifest_path) + + # Delete the TLS secret + subprocess.run( + [kubectl_cmd, "delete", "secret", "host2-tls", "-n", "default", + "--ignore-not-found=true"], + capture_output=True + ) + + +@pytest.fixture +def k8s_jwt_validator_secret(kind_cluster) -> Generator[dict, None, None]: + """Fixture for jwt-validator-secret-example.yml with generated JWT keys""" + if not JWT_AVAILABLE: + pytest.skip("PyJWT not available - install with: pip install pyjwt cryptography") + + kubectl_cmd = kind_cluster["kubectl"] + generated_certs = kind_cluster["certs"] + + # Create namespace if it doesn't exist + subprocess.run( + [kubectl_cmd, "create", "namespace", "default"], + capture_output=True # Ignore if already exists + ) + + # Read the generated JWT public key + with open(generated_certs["jwt_pubkey"], 'r') as f: + jwt_pubkey_content = f.read() + + # Create JWT secrets using kubectl + print(" → Creating JWT secret 'jwt-pubkey-secret'...") + subprocess.run( + [kubectl_cmd, "delete", "secret", "jwt-pubkey-secret", "-n", "default", + "--ignore-not-found=true"], + capture_output=True + ) + subprocess.run( + [kubectl_cmd, "create", "secret", "generic", "jwt-pubkey-secret", + f"--from-literal=pubkey={jwt_pubkey_content}", + "-n", "default"], + check=True, + capture_output=True + ) + + print(" → Creating JWT secret 'jwt-custom-secret'...") + subprocess.run( + [kubectl_cmd, "delete", "secret", "jwt-custom-secret", "-n", "default", + "--ignore-not-found=true"], + capture_output=True + ) + subprocess.run( + [kubectl_cmd, "create", "secret", "generic", "jwt-custom-secret", + f"--from-literal=rsa-public-key={jwt_pubkey_content}", + "-n", "default"], + check=True, + capture_output=True + ) + + # Apply manifest + manifest_path = BASE_DIR / "jwt-validator-secret-example.yml" + subprocess.run( + [kubectl_cmd, "apply", "-f", str(manifest_path), "-n", "default"], + check=True, + capture_output=True + ) + + # Wait for pods to be ready + time.sleep(5) + + # Wait for all pods to be running + max_wait = 60 + start_time = time.time() + while time.time() - start_time < max_wait: + result = subprocess.run( + [kubectl_cmd, "get", "pods", "-n", "default", "-l", "app=api", "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + if not pods['items']: + time.sleep(2) + continue + + all_running = all( + pod['status']['phase'] == 'Running' + for pod in pods['items'] + ) + + if all_running: + print("✓ All JWT API example pods running") + break + + time.sleep(2) + + # Return context with paths to JWT keys + yield { + "kubectl": kubectl_cmd, + "jwt_private_key": generated_certs["jwt_private"], + "jwt_public_key": generated_certs["jwt_pubkey"] + } + + # Cleanup + subprocess.run( + [kubectl_cmd, "delete", "-f", str(manifest_path), "-n", "default", + "--ignore-not-found=true"], + check=True, + capture_output=True + ) + + # Delete the JWT secrets + subprocess.run( + [kubectl_cmd, "delete", "secret", "jwt-pubkey-secret", "-n", "default", + "--ignore-not-found=true"], + capture_output=True + ) + subprocess.run( + [kubectl_cmd, "delete", "secret", "jwt-custom-secret", "-n", "default", + "--ignore-not-found=true"], + capture_output=True + ) + + +# ============================================================================= +# Helper Functions for Tests +# ============================================================================= + +def wait_for_easyhaproxy_discovery(kubectl_cmd: str, expected_host: str, timeout: int = 10) -> bool: + """ + Wait for EasyHAProxy to discover and configure the ingress host. + + This function performs multiple checks to ensure the ingress is fully ready: + 1. Backend pods are Running + 2. Ingress has an ADDRESS assigned + 3. EasyHAProxy logs show the host was discovered + 4. Simple connectivity test to HAProxy + + Args: + kubectl_cmd: Path to kubectl command + expected_host: The hostname to look for in logs (e.g., "example.org") + timeout: Maximum seconds to wait (default 10) + + Returns: + True if host is discovered and ready, False if timeout + """ + start_time = time.time() + + # Step 1: Wait for backend pods to be Running (with ingress selector) + print(f" → Waiting for backend pods with host '{expected_host}' to be ready...") + while time.time() - start_time < timeout: + try: + # Get all ingresses + result = subprocess.run( + [kubectl_cmd, "get", "ingress", "-A", "-o", "json"], + capture_output=True, + text=True, + timeout=5, + check=True + ) + ingresses = json.loads(result.stdout) + + # Find ingress with our host + ingress_namespace = None + for ing in ingresses.get('items', []): + for rule in ing.get('spec', {}).get('rules', []): + if rule.get('host') == expected_host: + ingress_namespace = ing.get('metadata', {}).get('namespace') + break + if ingress_namespace: + break + + if ingress_namespace: + # Check if pods in that namespace are running + result = subprocess.run( + [kubectl_cmd, "get", "pods", "-n", ingress_namespace, "-o", "json"], + capture_output=True, + text=True, + timeout=5, + check=True + ) + pods = json.loads(result.stdout) + + all_running = all( + pod['status']['phase'] == 'Running' + for pod in pods.get('items', []) + ) + + if all_running and pods.get('items'): + print(f" ✓ Backend pods are Running") + break + except Exception: + pass + + time.sleep(1) + + # Step 2: Wait for ingress to have an ADDRESS assigned + print(f" → Waiting for ingress ADDRESS to be assigned...") + address_found = False + while time.time() - start_time < timeout: + try: + result = subprocess.run( + [kubectl_cmd, "get", "ingress", "-A", "-o", "json"], + capture_output=True, + text=True, + timeout=5, + check=True + ) + ingresses = json.loads(result.stdout) + + for ing in ingresses.get('items', []): + for rule in ing.get('spec', {}).get('rules', []): + if rule.get('host') == expected_host: + # Check if ingress has loadBalancer status + lb_ingress = ing.get('status', {}).get('loadBalancer', {}).get('ingress', []) + if lb_ingress: + print(f" ✓ Ingress has ADDRESS assigned") + address_found = True + break + if address_found: + break + + if address_found: + break + except Exception: + pass + + time.sleep(1) + + # Step 3: Wait for EasyHAProxy to discover the host in logs + print(f" → Waiting for EasyHAProxy to discover '{expected_host}'...") + while time.time() - start_time < timeout: + # Get EasyHAProxy pod logs + result = subprocess.run( + [kubectl_cmd, "logs", "-n", "easyhaproxy", "-l", "app.kubernetes.io/name=easyhaproxy", + "--tail=100"], + capture_output=True, + text=True, + timeout=5 + ) + + # Check if "Found hosts:" appears in logs with our expected host + if "Found hosts:" in result.stdout and expected_host in result.stdout: + print(f" ✓ EasyHAProxy discovered '{expected_host}' in logs") + break + + time.sleep(1) + + # Step 4: Simple connectivity check to HAProxy + print(f" → Testing connectivity to HAProxy...") + retries = 3 + for attempt in range(retries): + try: + result = subprocess.run( + ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", + "-H", f"Host: {expected_host}", f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=5 + ) + http_code = result.stdout.strip() + # Accept 200, 503 (backend may not be ready yet), or any response that proves HAProxy is responding + if http_code and http_code != "000": + print(f" ✓ HAProxy is responding (HTTP {http_code})") + # Give HAProxy a moment to stabilize after configuration reload + time.sleep(2) + return True + except Exception: + pass + + if attempt < retries - 1: + time.sleep(1) + + # Check if we timed out + if time.time() - start_time >= timeout: + print(f" ✗ Timeout waiting for '{expected_host}' to be ready") + return False + + return True + + +# ============================================================================= +# Test: service.yml - Basic Service +# ============================================================================= + +@pytest.mark.kubernetes +class TestBasicService: + """Tests for service.yml - Basic Kubernetes service""" + + def test_resources_created(self, k8s_service): + """Test that deployment, service, and ingress are created""" + kubectl = k8s_service + + # Check all resources exist + result = subprocess.run( + [kubectl, "get", "deployment,service,ingress", "container-example", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "container-example" in result.stdout + + def test_pods_running(self, k8s_service): + """Test that all pods are running""" + kubectl = k8s_service + + # Wait for deployment to be ready + subprocess.run( + [kubectl, "wait", "--for=condition=Available", "deployment/container-example", + "-n", "default", "--timeout=30s"], + check=True + ) + + result = subprocess.run( + [kubectl, "get", "pods", "-n", "default", "-l", "app=container-example", "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + assert len(pods['items']) > 0, "No container-example pods found" + + for pod in pods['items']: + assert pod['status']['phase'] == 'Running', \ + f"Pod {pod['metadata']['name']} is not running: {pod['status']['phase']}" + + def test_http_request_example_org(self, k8s_service): + """Test HTTP request via ingress with example.org""" + kubectl = k8s_service + + # Wait for EasyHAProxy to discover and fully configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "example.org", timeout=30), \ + "EasyHAProxy did not become ready for example.org within 30 seconds" + + # Test HTTP request + result = subprocess.run( + ["curl", "-s", "-H", "Host: example.org", f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert "My Host Example" in result.stdout, \ + f"Expected 'My Host Example' in response, got: {result.stdout}" + + def test_http_request_www_example_org(self, k8s_service): + """Test HTTP request via ingress with www.example.org""" + kubectl = k8s_service + + # Wait for EasyHAProxy to discover and fully configure the ingress + # (Even though example.org was checked in the previous test, we should verify www.example.org too) + assert wait_for_easyhaproxy_discovery(kubectl, "www.example.org", timeout=30), \ + "EasyHAProxy did not become ready for www.example.org within 30 seconds" + + # Test HTTP request + result = subprocess.run( + ["curl", "-s", "-H", "Host: www.example.org", f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert "My Host Example" in result.stdout, \ + f"Expected 'My Host Example' in response for www.example.org, got: {result.stdout}" + + +# ============================================================================= +# Test: service_tls.yml - TLS/SSL Service +# ============================================================================= + +@pytest.mark.kubernetes +class TestTLSService: + """Tests for service_tls.yml - TLS/SSL ingress with custom certificates""" + + def test_resources_created(self, k8s_service_tls): + """Test that deployment, service, ingress, and secret are created""" + kubectl = k8s_service_tls + + # Check deployment, service, and ingress exist + result = subprocess.run( + [kubectl, "get", "deployment,service,ingress", "tls-example", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "tls-example" in result.stdout + + # Verify TLS secret exists (separate check since it has different name) + result = subprocess.run( + [kubectl, "get", "secret", "host2-tls", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "host2-tls" in result.stdout + assert "kubernetes.io/tls" in result.stdout + + def test_pods_running(self, k8s_service_tls): + """Test that all pods are running""" + kubectl = k8s_service_tls + + # Wait for deployment to be ready + subprocess.run( + [kubectl, "wait", "--for=condition=Available", "deployment/tls-example", + "-n", "default", "--timeout=30s"], + check=True + ) + + result = subprocess.run( + [kubectl, "get", "pods", "-n", "default", "-l", "app=tls-example", "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + assert len(pods['items']) > 0, "No tls-example pods found" + + for pod in pods['items']: + assert pod['status']['phase'] == 'Running', \ + f"Pod {pod['metadata']['name']} is not running: {pod['status']['phase']}" + + def test_https_request_host2_local(self, k8s_service_tls): + """Test HTTPS request via ingress with host2.local""" + kubectl = k8s_service_tls + + # Wait for EasyHAProxy to discover and fully configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "host2.local", timeout=30), \ + "EasyHAProxy did not become ready for host2.local within 30 seconds" + + # Test HTTPS request (using -k to allow self-signed certificate) + result = subprocess.run( + ["curl", "-k", "-s", "-H", "Host: host2.local", f"https://localhost:{HTTPS_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert "My Host Example" in result.stdout, \ + f"Expected 'My Host Example' in response, got: {result.stdout}" + + +# ============================================================================= +# Test: ip-whitelist.yml - IP Whitelist Plugin +# ============================================================================= + +@pytest.mark.kubernetes +class TestIPWhitelist: + """Tests for ip-whitelist.yml - IP whitelist plugin""" + + def test_resources_created(self, k8s_ip_whitelist): + """Test that deployment, service, and ingress are created""" + kubectl = k8s_ip_whitelist + + # Check deployment exists + result = subprocess.run( + [kubectl, "get", "deployment", "admin", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "admin" in result.stdout + + # Check service exists + result = subprocess.run( + [kubectl, "get", "service", "admin-service", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "admin-service" in result.stdout + + # Check ingress exists + result = subprocess.run( + [kubectl, "get", "ingress", "admin-ingress-whitelist", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "admin-ingress-whitelist" in result.stdout + + def test_pods_running(self, k8s_ip_whitelist): + """Test that all admin pods are running""" + kubectl = k8s_ip_whitelist + + # Wait for deployment to be ready + subprocess.run( + [kubectl, "wait", "--for=condition=Available", "deployment/admin", + "-n", "default", "--timeout=30s"], + check=True + ) + + result = subprocess.run( + [kubectl, "get", "pods", "-n", "default", "-l", "app=admin", "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + assert len(pods['items']) > 0, "No admin pods found" + + for pod in pods['items']: + assert pod['status']['phase'] == 'Running', \ + f"Pod {pod['metadata']['name']} is not running: {pod['status']['phase']}" + + def test_haproxy_config_has_ip_whitelist(self, k8s_ip_whitelist): + """Test that HAProxy configuration contains IP whitelist rules""" + kubectl = k8s_ip_whitelist + + # Wait for EasyHAProxy to discover the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "admin.example.local", timeout=30), \ + "EasyHAProxy did not discover admin.example.local within 30 seconds" + + # Get the EasyHAProxy pod name + result = subprocess.run( + [kubectl, "get", "pods", "-n", "easyhaproxy", + "-l", "app.kubernetes.io/name=easyhaproxy", + "-o", "jsonpath={.items[0].metadata.name}"], + check=True, + capture_output=True, + text=True + ) + pod_name = result.stdout.strip() + assert pod_name, "EasyHAProxy pod not found" + + # Get HAProxy configuration + result = subprocess.run( + [kubectl, "exec", "-n", "easyhaproxy", pod_name, + "--", "cat", "/etc/haproxy/haproxy.cfg"], + check=True, + capture_output=True, + text=True + ) + config = result.stdout + + # Find the backend for admin service + # The backend name should be something like srv_admin_example_local_80 or similar + assert "admin" in config.lower(), "Admin service backend not found in HAProxy config" + + # Verify IP whitelist plugin comment + assert "# IP Whitelist - Only allow specific IPs" in config, \ + "IP Whitelist plugin comment not found" + + # Verify ACL for whitelisted IPs + assert "acl whitelisted_ip src" in config, \ + "IP whitelist ACL not found" + + # Verify the IPs are in the configuration + assert "127.0.0.1" in config, "Localhost not in allowed IPs" + assert "10.0.0.0/8" in config, "10.0.0.0/8 network not in allowed IPs" + assert "172.16.0.0/12" in config, "172.16.0.0/12 network not in allowed IPs" + + # Verify deny rule for non-whitelisted IPs + assert "http-request deny" in config and "!whitelisted_ip" in config, \ + "Deny rule for non-whitelisted IPs not found" + + # Verify status code 403 + assert "deny_status 403" in config, \ + "Status code 403 not configured for blocked IPs" + + def test_access_from_localhost(self, k8s_ip_whitelist): + """Test that access from localhost is allowed""" + kubectl = k8s_ip_whitelist + + # Wait for EasyHAProxy to discover and configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "admin.example.local", timeout=30), \ + "EasyHAProxy did not become ready for admin.example.local within 30 seconds" + + # Test HTTP request (localhost should be in allowed IPs) + result = subprocess.run( + ["curl", "-s", "-H", "Host: admin.example.local", f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert "Admin Panel - IP Restricted" in result.stdout, \ + f"Expected 'Admin Panel - IP Restricted' in response, got: {result.stdout}" + + +# ============================================================================= +# Test: jwt-validator-secret-example.yml - JWT Validator with Kubernetes Secrets +# ============================================================================= + +@pytest.mark.kubernetes +class TestJWTValidatorSecret: + """Tests for jwt-validator-secret-example.yml - JWT validation using Kubernetes secrets""" + + def _generate_jwt_token(self, private_key_path: Path, issuer: str, audience: str, expired: bool = False) -> str: + """ + Generate a JWT token for testing + + Args: + private_key_path: Path to RSA private key + issuer: JWT issuer + audience: JWT audience + expired: If True, generate an expired token + + Returns: + JWT token string + """ + # Read private key + with open(private_key_path, 'rb') as f: + private_key = serialization.load_pem_private_key( + f.read(), + password=None, + backend=default_backend() + ) + + # Set expiration time + if expired: + exp = int(time.time()) - 3600 # Expired 1 hour ago + else: + exp = int(time.time()) + 3600 # Valid for 1 hour + + # Create JWT payload + payload = { + 'iss': issuer, + 'aud': audience, + 'exp': exp, + 'sub': 'test-user', + 'iat': int(time.time()) + } + + # Generate token + token = jwt.encode(payload, private_key, algorithm='RS256') + return token + + def test_resources_created(self, k8s_jwt_validator_secret): + """Test that secrets, service, and ingresses are created""" + kubectl = k8s_jwt_validator_secret["kubectl"] + + # Check secrets exist + result = subprocess.run( + [kubectl, "get", "secret", "jwt-pubkey-secret", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "jwt-pubkey-secret" in result.stdout + + result = subprocess.run( + [kubectl, "get", "secret", "jwt-custom-secret", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "jwt-custom-secret" in result.stdout + + # Check service exists + result = subprocess.run( + [kubectl, "get", "service", "api-service", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "api-service" in result.stdout + + # Check both ingresses exist + result = subprocess.run( + [kubectl, "get", "ingress", "api-ingress-jwt-auto", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "api-ingress-jwt-auto" in result.stdout + + result = subprocess.run( + [kubectl, "get", "ingress", "api-ingress-jwt-explicit", "-n", "default"], + check=True, + capture_output=True, + text=True + ) + assert "api-ingress-jwt-explicit" in result.stdout + + def test_pods_running(self, k8s_jwt_validator_secret): + """Test that all API pods are running""" + kubectl = k8s_jwt_validator_secret["kubectl"] + + result = subprocess.run( + [kubectl, "get", "pods", "-n", "default", "-l", "app=api", "-o", "json"], + check=True, + capture_output=True, + text=True + ) + pods = json.loads(result.stdout) + + assert len(pods['items']) > 0, "No API pods found" + + for pod in pods['items']: + assert pod['status']['phase'] == 'Running', \ + f"Pod {pod['metadata']['name']} is not running: {pod['status']['phase']}" + + def test_haproxy_config_has_jwt_validation(self, k8s_jwt_validator_secret): + """Test that HAProxy configuration contains JWT validation rules""" + kubectl = k8s_jwt_validator_secret["kubectl"] + + # Wait for EasyHAProxy to discover the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "api.example.local", timeout=30), \ + "EasyHAProxy did not discover api.example.local within 30 seconds" + + # Get the EasyHAProxy pod name + result = subprocess.run( + [kubectl, "get", "pods", "-n", "easyhaproxy", + "-l", "app.kubernetes.io/name=easyhaproxy", + "-o", "jsonpath={.items[0].metadata.name}"], + check=True, + capture_output=True, + text=True + ) + pod_name = result.stdout.strip() + assert pod_name, "EasyHAProxy pod not found" + + # Get HAProxy configuration + result = subprocess.run( + [kubectl, "exec", "-n", "easyhaproxy", pod_name, + "--", "cat", "/etc/haproxy/haproxy.cfg"], + check=True, + capture_output=True, + text=True + ) + config = result.stdout + + # Verify JWT Validator plugin comments + assert "# JWT Validator - Validate JWT tokens" in config, \ + "JWT Validator plugin comment not found" + + # Verify JWT extraction + assert "http_auth_bearer,jwt_header_query" in config, \ + "JWT header extraction not found" + assert "http_auth_bearer,jwt_payload_query" in config, \ + "JWT payload extraction not found" + + # Verify JWT validation rules + assert "jwt_verify" in config, \ + "JWT signature verification not found" + + # Verify issuer validation + assert "https://auth.example.com/" in config, \ + "JWT issuer validation not found" + + # Verify audience validation + assert "https://api.example.com" in config, \ + "JWT audience validation not found" + + # Verify JWT keys directory is used + assert "/etc/haproxy/jwt_keys/" in config, \ + "JWT keys directory not found in config" + + def test_access_without_token_denied(self, k8s_jwt_validator_secret): + """Test that access without Authorization header is denied""" + kubectl = k8s_jwt_validator_secret["kubectl"] + + # Wait for EasyHAProxy to discover and configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "api.example.local", timeout=30), \ + "EasyHAProxy did not become ready for api.example.local within 30 seconds" + + # Test HTTP request without Authorization header (should be denied) + result = subprocess.run( + ["curl", "-s", "-w", "\n%{http_code}", "-H", "Host: api.example.local", + f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + # Extract HTTP status code from last line + lines = result.stdout.strip().split('\n') + http_code = lines[-1] + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert http_code == "403", \ + f"Expected HTTP 403 for missing Authorization header, got: {http_code}" + assert "Missing Authorization HTTP header" in result.stdout, \ + f"Expected 'Missing Authorization HTTP header' in response, got: {result.stdout}" + + def test_access_with_valid_token_allowed(self, k8s_jwt_validator_secret): + """Test that access with valid JWT token is allowed""" + kubectl = k8s_jwt_validator_secret["kubectl"] + jwt_private_key = k8s_jwt_validator_secret["jwt_private_key"] + + # Wait for EasyHAProxy to discover and configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "api.example.local", timeout=30), \ + "EasyHAProxy did not become ready for api.example.local within 30 seconds" + + # Generate valid JWT token + token = self._generate_jwt_token( + jwt_private_key, + issuer="https://auth.example.com/", + audience="https://api.example.com", + expired=False + ) + + # Test HTTP request with valid token (should succeed) + result = subprocess.run( + ["curl", "-s", "-w", "\n%{http_code}", "-H", "Host: api.example.local", + "-H", f"Authorization: Bearer {token}", + f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + # Extract HTTP status code from last line + lines = result.stdout.strip().split('\n') + http_code = lines[-1] + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert http_code == "200", \ + f"Expected HTTP 200 for valid JWT token, got: {http_code}\nResponse: {result.stdout}" + + def test_access_with_expired_token_denied(self, k8s_jwt_validator_secret): + """Test that access with expired JWT token is denied""" + kubectl = k8s_jwt_validator_secret["kubectl"] + jwt_private_key = k8s_jwt_validator_secret["jwt_private_key"] + + # Wait for EasyHAProxy to discover and configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "api.example.local", timeout=30), \ + "EasyHAProxy did not become ready for api.example.local within 30 seconds" + + # Generate expired JWT token + token = self._generate_jwt_token( + jwt_private_key, + issuer="https://auth.example.com/", + audience="https://api.example.com", + expired=True + ) + + # Test HTTP request with expired token (should be denied) + result = subprocess.run( + ["curl", "-s", "-w", "\n%{http_code}", "-H", "Host: api.example.local", + "-H", f"Authorization: Bearer {token}", + f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + # Extract HTTP status code from last line + lines = result.stdout.strip().split('\n') + http_code = lines[-1] + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert http_code == "403", \ + f"Expected HTTP 403 for expired JWT token, got: {http_code}" + assert "JWT has expired" in result.stdout, \ + f"Expected 'JWT has expired' in response, got: {result.stdout}" + + def test_access_with_wrong_issuer_denied(self, k8s_jwt_validator_secret): + """Test that access with wrong issuer is denied""" + kubectl = k8s_jwt_validator_secret["kubectl"] + jwt_private_key = k8s_jwt_validator_secret["jwt_private_key"] + + # Wait for EasyHAProxy to discover and configure the ingress + assert wait_for_easyhaproxy_discovery(kubectl, "api.example.local", timeout=30), \ + "EasyHAProxy did not become ready for api.example.local within 30 seconds" + + # Generate JWT token with wrong issuer + token = self._generate_jwt_token( + jwt_private_key, + issuer="https://wrong-issuer.example.com/", # Wrong issuer + audience="https://api.example.com", + expired=False + ) + + # Test HTTP request with wrong issuer (should be denied) + result = subprocess.run( + ["curl", "-s", "-w", "\n%{http_code}", "-H", "Host: api.example.local", + "-H", f"Authorization: Bearer {token}", + f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + # Extract HTTP status code from last line + lines = result.stdout.strip().split('\n') + http_code = lines[-1] + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert http_code == "403", \ + f"Expected HTTP 403 for wrong JWT issuer, got: {http_code}" + assert "Invalid JWT issuer" in result.stdout, \ + f"Expected 'Invalid JWT issuer' in response, got: {result.stdout}" + + def test_explicit_key_ingress(self, k8s_jwt_validator_secret): + """Test that the explicit key format ingress also works""" + kubectl = k8s_jwt_validator_secret["kubectl"] + jwt_private_key = k8s_jwt_validator_secret["jwt_private_key"] + + # Wait for EasyHAProxy to discover the explicit key ingress + assert wait_for_easyhaproxy_discovery(kubectl, "api-custom.example.local", timeout=30), \ + "EasyHAProxy did not discover api-custom.example.local within 30 seconds" + + # Generate valid JWT token + token = self._generate_jwt_token( + jwt_private_key, + issuer="https://auth.example.com/", + audience="https://api.example.com", + expired=False + ) + + # Test HTTP request with valid token on explicit key ingress + result = subprocess.run( + ["curl", "-s", "-w", "\n%{http_code}", "-H", "Host: api-custom.example.local", + "-H", f"Authorization: Bearer {token}", + f"http://localhost:{HTTP_PORT}"], + capture_output=True, + text=True, + timeout=10 + ) + + # Extract HTTP status code from last line + lines = result.stdout.strip().split('\n') + http_code = lines[-1] + + assert result.returncode == 0, f"Curl failed with return code {result.returncode}" + assert http_code == "200", \ + f"Expected HTTP 200 for valid JWT token on explicit key ingress, got: {http_code}\nResponse: {result.stdout}" + + +# ============================================================================= +# Helper functions for manual testing +# ============================================================================= + +def run_manual_test(manifest_file: str): + """ + Helper function to run a test manually without pytest + + Example: + run_manual_test("service.yml") + """ + kind_bin = ensure_kind_installed() + kubectl_bin = ensure_kubectl_installed() + + cluster_name = "easyhaproxy-manual-test" + + print(f"Creating cluster '{cluster_name}'...") + subprocess.run( + [kind_bin, "create", "cluster", "--name", cluster_name], + check=True + ) + + # Wait for cluster to be ready + subprocess.run( + [kubectl_bin, "wait", "--for=condition=Ready", "nodes", "--all", + "--timeout=120s"], + check=True + ) + + fixture = None + try: + fixture = KubernetesFixture(manifest_file, kubectl_bin) + fixture.apply() + print("✅ Resources deployed successfully!") + print("\nPress Enter to cleanup...") + input() + finally: + if fixture: + fixture.delete() + subprocess.run( + [kind_bin, "delete", "cluster", "--name", cluster_name], + check=True + ) + print("✅ Cleanup complete!") + + +if __name__ == "__main__": + print("This is a pytest test suite. Run with: pytest test_kubernetes.py -v") + print("\nAvailable test classes:") + print(" - TestBasicService: Basic Kubernetes service tests") + print(" - TestTLSService: TLS/SSL ingress with custom certificates") + print(" - TestIPWhitelist: IP whitelist plugin tests") + print(" - TestJWTValidatorSecret: JWT validation with Kubernetes secrets") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index bfec772..6795364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,17 @@ packages = ["src/easymapping", "src/functions", "src/processor", "src/plugins", addopts = "-v -p no:warnings" testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "kubernetes: marks tests as kubernetes integration tests (deselect with '-m \"not kubernetes\"')", + "ssl: marks tests for SSL/TLS functionality", + "jwt: marks tests for JWT validator plugin", + "loadbalancing: marks tests for load balancing functionality", + "php: marks tests for PHP-FPM FastCGI plugin", + "plugins: marks tests for combined plugin functionality", + "security: marks tests for security features (IP whitelist, etc.)", + "cloudflare: marks tests for Cloudflare IP restoration plugin", + "custom_label: marks tests for custom label prefix functionality", +] [tool.ruff] line-length = 120 diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index a3934d0..8764415 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -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: diff --git a/src/functions/__init__.py b/src/functions/__init__.py index b7fb940..31b9531 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -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: diff --git a/src/main.py b/src/main.py index e0b672d..799a15e 100644 --- a/src/main.py +++ b/src/main.py @@ -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) diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py index 0649a77..ae0904b 100644 --- a/src/plugins/builtin/jwt_validator.py +++ b/src/plugins/builtin/jwt_validator.py @@ -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() diff --git a/src/processor/__init__.py b/src/processor/__init__.py index fb1582d..e82db70 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -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: "" + 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: diff --git a/tests/test_kubernetes_processor.py b/tests/test_kubernetes_processor.py new file mode 100644 index 0000000..a736734 --- /dev/null +++ b/tests/test_kubernetes_processor.py @@ -0,0 +1,419 @@ +""" +Tests for Kubernetes Processor - k8s_secret functionality + +Tests the generic k8s_secret annotation pattern that allows loading +plugin configuration values from Kubernetes Secrets. +""" + +import base64 +import os +import sys +from unittest.mock import MagicMock, Mock +from types import SimpleNamespace + +# Add src to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from processor import Kubernetes + + +class TestKubernetesSecretPattern: + """Test cases for k8s_secret annotation pattern""" + + def create_mock_secret(self, data): + """Helper to create a mock Kubernetes secret""" + secret = Mock() + # Kubernetes stores secret data as base64-encoded strings + secret.data = { + key: base64.b64encode(value.encode('ascii')).decode('ascii') + for key, value in data.items() + } + return secret + + def create_mock_ingress(self, annotations, namespace="default"): + """Helper to create a mock Kubernetes ingress""" + ingress = Mock() + ingress.metadata = Mock() + ingress.metadata.namespace = namespace + ingress.metadata.name = "test-ingress" + ingress.metadata.annotations = annotations + ingress.metadata.creation_timestamp = Mock() + ingress.metadata.creation_timestamp.strftime = Mock(return_value="01/01/2024 00:00:00") + ingress.metadata.resource_version = "12345" + ingress.spec = Mock() + ingress.spec.tls = None + ingress.spec.ingress_class_name = "easyhaproxy" + + # Create a proper rule with path and backend + rule = Mock() + rule.host = "test.example.com" + rule.http = Mock() + + path = Mock() + path.path = "/" + path.path_type = "Prefix" + path.backend = Mock() + path.backend.service = Mock() + path.backend.service.name = "test-service" + path.backend.service.port = Mock() + path.backend.service.port.number = 8080 + + rule.http.paths = [path] + ingress.spec.rules = [rule] + + return ingress + + def test_k8s_secret_auto_detect_exact_match(self): + """Test k8s_secret with auto-detect finds exact key match""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with exact key name "pubkey" + secret = self.create_mock_secret({"pubkey": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress with k8s_secret annotation (auto-detect format) + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients (refresh is called automatically in __init__) + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify secret was read + mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default") + + # Verify the annotation was transformed correctly + parsed = processor.get_parsed_object() + assert len(parsed) == 1, f"Expected 1 entry in parsed_object, got {len(parsed)}: {list(parsed.keys())}" + # parsed_object is a dict with IP addresses as keys, get the first (and only) value + ingress_data = list(parsed.values())[0] + + # The k8s_secret annotation should have been transformed and stored in the ingress data + # Format: easyhaproxy.{host}_{port}.plugin.{plugin_name}.{key} + # For test.example.com:8080 -> easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data + # The value should be base64-encoded (double encoding: K8s decodes, we re-encode for plugin) + assert ingress_data["easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey"] is not None + + def test_k8s_secret_auto_detect_variation_match(self): + """Test k8s_secret with auto-detect finds variation key""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with variation key name "public-key" instead of "pubkey" + secret = self.create_mock_secret({"public-key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress with k8s_secret annotation (auto-detect format) + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients (refresh is called automatically in __init__) + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify secret was read + mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default") + + # Verify the annotation was transformed correctly + parsed = processor.get_parsed_object() + assert len(parsed) == 1, f"Expected 1 entry in parsed_object, got {len(parsed)}: {list(parsed.keys())}" + # parsed_object is a dict with IP addresses as keys, get the first (and only) value + ingress_data = list(parsed.values())[0] + + # Should find the "public-key" variation + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data + + def test_k8s_secret_explicit_key(self): + """Test k8s_secret with explicit key name (secret_name/key_name format)""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with custom key name + secret = self.create_mock_secret({"rsa-public-key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress with explicit key format + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret/rsa-public-key" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients (refresh is called automatically in __init__) + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify secret was read + mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default") + + # Verify the annotation was transformed correctly + parsed = processor.get_parsed_object() + assert len(parsed) == 1, f"Expected 1 entry in parsed_object, got {len(parsed)}: {list(parsed.keys())}" + # parsed_object is a dict with IP addresses as keys, get the first (and only) value + ingress_data = list(parsed.values())[0] + + # Should use the explicit key + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data + + def test_k8s_secret_explicit_key_no_variations(self): + """Test k8s_secret with explicit key doesn't try variations""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with ONLY "public-key", not "custom-key" + secret = self.create_mock_secret({"public-key": "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress with explicit key that doesn't exist + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret/custom-key" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients (refresh is called automatically in __init__) + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify secret was read + mock_core_api.read_namespaced_secret.assert_called_once_with("my-jwt-secret", "default") + + # Verify the annotation was NOT created (explicit key not found, no variations tried) + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + # Should NOT have the pubkey annotation (explicit key not found) + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" not in ingress_data + + def test_k8s_secret_priority_explicit_annotation_wins(self): + """Test that explicit annotation overrides k8s_secret annotation""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret + secret = self.create_mock_secret({"pubkey": "-----BEGIN PUBLIC KEY-----\nfrom-secret\n-----END PUBLIC KEY-----"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress with BOTH explicit pubkey AND k8s_secret.pubkey + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.pubkey": base64.b64encode(b"explicit-value").decode('ascii'), + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-jwt-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify the annotation kept the explicit value (not replaced by secret) + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + # Should have the explicit annotation value, NOT the secret value + explicit_value = base64.b64encode(b"explicit-value").decode('ascii') + assert ingress_data.get("easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey") == explicit_value + + def test_k8s_secret_secret_not_found(self): + """Test k8s_secret handles secret not found gracefully""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Simulate secret not found + from kubernetes.client.rest import ApiException + mock_core_api.read_namespaced_secret.side_effect = ApiException(status=404, reason="Not Found") + + # Create ingress with k8s_secret annotation + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "nonexistent-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Should not raise exception, just log warning + + # Verify the annotation was NOT created + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + # Should NOT have pubkey annotation + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" not in ingress_data + + def test_k8s_secret_key_not_found(self): + """Test k8s_secret handles key not found in secret gracefully""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with NO matching keys + secret = self.create_mock_secret({"some-other-key": "value"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress with k8s_secret annotation + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify the annotation was NOT created + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + # Should NOT have pubkey annotation (no matching keys) + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" not in ingress_data + + def test_k8s_secret_multiple_plugins(self): + """Test k8s_secret works with multiple plugins""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create different secrets for different plugins + def get_secret(name, namespace): + if name == "jwt-secret": + return self.create_mock_secret({"pubkey": "jwt-public-key"}) + elif name == "api-secret": + return self.create_mock_secret({"api_key": "secret-api-key"}) + raise Exception("Secret not found") + + mock_core_api.read_namespaced_secret.side_effect = get_secret + + # Create ingress with multiple k8s_secret annotations + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator,api_auth", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "jwt-secret", + "easyhaproxy.plugin.api_auth.k8s_secret.api_key": "api-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify both secrets were read + assert mock_core_api.read_namespaced_secret.call_count == 2 + + # Verify both annotations were transformed + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + assert "easyhaproxy.test-example-com_8080.plugin.jwt_validator.pubkey" in ingress_data + assert "easyhaproxy.test-example-com_8080.plugin.api_auth.api_key" in ingress_data + + def test_k8s_secret_namespace_isolation(self): + """Test k8s_secret reads secrets from same namespace as ingress""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + secret = self.create_mock_secret({"pubkey": "test-key"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress in "production" namespace + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey": "my-secret" + }, namespace="production") + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify secret was read from correct namespace + mock_core_api.read_namespaced_secret.assert_called_once_with("my-secret", "production") + + def test_k8s_secret_malformed_annotation(self): + """Test k8s_secret handles malformed annotation gracefully""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create ingress with malformed k8s_secret annotation + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "jwt_validator", + # Malformed: multiple k8s_secret in the key + "easyhaproxy.plugin.k8s_secret.jwt_validator.k8s_secret.pubkey": "my-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Should not raise exception + + # Verify no secret read was attempted + mock_core_api.read_namespaced_secret.assert_not_called() + + def test_k8s_secret_password_variations(self): + """Test k8s_secret auto-detect variations for password key""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with "pass" instead of "password" + secret = self.create_mock_secret({"pass": "secret-password"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress requesting "password" key (should find "pass" variation) + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "auth_plugin", + "easyhaproxy.plugin.auth_plugin.k8s_secret.password": "my-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify the annotation was created (variation found) + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + assert "easyhaproxy.test-example-com_8080.plugin.auth_plugin.password" in ingress_data + + def test_k8s_secret_api_key_variations(self): + """Test k8s_secret auto-detect variations for api_key""" + # Setup mocks + mock_core_api = MagicMock() + mock_networking_api = MagicMock() + + # Create a secret with "apikey" instead of "api_key" + secret = self.create_mock_secret({"apikey": "secret-key-123"}) + mock_core_api.read_namespaced_secret.return_value = secret + + # Create ingress requesting "api_key" (should find "apikey" variation) + ingress = self.create_mock_ingress({ + "easyhaproxy.plugins": "api_plugin", + "easyhaproxy.plugin.api_plugin.k8s_secret.api_key": "my-secret" + }) + mock_networking_api.list_ingress_for_all_namespaces.return_value = Mock(items=[ingress]) + + # Create processor with mocked API clients + processor = Kubernetes(api_instance=mock_core_api, v1=mock_networking_api) + + # Verify the annotation was created (variation found) + parsed = processor.get_parsed_object() + assert len(parsed) == 1 + ingress_data = list(parsed.values())[0] + + assert "easyhaproxy.test-example-com_8080.plugin.api_plugin.api_key" in ingress_data \ No newline at end of file