1
0
Fork 0

Refactor E2E tests and configuration format

- Replaced `easymapping` configuration with `containers` for better maintainability and clarity.
- Introduced `DockerComposeFixture` class in `utils.py` to manage Docker Compose lifecycle and smart build strategy.
- Enhanced YAML-to-environment variable conversion in `ContainerEnv` for dynamic configuration support.
- Updated HAProxy configurations and test fixtures to reflect the new format.
- Improved test coverage for YAML parsing, environment variable handling, and HAProxy config generation.
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-13 11:44:26 -05:00
parent c125985150
commit 97845b8a52
22 changed files with 1042 additions and 442 deletions

View file

@ -74,15 +74,13 @@ spec:
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/haproxy/static/config.yaml
easymapping: containers:
- host: example.com "example.com:80":
port: 80 ip: ["webapp:80"]
container: webapp:80 plugins: [deny_pages]
plugins: plugin:
- deny_pages
plugin_config:
deny_pages: deny_pages:
paths: /admin,/private,/debug paths: [/admin, /private, /debug]
status_code: 403 status_code: 403
``` ```

View file

@ -228,13 +228,12 @@ spec:
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/haproxy/static/config.yaml
easymapping: containers:
- host: api.example.com "api.example.com:443":
port: 443 ip: ["api-service:8080"]
container: api-service:8080 ssl: true
plugins: plugins: [jwt_validator]
- jwt_validator plugin:
plugin_config:
jwt_validator: jwt_validator:
algorithm: RS256 algorithm: RS256
issuer: https://auth.example.com/ issuer: https://auth.example.com/

View file

@ -29,39 +29,43 @@ ssl_mode: default
logLevel: logLevel:
haproxy: INFO haproxy: INFO
certbot: { certbot:
"email": "acme@example.org" email: "acme@example.org"
}
easymapping:
- port: 80
hosts:
host1.com.br:
containers: containers:
- container:5000 # HTTP with certbot + redirect to HTTPS
"host1.com.br:80":
ip: ["container:5000"]
certbot: true certbot: true
redirect_ssl: true redirect_ssl: true
host2.com.br:
containers:
- other:3000
redirect:
www.host1.com.br: http://host1.com.br
- port: 443 # Additional HTTP host
hosts: "host2.com.br:80":
host1.com.br: ip: ["other:3000"]
containers:
- container:80 # Redirect www → main domain
redirect_ssl: false "www.host1.com.br:80":
ip: ["container:5000"]
redirect_ssl: true
# HTTPS version
"host1.com.br:443":
ip: ["container:80"]
ssl: true ssl: true
- port: 8080 # Different host on different port
hosts: "host3.com.br:8080":
host3.com.br: ip: ["domain:8181"]
containers:
- domain:8181
``` ```
:::info New Configuration Format
The `containers` format simplifies static configuration:
- **Flatter structure**: `"hostname:port"` keys instead of nested `easymapping``ports``hosts`
- **Better readability**: Port and localport embedded in keys (`"host:port"` and `"container:localport"`)
- **Plugin support**: Global and per-host plugin configuration
- **Clearer mapping**: Format mirrors internal Docker label structure
:::
Then map this file to `/etc/haproxy/static/config.yml` in your EasyHAProxy container: Then map this file to `/etc/haproxy/static/config.yml` in your EasyHAProxy container:
```bash title="Run EasyHAProxy with static configuration" ```bash title="Run EasyHAProxy with static configuration"
@ -109,19 +113,23 @@ certbot:
retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations. retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations.
} }
easymapping:
- port: 80 # Listen port
mode: http # Optional. Default `http`. Can be http or tcp
hosts:
host1.com.br: # Hostname
containers: containers:
- container:5000 # Endpoints of the hostname above (ip, dns, container, etc) # Format: "hostname:port"
certbot: true # Optional. it will request a certbot certificate. Needs certbot.email set. "host1.com.br:80":
redirect_ssl: true # Optional. It will redirect this site to it SSL. ip: ["container:5000"] # Endpoints (ip, dns, container, etc) with format "address:localport"
ssl: true # Optional. Inform this port will listen to SSL, instead of HTTP certbot: true # Optional. Request a certbot certificate. Requires certbot.email set.
clone_to_ssl: true # Optional. Default False. You clone these hosts to its equivalent SSL. redirect_ssl: true # Optional. Redirect HTTP to HTTPS for this host.
redirect: mode: http # Optional. Default `http`. Can be http or tcp
www.host1.com.br: http://host1.com.br
# HTTPS version (SSL)
"host1.com.br:443":
ip: ["container:80"]
ssl: true # Enable SSL for this port
# Redirect www → main domain (using redirect_ssl with backend)
"www.host1.com.br:80":
ip: ["container:5000"]
redirect_ssl: true
``` ```
:::note SSL Certificates in Static Mode :::note SSL Certificates in Static Mode

View file

@ -66,6 +66,7 @@ markers = [
"security: marks tests for security features (IP whitelist, etc.)", "security: marks tests for security features (IP whitelist, etc.)",
"cloudflare: marks tests for Cloudflare IP restoration plugin", "cloudflare: marks tests for Cloudflare IP restoration plugin",
"custom_label: marks tests for custom label prefix functionality", "custom_label: marks tests for custom label prefix functionality",
"static: marks tests for static configuration mode",
] ]
[tool.ruff] [tool.ruff]

View file

@ -32,7 +32,9 @@ class DockerLabelHandler:
return self.__data[label].lower() in ["true", "1", "yes"] return self.__data[label].lower() in ["true", "1", "yes"]
return default_value return default_value
def get_json(self, label, default_value={}): def get_json(self, label, default_value=None):
if default_value is None:
default_value = {}
if self.has_label(label): if self.has_label(label):
value = self.__data[label] value = self.__data[label]
if not value: # Handle empty strings if not value: # Handle empty strings
@ -178,6 +180,12 @@ class HaproxyConfigGenerator:
self.label.create([definition, "clone_to_ssl"]) self.label.create([definition, "clone_to_ssl"])
) )
# Check if this is a redirect-only entry (no backend)
redirect_only = self.label.get_bool(
self.label.create([definition, "redirect_only"]),
False
)
if port not in easymapping: if port not in easymapping:
easymapping[port] = { easymapping[port] = {
"mode": mode, "mode": mode,
@ -187,6 +195,12 @@ class HaproxyConfigGenerator:
"redirect": dict(), "redirect": dict(),
} }
if redirect_only:
easymapping[port]["redirect"].update(self.label.get_json(
self.label.create([definition, "redirect"])
))
continue
# TODO: this could use `EXPOSE` from `Dockerfile`? # TODO: this could use `EXPOSE` from `Dockerfile`?
ct_port = self.label.get( ct_port = self.label.get(
self.label.create([definition, "localport"]), self.label.create([definition, "localport"]),

View file

@ -15,7 +15,20 @@ from OpenSSL import crypto
class ContainerEnv: class ContainerEnv:
@staticmethod @staticmethod
def read(): def read(yaml_config=None):
"""
Read configuration from environment variables, optionally merged with YAML config.
Args:
yaml_config: Optional dict from YAML file (for static mode). YAML values take precedence.
Returns:
Dict with configuration settings
"""
# Convert YAML config to environment variables first (if provided)
if yaml_config:
ContainerEnv._yaml_to_env(yaml_config)
env_vars = { env_vars = {
"customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False, "customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False,
"ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default' "ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default'
@ -115,6 +128,68 @@ class ContainerEnv:
return env_vars return env_vars
@staticmethod
def _yaml_to_env(yaml_config):
"""Convert YAML configuration to environment variables"""
# Convert customerrors
if 'customerrors' in yaml_config:
os.environ['HAPROXY_CUSTOMERRORS'] = 'true' if yaml_config['customerrors'] else 'false'
# Convert ssl_mode
if 'ssl_mode' in yaml_config:
os.environ['EASYHAPROXY_SSL_MODE'] = str(yaml_config['ssl_mode'])
# Convert stats
if 'stats' in yaml_config:
stats = yaml_config['stats']
if 'username' in stats:
os.environ['HAPROXY_USERNAME'] = str(stats['username'])
if 'password' in stats:
os.environ['HAPROXY_PASSWORD'] = str(stats['password'])
if 'port' in stats:
os.environ['HAPROXY_STATS_PORT'] = str(stats['port'])
# Convert logLevel
if 'logLevel' in yaml_config:
log_level = yaml_config['logLevel']
for source, level in log_level.items():
os.environ[source.upper() + '_LOG_LEVEL'] = str(level)
# Convert certbot
if 'certbot' in yaml_config:
certbot = yaml_config['certbot']
for config, value in certbot.items():
os.environ['EASYHAPROXY_CERTBOT_' + config.upper()] = str(value)
# Convert plugins
if 'plugins' in yaml_config:
plugins = yaml_config['plugins']
# Convert enabled list
if 'enabled' in plugins:
enabled_list = plugins['enabled'] if isinstance(plugins['enabled'], list) else [plugins['enabled']]
os.environ['EASYHAPROXY_PLUGINS_ENABLED'] = ','.join(enabled_list)
# Convert abort_on_error
if 'abort_on_error' in plugins:
os.environ['EASYHAPROXY_PLUGINS_ABORT_ON_ERROR'] = 'true' if plugins['abort_on_error'] else 'false'
# Convert plugin configs
if 'config' in plugins:
for plugin_name, plugin_config in plugins['config'].items():
for config_key, config_value in plugin_config.items():
# Convert to env var format: EASYHAPROXY_PLUGIN_<NAME>_<KEY>
env_key = f"EASYHAPROXY_PLUGIN_{plugin_name.upper()}_{config_key.upper()}"
# Convert list values to comma-separated strings
if isinstance(config_value, list):
env_value = ','.join(str(v) for v in config_value)
else:
env_value = str(config_value)
os.environ[env_key] = env_value
class Functions: class Functions:
HAPROXY_LOG: Final[str] = "HAPROXY" HAPROXY_LOG: Final[str] = "HAPROXY"

View file

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

View file

@ -45,6 +45,22 @@ backend srv_stats
mode http mode http
server Local 127.0.0.1:1936 server Local 127.0.0.1:1936
frontend http_in_443
bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1
mode http
acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br
acl is_rule_host1_com_br_443_2 hdr(host) -i host1.com.br:443
use_backend srv_host1_com_br_443 if is_rule_host1_com_br_443_1 OR is_rule_host1_com_br_443_2
backend srv_host1_com_br_443
balance roundrobin
mode http
option forwardfor
http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc }
server srv-0 container:5000 check weight 1
frontend http_in_80 frontend http_in_80
bind *:80 bind *:80
mode http mode http
@ -75,22 +91,6 @@ backend srv_host2_com_br_80
http-request add-header X-Forwarded-Proto https if { ssl_fc } http-request add-header X-Forwarded-Proto https if { ssl_fc }
server srv-0 other:3000 check weight 1 server srv-0 other:3000 check weight 1
frontend http_in_443
bind *:443 ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1
mode http
acl is_rule_host1_com_br_443_1 hdr(host) -i host1.com.br
acl is_rule_host1_com_br_443_2 hdr(host) -i host1.com.br:443
use_backend srv_host1_com_br_443 if is_rule_host1_com_br_443_1 OR is_rule_host1_com_br_443_2
backend srv_host1_com_br_443
balance roundrobin
mode http
option forwardfor
http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc }
server srv-0 container:80 check weight 1
frontend http_in_8080 frontend http_in_8080
bind *:8080 bind *:8080
mode http mode http

View file

@ -5,27 +5,23 @@ stats:
customerrors: true # Optional (default false) customerrors: true # Optional (default false)
easymapping: certbot:
- port: 80 email: test@example.com
hosts:
host1.com.br:
containers: containers:
- container:5000 "host1.com.br:80":
ip: ["container:5000"]
certbot: true certbot: true
host2.com.br:
containers:
- other:3000
redirect:
www.host1.com.br: http://host1.com.br
- port: 443 "host2.com.br:80":
ssl: True ip: ["other:3000"]
hosts:
host1.com.br:
containers:
- container:80
- port: 8080 "www.host1.com.br:80":
hosts: redirect: "http://host1.com.br"
host3.com.br:
containers: [ "domain:8181" ] "host1.com.br:443":
ip: ["container:80"]
ssl: true
"host3.com.br:8080":
ip: ["domain:8181"]

13
tests/fixtures/static_multi_domain.yml vendored Normal file
View file

@ -0,0 +1,13 @@
stats:
username: admin
password: test123
port: 1936
customerrors: true
containers:
"host1.com:80":
ip: ["webapp:8080"]
"host2.com:80":
ip: ["webapp:8080"] # Same container as host1

View file

@ -344,3 +344,110 @@ def test_container_log_level():
del os.environ['CERTBOT_LOG_LEVEL'] del os.environ['CERTBOT_LOG_LEVEL']
del os.environ['EASYHAPROXY_LOG_LEVEL'] del os.environ['EASYHAPROXY_LOG_LEVEL']
del os.environ['HAPROXY_LOG_LEVEL'] del os.environ['HAPROXY_LOG_LEVEL']
def test_yaml_to_env_loglevel():
"""Test that YAML logLevel config is properly converted to environment variables"""
yaml_config = {
"logLevel": {
"easyhaproxy": Functions.ERROR,
"haproxy": Functions.FATAL,
"certbot": Functions.TRACE,
}
}
try:
result = ContainerEnv.read(yaml_config)
assert result["logLevel"]["easyhaproxy"] == Functions.ERROR
assert result["logLevel"]["haproxy"] == Functions.FATAL
assert result["logLevel"]["certbot"] == Functions.TRACE
# Verify environment variables were set
assert os.environ.get('EASYHAPROXY_LOG_LEVEL') == Functions.ERROR
assert os.environ.get('HAPROXY_LOG_LEVEL') == Functions.FATAL
assert os.environ.get('CERTBOT_LOG_LEVEL') == Functions.TRACE
finally:
# Cleanup
for key in ['EASYHAPROXY_LOG_LEVEL', 'HAPROXY_LOG_LEVEL', 'CERTBOT_LOG_LEVEL']:
if key in os.environ:
del os.environ[key]
def test_yaml_to_env_certbot():
"""Test that YAML certbot config is properly converted to environment variables"""
yaml_config = {
"certbot": {
"email": "test@example.com",
"autoconfig": "letsencrypt",
"server": "https://acme-v02.api.letsencrypt.org/directory",
"eab_kid": "test_kid",
"eab_hmac_key": "test_hmac",
"retry_count": 10,
"preferred_challenges": "dns",
"manual_auth_hook": "test_hook"
}
}
try:
result = ContainerEnv.read(yaml_config)
assert result["certbot"]["email"] == "test@example.com"
assert result["certbot"]["autoconfig"] == "letsencrypt"
assert result["certbot"]["server"] == "https://acme-v02.api.letsencrypt.org/directory"
assert result["certbot"]["eab_kid"] == "test_kid"
assert result["certbot"]["eab_hmac_key"] == "test_hmac"
assert result["certbot"]["retry_count"] == 10
assert result["certbot"]["preferred_challenges"] == "dns"
assert result["certbot"]["manual_auth_hook"] == "test_hook"
# Verify environment variables were set
assert os.environ.get('EASYHAPROXY_CERTBOT_EMAIL') == "test@example.com"
assert os.environ.get('EASYHAPROXY_CERTBOT_AUTOCONFIG') == "letsencrypt"
assert os.environ.get('EASYHAPROXY_CERTBOT_SERVER') == "https://acme-v02.api.letsencrypt.org/directory"
assert os.environ.get('EASYHAPROXY_CERTBOT_EAB_KID') == "test_kid"
assert os.environ.get('EASYHAPROXY_CERTBOT_EAB_HMAC_KEY') == "test_hmac"
assert os.environ.get('EASYHAPROXY_CERTBOT_RETRY_COUNT') == "10"
assert os.environ.get('EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES') == "dns"
assert os.environ.get('EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK') == "test_hook"
finally:
# Cleanup
for key in ['EASYHAPROXY_CERTBOT_EMAIL', 'EASYHAPROXY_CERTBOT_AUTOCONFIG',
'EASYHAPROXY_CERTBOT_SERVER', 'EASYHAPROXY_CERTBOT_EAB_KID',
'EASYHAPROXY_CERTBOT_EAB_HMAC_KEY', 'EASYHAPROXY_CERTBOT_RETRY_COUNT',
'EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES', 'EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK']:
if key in os.environ:
del os.environ[key]
def test_yaml_to_env_combined():
"""Test that combined YAML config (logLevel + certbot) works correctly"""
yaml_config = {
"customerrors": True,
"ssl_mode": "strict",
"logLevel": {
"easyhaproxy": Functions.WARN,
"haproxy": Functions.ERROR,
},
"certbot": {
"email": "combined@example.com",
"retry_count": 5
}
}
try:
result = ContainerEnv.read(yaml_config)
# Check the result
assert result["customerrors"] == True
assert result["ssl_mode"] == "strict"
assert result["logLevel"]["easyhaproxy"] == Functions.WARN
assert result["logLevel"]["haproxy"] == Functions.ERROR
assert result["certbot"]["email"] == "combined@example.com"
assert result["certbot"]["retry_count"] == 5
# Verify environment variables
assert os.environ.get('HAPROXY_CUSTOMERRORS') == "true"
assert os.environ.get('EASYHAPROXY_SSL_MODE') == "strict"
assert os.environ.get('EASYHAPROXY_LOG_LEVEL') == Functions.WARN
assert os.environ.get('HAPROXY_LOG_LEVEL') == Functions.ERROR
assert os.environ.get('EASYHAPROXY_CERTBOT_EMAIL') == "combined@example.com"
assert os.environ.get('EASYHAPROXY_CERTBOT_RETRY_COUNT') == "5"
finally:
# Cleanup
for key in ['HAPROXY_CUSTOMERRORS', 'EASYHAPROXY_SSL_MODE',
'EASYHAPROXY_LOG_LEVEL', 'HAPROXY_LOG_LEVEL',
'EASYHAPROXY_CERTBOT_EMAIL', 'EASYHAPROXY_CERTBOT_RETRY_COUNT']:
if key in os.environ:
del os.environ[key]

View file

@ -233,15 +233,25 @@ def test_parser_finds_services_raw():
def test_parser_static(): def test_parser_static():
path = os.path.dirname(os.path.realpath(__file__)) path = os.path.dirname(os.path.realpath(__file__))
with open(path + "/fixtures/static.yml") as content_file: with open(path + "/fixtures/static.yml") as content_file:
parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader) parsed_yaml = yaml.load(content_file.read(), Loader=yaml.FullLoader)
cfg = easymapping.HaproxyConfigGenerator(parsed) # Use ContainerEnv.read() to convert containers format to env vars
haproxy_config = cfg.generate() from functions import ContainerEnv
env_config = ContainerEnv.read(parsed_yaml)
cfg = easymapping.HaproxyConfigGenerator(env_config)
# Simulate static processor's conversion of containers to labels
from processor import Static
static = Static(path + "/fixtures/static.yml")
parsed_labels = static.parsed_object
haproxy_config = cfg.generate(parsed_labels)
assert len(haproxy_config) > 0 assert len(haproxy_config) > 0
with open(path + "/expected/static.txt") as expected_file: with open(path + "/expected/static.txt") as expected_file:
assert expected_file.read() == haproxy_config assert expected_file.read() == haproxy_config
assert [] == cfg.certbot_hosts assert ['host1.com.br'] == cfg.certbot_hosts
def test_parser_static_raw(): def test_parser_static_raw():
@ -249,6 +259,7 @@ def test_parser_static_raw():
with open(path + "/fixtures/static.yml") as content_file: with open(path + "/fixtures/static.yml") as content_file:
parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader) parsed = yaml.load(content_file.read(), Loader=yaml.FullLoader)
# Updated to new containers format
expected = { expected = {
"stats": { "stats": {
"username": "admin", "username": "admin",
@ -256,49 +267,37 @@ def test_parser_static_raw():
"port": 1936 "port": 1936
}, },
"customerrors": True, "customerrors": True,
"easymapping": [ "certbot": {
{ "email": "test@example.com"
"port": 80, },
"hosts": { "containers": {
"host1.com.br": { "host1.com.br:80": {
"containers": [ "ip": [
"container:5000" "container:5000"
], ],
"certbot": True "certbot": True
}, },
"host2.com.br": { "host2.com.br:80": {
"containers": [ "ip": [
"other:3000" "other:3000"
] ]
}
}, },
"redirect": { "www.host1.com.br:80": {
"www.host1.com.br": "http://host1.com.br" "redirect": "http://host1.com.br"
}
}, },
{ "host1.com.br:443": {
"port": 443, "ip": [
"ssl": True,
"hosts": {
"host1.com.br": {
"containers": [
"container:80" "container:80"
] ],
} "ssl": True
}
}, },
{ "host3.com.br:8080": {
"port": 8080, "ip": [
"hosts": {
"host3.com.br": {
"containers": [
"domain:8181" "domain:8181"
] ]
} }
} }
} }
]
}
assert expected == parsed assert expected == parsed

View file

@ -8,58 +8,46 @@ def test_processor_static():
ProcessorInterface.static_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "./fixtures/static.yml") ProcessorInterface.static_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "./fixtures/static.yml")
static = ProcessorInterface.factory(ProcessorInterface.STATIC) static = ProcessorInterface.factory(ProcessorInterface.STATIC)
parsed_object = [ # New format: parsed_object is a dict mapping container IPs to their labels
{ # Note: 'container' now has labels for BOTH host1.com.br:80 and host1.com.br:443
"hosts": { parsed_object = {
"host1.com.br": { 'container': {
"containers": [ 'easyhaproxy.host1_com_br_80.host': 'host1.com.br',
"container:5000" 'easyhaproxy.host1_com_br_80.port': '80',
], 'easyhaproxy.host1_com_br_80.localport': '5000',
"certbot": True 'easyhaproxy.host1_com_br_80.certbot': 'true',
'easyhaproxy.host1_com_br_443.host': 'host1.com.br',
'easyhaproxy.host1_com_br_443.port': '443',
'easyhaproxy.host1_com_br_443.localport': '80',
'easyhaproxy.host1_com_br_443.ssl': 'true',
},
'other': {
'easyhaproxy.host2_com_br_80.host': 'host2.com.br',
'easyhaproxy.host2_com_br_80.port': '80',
'easyhaproxy.host2_com_br_80.localport': '3000',
},
'redirect-www.host1.com.br-80': {
'easyhaproxy.www_host1_com_br_80.host': 'www.host1.com.br',
'easyhaproxy.www_host1_com_br_80.port': '80',
'easyhaproxy.www_host1_com_br_80.redirect': '{"www.host1.com.br": "http://host1.com.br"}',
'easyhaproxy.www_host1_com_br_80.redirect_only': 'true',
},
'domain': {
'easyhaproxy.host3_com_br_8080.host': 'host3.com.br',
'easyhaproxy.host3_com_br_8080.port': '8080',
'easyhaproxy.host3_com_br_8080.localport': '8181',
}, },
"host2.com.br": {
"containers": [
"other:3000"
]
} }
},
"port": 80,
"redirect": {
"www.host1.com.br": "http://host1.com.br"
}
},
{
"hosts": {
"host1.com.br": {
"containers": [
"container:80"
]
}
},
"port": 443,
"ssl": True
},
{
"hosts": {
"host3.com.br": {
"containers": [
"domain:8181"
]
}
},
"port": 8080
}
]
hosts = [ hosts = [
'host1.com.br:443',
'host1.com.br:80', 'host1.com.br:80',
'host2.com.br:80', 'host2.com.br:80',
'host1.com.br:443',
'host3.com.br:8080' 'host3.com.br:8080'
] ]
assert static.get_certbot_hosts() is None assert static.get_certbot_hosts() is None
assert static.get_parsed_object() == parsed_object assert static.get_parsed_object() == parsed_object
assert static.get_hosts() == hosts assert static.get_hosts() is None
haproxy_cfg = static.get_haproxy_conf() haproxy_cfg = static.get_haproxy_conf()
@ -67,8 +55,39 @@ def test_processor_static():
os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt")) os.path.join(os.path.dirname(os.path.realpath(__file__)), "./expected/static.txt"))
# @todo: Static doesnt populate this fields # @todo: Static doesnt populate this fields
assert static.get_certbot_hosts() == [] assert static.get_certbot_hosts() == ['host1.com.br']
assert static.get_parsed_object() == parsed_object assert static.get_parsed_object() == parsed_object
assert static.get_hosts() == hosts assert static.get_hosts() == hosts
def test_processor_static_multiple_domains_same_container():
"""Test that multiple domains can point to the same backend container"""
ProcessorInterface.static_file = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"./fixtures/static_multi_domain.yml"
)
static = ProcessorInterface.factory(ProcessorInterface.STATIC)
parsed_object = static.get_parsed_object()
# Should have labels for both host1 and host2 on the same container
assert 'webapp' in parsed_object
webapp_labels = parsed_object['webapp']
# Check both host definitions are present (this is the key test - both should exist!)
assert 'easyhaproxy.host1_com_80.host' in webapp_labels
assert 'easyhaproxy.host2_com_80.host' in webapp_labels
assert webapp_labels['easyhaproxy.host1_com_80.host'] == 'host1.com'
assert webapp_labels['easyhaproxy.host2_com_80.host'] == 'host2.com'
# Generate HAProxy config
haproxy_cfg = static.get_haproxy_conf()
# Verify both backends are created
assert 'backend srv_host1_com_80' in haproxy_cfg
assert 'backend srv_host2_com_80' in haproxy_cfg
# Both should point to the same container
assert haproxy_cfg.count('server srv-0 webapp:8080') == 2
# test_processor_static() # test_processor_static()

View file

@ -79,13 +79,10 @@ stats:
password: password password: password
port: 1936 port: 1936
easymapping:
- port: 443
ssl: true
hosts:
host1.local:
containers: containers:
- container:8080 # Can also be IP:PORT for external backends "host1.local:443":
ip: ["container:8080"] # Can also be IP:PORT for external backends
ssl: true
``` ```
See `conf/` directory for complete examples. See `conf/` directory for complete examples.

View file

@ -15,17 +15,17 @@ stats:
customerrors: true # Optional (default false) customerrors: true # Optional (default false)
easymapping: containers:
# HTTP - Redirect to HTTPS # HTTP - Redirect to HTTPS using redirect_ssl
- port: 80 "host1.local:80":
redirect: ip: ["container:8080"]
host1.local: https://host1.local redirect_ssl: true
www.host1.local: https://host1.local
"www.host1.local:80":
ip: ["container:8080"]
redirect_ssl: true
# HTTPS - Serve application # HTTPS - Serve application
- port: 443 "host1.local:443":
ip: ["container:8080"]
ssl: true ssl: true
hosts:
host1.local:
containers:
- container:8080

View file

@ -35,52 +35,46 @@ stats:
customerrors: true customerrors: true
easymapping: containers:
# HTTP Port 80 # HTTP Port 80
# Required for ACME HTTP-01 challenge and redirect # Required for ACME HTTP-01 challenge and redirect
- port: 80
hosts:
# Domain with certbot enabled # Domain with certbot enabled
example.com: "example.com:80":
containers: ip: ["webapp:8080"]
- webapp:8080
# Enable certbot for this domain # Enable certbot for this domain
certbot: true certbot: true
# Redirect HTTP to HTTPS after cert is issued # Redirect HTTP to HTTPS after cert is issued
redirect_ssl: true redirect_ssl: true
# Additional domain with certbot # Additional domain with certbot
app.example.com: "app.example.com:80":
containers: ip: ["app:3000"]
- app:3000
certbot: true certbot: true
redirect_ssl: true redirect_ssl: true
# Domain without certbot (uses custom certificate) # Domain without certbot (uses custom certificate)
custom.example.com: "custom.example.com:80":
containers: ip: ["custom-app:8080"]
- custom-app:8080
# No certbot - expects certificate at /certs/haproxy/custom.example.com.pem # No certbot - expects certificate at /certs/haproxy/custom.example.com.pem
# HTTPS Port 443 # HTTPS Port 443
# Serves HTTPS traffic with auto-generated certificates # Serves HTTPS traffic with auto-generated certificates
- port: 443
"example.com:443":
ip: ["webapp:8080"]
ssl: true ssl: true
hosts:
example.com:
containers:
- webapp:8080
# Certificate path (auto-generated by certbot) # Certificate path (auto-generated by certbot)
# /certs/certbot/example.com/fullchain.pem # /certs/certbot/example.com/fullchain.pem
app.example.com: "app.example.com:443":
containers: ip: ["app:3000"]
- app:3000 ssl: true
# Custom certificate example # Custom certificate example
custom.example.com: "custom.example.com:443":
containers: ip: ["custom-app:8080"]
- custom-app:8080 ssl: true
# Place your certificate at: # Place your certificate at:
# /certs/haproxy/custom.example.com.pem # /certs/haproxy/custom.example.com.pem

View file

@ -33,23 +33,18 @@ plugins:
- /config - /config
status_code: 404 # Hide existence of these paths status_code: 404 # Hide existence of these paths
easymapping:
- port: 80
hosts:
# Domain 1: Uses global deny_pages configuration
host1.local:
containers: containers:
- webapp1:8080 # Domain 1: Uses global deny_pages configuration
"host1.local:80":
ip: ["webapp1:8080"]
# No plugins specified = uses global configuration # No plugins specified = uses global configuration
# Domain 2: WordPress site with custom blocked paths # Domain 2: WordPress site with custom blocked paths
host2.local: "host2.local:80":
containers: ip: ["wordpress:80"]
- wordpress:80
# Override global plugin configuration for this domain # Override global plugin configuration for this domain
plugins: plugins: [deny_pages]
- deny_pages plugin:
plugin_config:
deny_pages: deny_pages:
paths: paths:
- /wp-admin - /wp-admin
@ -59,12 +54,10 @@ easymapping:
status_code: 403 # Return forbidden instead of 404 status_code: 403 # Return forbidden instead of 404
# Domain 3: Public site with stricter blocking # Domain 3: Public site with stricter blocking
host3.local: "host3.local:80":
containers: ip: ["publicsite:3000"]
- publicsite:3000 plugins: [deny_pages]
plugins: plugin:
- deny_pages
plugin_config:
deny_pages: deny_pages:
paths: paths:
- /admin - /admin

View file

@ -32,16 +32,12 @@ stats:
customerrors: true customerrors: true
easymapping:
- port: 80
hosts:
# Public API with full JWT validation
api.local:
containers: containers:
- api-server:8080 # Public API with full JWT validation
plugins: "api.local:80":
- jwt_validator ip: ["api-server:8080"]
plugin_config: plugins: [jwt_validator]
plugin:
jwt_validator: jwt_validator:
algorithm: RS256 algorithm: RS256
issuer: https://auth.example.com/ issuer: https://auth.example.com/
@ -49,25 +45,20 @@ easymapping:
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
# Internal API - validate signature only (no issuer/audience check) # Internal API - validate signature only (no issuer/audience check)
internal-api.local: "internal-api.local:80":
containers: ip: ["internal-api:3000"]
- internal-api:3000 plugins: [jwt_validator]
plugins: plugin:
- jwt_validator
plugin_config:
jwt_validator: jwt_validator:
algorithm: RS256 algorithm: RS256
# No issuer/audience = skip those validations # No issuer/audience = skip those validations
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
# Admin API - different issuer and key # Admin API - different issuer and key
admin-api.local: "admin-api.local:80":
containers: ip: ["admin-api:4000"]
- admin-api:4000 plugins: [jwt_validator, deny_pages] # Also block internal paths
plugins: plugin:
- jwt_validator
- deny_pages # Also block internal paths
plugin_config:
jwt_validator: jwt_validator:
algorithm: RS256 algorithm: RS256
issuer: https://admin-auth.example.com/ issuer: https://admin-auth.example.com/
@ -80,7 +71,6 @@ easymapping:
status_code: 403 status_code: 403
# Public website - no JWT required # Public website - no JWT required
website.local: "website.local:80":
containers: ip: ["website:8080"]
- website:8080
# No plugins = public access # No plugins = public access

View file

@ -60,17 +60,70 @@
services: services:
haproxy: haproxy:
image: byjg/easy-haproxy:5.0.0 image: byjg/easy-haproxy:local
build:
context: ../..
dockerfile: build/Dockerfile
volumes: volumes:
- ./conf/:/etc/haproxy/static/ - ./conf/:/etc/haproxy/static/
- ./host1.local.pem:/certs/haproxy/host1.local.pem - ../static/host1.local.pem:/certs/haproxy/host1.local.pem:ro
- ../docker/jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
- ../docker/jwt_pubkey.pem:/etc/haproxy/jwt_keys/admin_pubkey.pem:ro
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
environment: environment:
EASYHAPROXY_DISCOVER: static EASYHAPROXY_DISCOVER: static
HAPROXY_USERNAME: admin
HAPROXY_PASSWORD: password
ports: ports:
- "80:80/tcp" - "80:80/tcp"
- "443:443/tcp" - "443:443/tcp"
- "1936:1936/tcp" - "1936:1936/tcp"
# Main container for basic tests
container: container:
image: byjg/static-httpserver image: byjg/static-httpserver
container_name: container
# Containers for deny-pages tests
webapp1:
image: byjg/static-httpserver
container_name: webapp1
environment:
TITLE: "WebApp 1"
wordpress:
image: byjg/static-httpserver
container_name: wordpress
environment:
TITLE: "WordPress Site"
publicsite:
image: byjg/static-httpserver
container_name: publicsite
environment:
TITLE: "Public Site"
# Containers for JWT validator tests
api-server:
image: byjg/static-httpserver
container_name: api-server
environment:
TITLE: "Protected API"
internal-api:
image: byjg/static-httpserver
container_name: internal-api
environment:
TITLE: "Internal API"
admin-api:
image: byjg/static-httpserver
container_name: admin-api
environment:
TITLE: "Admin API"
website:
image: byjg/static-httpserver
container_name: website
environment:
TITLE: "Public Website"

View file

@ -33,17 +33,15 @@ import pytest
import requests import requests
import jwt as jwt_lib import jwt as jwt_lib
from typing import Generator from typing import Generator
from utils import extract_backend_block from utils import extract_backend_block, DockerComposeFixture
# Base directory for docker-compose files # Base directory for docker-compose files
BASE_DIR = Path(__file__).parent.absolute() BASE_DIR = Path(__file__).parent.absolute()
DOCKER_DIR = BASE_DIR / "docker"
# Track if cloudflare_ips.lst has been created in this test session # Track if cloudflare_ips.lst has been created in this test session
_cloudflare_ips_created = False _cloudflare_ips_created = False
# Track if Docker image has been built in this test session
_docker_image_built = False
def create_cloudflare_ips_file(): def create_cloudflare_ips_file():
""" """
@ -89,75 +87,10 @@ def create_cloudflare_ips_file():
_cloudflare_ips_created = True _cloudflare_ips_created = True
class DockerComposeFixture:
"""Helper class to manage docker-compose lifecycle"""
def __init__(self, compose_file: str, startup_wait: int = 3, build: bool = None):
self.compose_file = str(BASE_DIR / "docker" / compose_file)
self.startup_wait = startup_wait
# Smart build strategy: build on first call, skip on subsequent calls
global _docker_image_built
if build is None:
self.build = not _docker_image_built
else:
self.build = build
def up(self):
"""Start docker-compose services"""
global _docker_image_built
compose_name = Path(self.compose_file).name
print() # Newline for better test output formatting
print(f" → Starting services from {compose_name}...")
cmd = ["docker", "compose", "-f", self.compose_file, "up", "-d"]
if self.build:
cmd.append("--build")
result = subprocess.run(
cmd,
capture_output=True,
text=True
)
if result.returncode != 0:
print(f" ✗ ERROR: Failed to start services!")
print(f" stdout: {result.stdout}")
print(f" stderr: {result.stderr}")
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
# Mark image as built for this test session
if self.build:
_docker_image_built = True
print(f" ✓ Services started, waiting {self.startup_wait}s for initialization...")
time.sleep(self.startup_wait)
print(f" ✓ Services ready")
def down(self):
"""Stop and remove docker-compose services"""
compose_name = Path(self.compose_file).name
print(f" → Stopping services from {compose_name}...")
result = subprocess.run(
["docker", "compose", "-f", self.compose_file, "down", "--remove-orphans"],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f" ⚠ WARNING: Failed to stop services cleanly")
print(f" stderr: {result.stderr}")
# Don't raise error on cleanup, just warn
else:
print(f" ✓ Services stopped and cleaned up")
@pytest.fixture @pytest.fixture
def docker_compose_basic_ssl() -> Generator[None, None, None]: def docker_compose_basic_ssl() -> Generator[None, None, None]:
"""Fixture for docker-compose.yml (Basic SSL)""" """Fixture for docker-compose.yml (Basic SSL)"""
fixture = DockerComposeFixture("docker-compose.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -166,7 +99,7 @@ def docker_compose_basic_ssl() -> Generator[None, None, None]:
@pytest.fixture @pytest.fixture
def docker_compose_jwt_validator() -> Generator[None, None, None]: def docker_compose_jwt_validator() -> Generator[None, None, None]:
"""Fixture for docker-compose-jwt-validator.yml""" """Fixture for docker-compose-jwt-validator.yml"""
fixture = DockerComposeFixture("docker-compose-jwt-validator.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-jwt-validator.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -175,7 +108,7 @@ def docker_compose_jwt_validator() -> Generator[None, None, None]:
@pytest.fixture @pytest.fixture
def docker_compose_multi_containers() -> Generator[None, None, None]: def docker_compose_multi_containers() -> Generator[None, None, None]:
"""Fixture for docker-compose-multi-containers.yml""" """Fixture for docker-compose-multi-containers.yml"""
fixture = DockerComposeFixture("docker-compose-multi-containers.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-multi-containers.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -184,7 +117,7 @@ def docker_compose_multi_containers() -> Generator[None, None, None]:
@pytest.fixture @pytest.fixture
def docker_compose_php_fpm() -> Generator[None, None, None]: def docker_compose_php_fpm() -> Generator[None, None, None]:
"""Fixture for docker-compose-php-fpm.yml""" """Fixture for docker-compose-php-fpm.yml"""
fixture = DockerComposeFixture("docker-compose-php-fpm.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-php-fpm.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -196,7 +129,7 @@ def docker_compose_plugins_combined() -> Generator[None, None, None]:
# Create cloudflare_ips.lst (required by this compose file) # Create cloudflare_ips.lst (required by this compose file)
create_cloudflare_ips_file() create_cloudflare_ips_file()
fixture = DockerComposeFixture("docker-compose-plugins-combined.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-plugins-combined.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -205,7 +138,7 @@ def docker_compose_plugins_combined() -> Generator[None, None, None]:
@pytest.fixture @pytest.fixture
def docker_compose_ip_whitelist() -> Generator[None, None, None]: def docker_compose_ip_whitelist() -> Generator[None, None, None]:
"""Fixture for docker-compose-ip-whitelist.yml""" """Fixture for docker-compose-ip-whitelist.yml"""
fixture = DockerComposeFixture("docker-compose-ip-whitelist.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-ip-whitelist.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -217,7 +150,7 @@ def docker_compose_cloudflare() -> Generator[None, None, None]:
# Create cloudflare_ips.lst (required by this compose file) # Create cloudflare_ips.lst (required by this compose file)
create_cloudflare_ips_file() create_cloudflare_ips_file()
fixture = DockerComposeFixture("docker-compose-cloudflare.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-cloudflare.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()
@ -787,7 +720,7 @@ class TestCloudflare:
@pytest.fixture @pytest.fixture
def docker_compose_changed_label() -> Generator[None, None, None]: def docker_compose_changed_label() -> Generator[None, None, None]:
"""Fixture for docker-compose-changed-label.yml""" """Fixture for docker-compose-changed-label.yml"""
fixture = DockerComposeFixture("docker-compose-changed-label.yml") fixture = DockerComposeFixture(str(DOCKER_DIR / "docker-compose-changed-label.yml"))
fixture.up() fixture.up()
yield yield
fixture.down() fixture.down()

259
tests_e2e/test_static.py Normal file
View file

@ -0,0 +1,259 @@
"""
Pytest test suite for EasyHAProxy Static Configuration Mode
These tests verify static YAML configuration mode (EASYHAPROXY_DISCOVER=static).
Tests are organized by configuration file and can be run individually or as a suite.
Requirements:
- pytest
- requests
- PyJWT
- cryptography
- docker-compose
Usage:
# Run all static tests
pytest test_static.py -v
# Run specific test class
pytest test_static.py::TestStaticBasic -v
# Run specific test
pytest test_static.py::TestStaticBasic::test_https_host1 -v
"""
import subprocess
import shutil
from pathlib import Path
import pytest
import requests
from typing import Generator
from utils import extract_backend_block, DockerComposeFixture
# Base directory for static configuration
BASE_DIR = Path(__file__).parent.absolute()
STATIC_DIR = BASE_DIR / "static"
CONF_DIR = STATIC_DIR / "conf"
class StaticDockerComposeFixture(DockerComposeFixture):
"""Helper class to manage static docker-compose lifecycle with config file switching"""
def __init__(self, config_file: str, startup_wait: int = 3, build: bool = None):
# Initialize parent with static docker-compose.yml path
super().__init__(str(STATIC_DIR / "docker-compose.yml"), startup_wait, build)
self.config_file = config_file
self.config_source = CONF_DIR / config_file
self.config_target = CONF_DIR / "config.yml"
def up(self):
"""Start docker-compose services with specified config"""
print() # Newline for better test output formatting
print(f" → Using static config: {self.config_file}")
# Copy the config file to config.yml
shutil.copy(self.config_source, self.config_target)
print(f" ✓ Config copied to config.yml")
# Call parent's up() method to start services
super().up()
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def static_basic() -> Generator[None, None, None]:
"""Fixture for config-basic.yml"""
fixture = StaticDockerComposeFixture("config-basic.yml")
fixture.up()
yield
fixture.down()
@pytest.fixture
def static_deny_pages() -> Generator[None, None, None]:
"""Fixture for config-deny-pages.yml"""
fixture = StaticDockerComposeFixture("config-deny-pages.yml")
fixture.up()
yield
fixture.down()
@pytest.fixture
def static_jwt_validator() -> Generator[None, None, None]:
"""Fixture for config-jwt-validator.yml"""
fixture = StaticDockerComposeFixture("config-jwt-validator.yml")
fixture.up()
yield
fixture.down()
# =============================================================================
# Test: config-basic.yml - Basic HTTP→HTTPS Redirect
# =============================================================================
@pytest.mark.static
class TestStaticBasic:
"""Tests for static config-basic.yml"""
def test_haproxy_config(self, static_basic):
"""Test HAProxy configuration has SSL and redirect configurations"""
result = subprocess.run(
["docker", "exec", "static-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
capture_output=True,
text=True,
check=True
)
config = result.stdout
# Test HTTPS backend for host1
https_host1_block = extract_backend_block(config, "srv_host1_local_443")
assert https_host1_block, "Backend srv_host1_local_443 not found"
assert "mode http" in https_host1_block
# Verify SSL frontend exists
assert "frontend https_in_443" in config or "bind *:443" in config
# Verify HTTP to HTTPS redirect (new format uses http-request redirect scheme)
assert "http-request redirect scheme https code 301" in config
def test_https_host1(self, static_basic):
"""Test HTTPS access to host1.local"""
response = requests.get(
"https://127.0.0.1/",
headers={"Host": "host1.local"},
verify=False
)
assert response.status_code == 200
def test_http_redirect_host1(self, static_basic):
"""Test HTTP to HTTPS redirect for host1.local"""
response = requests.get(
"http://127.0.0.1/",
headers={"Host": "host1.local"},
allow_redirects=False
)
assert response.status_code == 301
assert "https://host1.local" in response.headers.get("location", "")
def test_haproxy_stats(self, static_basic):
"""Test HAProxy stats interface"""
from conftest import verify_haproxy_stats
verify_haproxy_stats()
# =============================================================================
# Test: config-deny-pages.yml - Deny Pages Plugin
# =============================================================================
@pytest.mark.static
class TestStaticDenyPages:
"""Tests for static config-deny-pages.yml"""
def test_haproxy_config(self, static_deny_pages):
"""Test HAProxy configuration has deny pages rules"""
result = subprocess.run(
["docker", "exec", "static-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
capture_output=True,
text=True,
check=True
)
config = result.stdout
# Extract backend for host1.local (should have global deny_pages config)
backend_block = extract_backend_block(config, "srv_host1_local_80")
assert backend_block, "Backend srv_host1_local_80 not found"
# Verify deny pages plugin is configured
assert "# Deny Pages - Block specific paths" in backend_block
assert "acl denied_path path_beg" in backend_block
assert "/admin" in backend_block
assert "/.env" in backend_block
assert "/config" in backend_block
assert "http-request deny" in backend_block
def test_normal_access(self, static_deny_pages):
"""Test normal access to allowed paths"""
response = requests.get(
"http://127.0.0.1/",
headers={"Host": "host1.local"}
)
assert response.status_code == 200
def test_blocked_paths(self, static_deny_pages):
"""Test access to blocked paths"""
blocked_paths = ["/admin", "/.env", "/config"]
for path in blocked_paths:
response = requests.get(
f"http://127.0.0.1{path}",
headers={"Host": "host1.local"}
)
assert response.status_code == 404, f"Path {path} should be blocked with 404"
def test_haproxy_stats(self, static_deny_pages):
"""Test HAProxy stats interface"""
from conftest import verify_haproxy_stats
verify_haproxy_stats()
# =============================================================================
# Test: config-jwt-validator.yml - JWT Validator Plugin
# =============================================================================
@pytest.mark.static
class TestStaticJWTValidator:
"""Tests for static config-jwt-validator.yml"""
def test_haproxy_config(self, static_jwt_validator):
"""Test HAProxy configuration has JWT validation rules"""
result = subprocess.run(
["docker", "exec", "static-haproxy-1", "cat", "/etc/haproxy/haproxy.cfg"],
capture_output=True,
text=True,
check=True
)
config = result.stdout
# Extract backend for API (static mode uses different naming)
# Find any backend that contains JWT validation
assert "# JWT Validator - Validate JWT tokens" in config, \
"JWT Validator plugin comment not found"
assert "jwt_verify" in config, \
"JWT signature verification not found"
assert "Missing Authorization HTTP header" in config, \
"JWT authorization check not found"
def test_without_token(self, static_jwt_validator):
"""Test API access without JWT token (should fail)"""
response = requests.get(
"http://127.0.0.1/",
headers={"Host": "api.local"}
)
assert response.status_code == 403
assert "Missing Authorization HTTP header" in response.text
def test_with_valid_token(self, static_jwt_validator, jwt_token):
"""Test API access with valid JWT token (should succeed)"""
response = requests.get(
"http://127.0.0.1/",
headers={
"Host": "api.local",
"Authorization": f"Bearer {jwt_token}"
}
)
assert response.status_code == 200
def test_haproxy_stats(self, static_jwt_validator):
"""Test HAProxy stats interface"""
from conftest import verify_haproxy_stats
verify_haproxy_stats()
if __name__ == "__main__":
print("This is a pytest test suite. Run with: pytest test_static.py -v")
print("\nAvailable test classes:")
print(" - TestStaticBasic: Basic static configuration tests")
print(" - TestStaticDenyPages: Deny pages plugin tests")
print(" - TestStaticJWTValidator: JWT validator plugin tests")

View file

@ -14,6 +14,74 @@ import jwt as jwt_lib
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
# Track if Docker image has been built in this test session
_docker_image_built = False
class DockerComposeFixture:
"""Helper class to manage docker-compose lifecycle"""
def __init__(self, compose_file: str, startup_wait: int = 3, build: bool = None):
self.compose_file = compose_file
self.startup_wait = startup_wait
# Smart build strategy: build on first call, skip on subsequent calls
global _docker_image_built
if build is None:
self.build = not _docker_image_built
else:
self.build = build
def up(self):
"""Start docker-compose services"""
global _docker_image_built
compose_name = Path(self.compose_file).name
print() # Newline for better test output formatting
print(f" → Starting services from {compose_name}...")
cmd = ["docker", "compose", "-f", self.compose_file, "up", "-d"]
if self.build:
cmd.append("--build")
result = subprocess.run(
cmd,
capture_output=True,
text=True
)
if result.returncode != 0:
print(f" ✗ ERROR: Failed to start services!")
print(f" stdout: {result.stdout}")
print(f" stderr: {result.stderr}")
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
# Mark image as built for this test session
if self.build:
_docker_image_built = True
print(f" ✓ Services started, waiting {self.startup_wait}s for initialization...")
time.sleep(self.startup_wait)
print(f" ✓ Services ready")
def down(self):
"""Stop and remove docker-compose services"""
compose_name = Path(self.compose_file).name
print(f" → Stopping services from {compose_name}...")
result = subprocess.run(
["docker", "compose", "-f", self.compose_file, "down", "--remove-orphans"],
capture_output=True,
text=True
)
if result.returncode != 0:
print(f" ⚠ WARNING: Failed to stop services cleanly")
print(f" stderr: {result.stderr}")
# Don't raise error on cleanup, just warn
else:
print(f" ✓ Services stopped and cleaned up")
def generate_jwt_token( def generate_jwt_token(
private_key_path: Path, private_key_path: Path,