Add standalone Python entrypoint for EasyHAProxy and pip-based installation
- Introduced `/scripts/.venv/bin/easy-haproxy` as the default entrypoint for standalone environments. - Added `_build_parser` and `start` functions to enable CLI-driven functionality configuration. - Updated packaging to reflect new `easyhaproxy.main` module and adapted project name from `easymapping` to `easyhaproxy`. - Modified `Dockerfile` to align with new script paths and entrypoints for better portability. - Enhanced documentation with dedicated sections for pip-based installation, CLI references, and systemd setup for non-Docker environments. - Adjusted helm, plugin, and environment-related docs for accurate sidebar ordering. - Added a new GitHub workflow to automate package publishing to PyPI.
This commit is contained in:
parent
3805100244
commit
bfd38adea5
19 changed files with 497 additions and 20 deletions
0
src/easyhaproxy/__init__.py
Normal file
0
src/easyhaproxy/__init__.py
Normal file
234
src/easyhaproxy/main.py
Normal file
234
src/easyhaproxy/main.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from deepdiff import DeepDiff
|
||||
|
||||
from functions import (
|
||||
Certbot,
|
||||
Consts,
|
||||
DaemonizeHAProxy,
|
||||
Functions,
|
||||
logger_easyhaproxy,
|
||||
logger_init,
|
||||
)
|
||||
from processor import ProcessorInterface
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="easy-haproxy",
|
||||
description="HAProxy label-based routing with service discovery for Docker, Swarm, and Kubernetes.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
|
||||
# Core
|
||||
parser.add_argument("--discover", metavar="MODE",
|
||||
choices=["static", "docker", "swarm", "kubernetes"],
|
||||
help="Service discovery mode. Also set by EASYHAPROXY_DISCOVER.")
|
||||
parser.add_argument("--base-path", metavar="PATH",
|
||||
help="Base directory for all EasyHAProxy files. Also set by EASYHAPROXY_BASE_PATH.")
|
||||
parser.add_argument("--label-prefix", metavar="PREFIX",
|
||||
help="Label/annotation prefix used to discover services. Also set by EASYHAPROXY_LABEL_PREFIX.")
|
||||
parser.add_argument("--ssl-mode", metavar="MODE",
|
||||
choices=["strict", "default", "loose"],
|
||||
help="TLS policy: strict (TLS 1.3 only), default, or loose (all). Also set by EASYHAPROXY_SSL_MODE.")
|
||||
parser.add_argument("--refresh-conf", metavar="SECONDS", type=int,
|
||||
help="Interval in seconds to poll for configuration changes. Also set by EASYHAPROXY_REFRESH_CONF.")
|
||||
parser.add_argument("--customer-errors", metavar="BOOL",
|
||||
choices=["true", "false"],
|
||||
help="Enable custom HAProxy HTML error pages. Also set by HAPROXY_CUSTOMERRORS.")
|
||||
|
||||
# Logging
|
||||
log_levels = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"]
|
||||
parser.add_argument("--log-level", metavar="LEVEL", choices=log_levels,
|
||||
help="EasyHAProxy log level. Also set by EASYHAPROXY_LOG_LEVEL.")
|
||||
parser.add_argument("--haproxy-log-level", metavar="LEVEL", choices=log_levels,
|
||||
help="HAProxy process log level. Also set by HAPROXY_LOG_LEVEL.")
|
||||
parser.add_argument("--certbot-log-level", metavar="LEVEL", choices=log_levels,
|
||||
help="Certbot log level. Also set by CERTBOT_LOG_LEVEL.")
|
||||
|
||||
# Stats
|
||||
parser.add_argument("--haproxy-password", metavar="PASSWORD",
|
||||
help="Enable HAProxy stats dashboard with this password. Also set by HAPROXY_PASSWORD.")
|
||||
parser.add_argument("--haproxy-username", metavar="USERNAME",
|
||||
help="HAProxy stats dashboard username. Also set by HAPROXY_USERNAME.")
|
||||
parser.add_argument("--haproxy-stats-port", metavar="PORT",
|
||||
help="HAProxy stats dashboard port. Also set by HAPROXY_STATS_PORT.")
|
||||
parser.add_argument("--haproxy-stats-cors-origin", metavar="ORIGIN",
|
||||
help="Allowed CORS origin for the stats dashboard. Also set by HAPROXY_STATS_CORS_ORIGIN.")
|
||||
|
||||
# ACME / Certbot
|
||||
parser.add_argument("--certbot-email", metavar="EMAIL",
|
||||
help="Contact email for ACME/Let's Encrypt. Enables certbot when set. Also set by EASYHAPROXY_CERTBOT_EMAIL.")
|
||||
parser.add_argument("--certbot-autoconfig", metavar="CA",
|
||||
choices=["letsencrypt", "letsencrypt_test", "buypass", "buypass_test",
|
||||
"sslcom_rca", "sslcom_ecc", "google", "google_test", "zerossl"],
|
||||
help="Shorthand to configure a well-known ACME CA. Also set by EASYHAPROXY_CERTBOT_AUTOCONFIG.")
|
||||
parser.add_argument("--certbot-server", metavar="URL",
|
||||
help="Custom ACME server directory URL. Also set by EASYHAPROXY_CERTBOT_SERVER.")
|
||||
parser.add_argument("--certbot-eab-kid", metavar="KID",
|
||||
help="External Account Binding key ID (required by some CAs). Also set by EASYHAPROXY_CERTBOT_EAB_KID.")
|
||||
parser.add_argument("--certbot-eab-hmac-key", metavar="KEY",
|
||||
help="External Account Binding HMAC key. Also set by EASYHAPROXY_CERTBOT_EAB_HMAC_KEY.")
|
||||
parser.add_argument("--certbot-retry-count", metavar="N", type=int,
|
||||
help="Iterations before retrying after a rate limit. Also set by EASYHAPROXY_CERTBOT_RETRY_COUNT.")
|
||||
parser.add_argument("--certbot-preferred-challenges", metavar="TYPE",
|
||||
help="ACME challenge type (default: http). Also set by EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES.")
|
||||
parser.add_argument("--certbot-manual-auth-hook", metavar="SCRIPT",
|
||||
help="Path to manual auth hook script for certbot. Also set by EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK.")
|
||||
|
||||
# Plugins
|
||||
parser.add_argument("--plugins-enabled", metavar="LIST",
|
||||
help="Comma-separated list of plugins to enable. Also set by EASYHAPROXY_PLUGINS_ENABLED.")
|
||||
parser.add_argument("--plugins-abort-on-error", metavar="BOOL",
|
||||
choices=["true", "false"],
|
||||
help="Abort startup if a plugin fails to load. Also set by EASYHAPROXY_PLUGINS_ABORT_ON_ERROR.")
|
||||
|
||||
# Kubernetes
|
||||
parser.add_argument("--update-ingress-status", metavar="BOOL",
|
||||
choices=["true", "false"],
|
||||
help="Update Kubernetes Ingress status with load-balancer IP. Also set by EASYHAPROXY_UPDATE_INGRESS_STATUS.")
|
||||
parser.add_argument("--deployment-mode", metavar="MODE",
|
||||
choices=["auto", "single", "cluster"],
|
||||
help="Kubernetes deployment mode. Also set by EASYHAPROXY_DEPLOYMENT_MODE.")
|
||||
parser.add_argument("--external-hostname", metavar="HOSTNAME",
|
||||
help="External hostname reported in Ingress status. Also set by EASYHAPROXY_EXTERNAL_HOSTNAME.")
|
||||
parser.add_argument("--ingress-status-update-interval", metavar="SECONDS", type=int,
|
||||
help="Interval in seconds to update Ingress status. Also set by EASYHAPROXY_STATUS_UPDATE_INTERVAL.")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _apply_args_to_env(args: argparse.Namespace) -> None:
|
||||
"""Write non-None CLI arguments into os.environ so the rest of the code reads them."""
|
||||
mapping = {
|
||||
"discover": "EASYHAPROXY_DISCOVER",
|
||||
"base_path": "EASYHAPROXY_BASE_PATH",
|
||||
"label_prefix": "EASYHAPROXY_LABEL_PREFIX",
|
||||
"ssl_mode": "EASYHAPROXY_SSL_MODE",
|
||||
"refresh_conf": "EASYHAPROXY_REFRESH_CONF",
|
||||
"customer_errors": "HAPROXY_CUSTOMERRORS",
|
||||
"log_level": "EASYHAPROXY_LOG_LEVEL",
|
||||
"haproxy_log_level": "HAPROXY_LOG_LEVEL",
|
||||
"certbot_log_level": "CERTBOT_LOG_LEVEL",
|
||||
"haproxy_password": "HAPROXY_PASSWORD",
|
||||
"haproxy_username": "HAPROXY_USERNAME",
|
||||
"haproxy_stats_port": "HAPROXY_STATS_PORT",
|
||||
"haproxy_stats_cors_origin": "HAPROXY_STATS_CORS_ORIGIN",
|
||||
"certbot_email": "EASYHAPROXY_CERTBOT_EMAIL",
|
||||
"certbot_autoconfig": "EASYHAPROXY_CERTBOT_AUTOCONFIG",
|
||||
"certbot_server": "EASYHAPROXY_CERTBOT_SERVER",
|
||||
"certbot_eab_kid": "EASYHAPROXY_CERTBOT_EAB_KID",
|
||||
"certbot_eab_hmac_key": "EASYHAPROXY_CERTBOT_EAB_HMAC_KEY",
|
||||
"certbot_retry_count": "EASYHAPROXY_CERTBOT_RETRY_COUNT",
|
||||
"certbot_preferred_challenges": "EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES",
|
||||
"certbot_manual_auth_hook": "EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK",
|
||||
"plugins_enabled": "EASYHAPROXY_PLUGINS_ENABLED",
|
||||
"plugins_abort_on_error": "EASYHAPROXY_PLUGINS_ABORT_ON_ERROR",
|
||||
"update_ingress_status": "EASYHAPROXY_UPDATE_INGRESS_STATUS",
|
||||
"deployment_mode": "EASYHAPROXY_DEPLOYMENT_MODE",
|
||||
"external_hostname": "EASYHAPROXY_EXTERNAL_HOSTNAME",
|
||||
"ingress_status_update_interval": "EASYHAPROXY_STATUS_UPDATE_INTERVAL",
|
||||
}
|
||||
for arg_name, env_name in mapping.items():
|
||||
value = getattr(args, arg_name, None)
|
||||
if value is not None:
|
||||
os.environ[env_name] = str(value)
|
||||
|
||||
|
||||
def start():
|
||||
processor_obj = ProcessorInterface.factory(os.getenv("EASYHAPROXY_DISCOVER"))
|
||||
if processor_obj is None:
|
||||
exit(1)
|
||||
|
||||
os.makedirs(Consts.certs_certbot, exist_ok=True)
|
||||
os.makedirs(Consts.certs_haproxy, exist_ok=True)
|
||||
|
||||
processor_obj.save_config(Consts.haproxy_config)
|
||||
processor_obj.save_certs(Consts.certs_haproxy)
|
||||
certbot_certs_found = processor_obj.get_certbot_hosts()
|
||||
logger_easyhaproxy.info(f'Found hosts: {", ".join(processor_obj.get_hosts())}') # Needs to run after save_config
|
||||
logger_easyhaproxy.debug(f'Object Found: {processor_obj.get_parsed_object()}')
|
||||
|
||||
old_haproxy = None
|
||||
haproxy = DaemonizeHAProxy()
|
||||
current_custom_config_files = haproxy.get_custom_config_files()
|
||||
haproxy.haproxy(DaemonizeHAProxy.HAPROXY_START)
|
||||
haproxy.sleep()
|
||||
|
||||
certbot = Certbot(Consts.certs_certbot)
|
||||
|
||||
# Check ACME environment readiness if Certbot is configured
|
||||
if certbot.email != "":
|
||||
is_ready, error_msg = Certbot.check_acme_environment_ready(certbot.email, certbot.acme_server)
|
||||
if not is_ready:
|
||||
logger_easyhaproxy.warning(f"ACME environment not ready: {error_msg}")
|
||||
logger_easyhaproxy.warning("Certificate auto-renewal may fail. Verify ACME server configuration.")
|
||||
else:
|
||||
logger_easyhaproxy.info("ACME environment validated and ready")
|
||||
|
||||
while True:
|
||||
if old_haproxy is not None:
|
||||
old_haproxy.kill()
|
||||
old_haproxy = None
|
||||
try:
|
||||
old_parsed = processor_obj.get_parsed_object()
|
||||
processor_obj.refresh()
|
||||
if certbot.check_certificates(certbot_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive() or DeepDiff(current_custom_config_files, haproxy.get_custom_config_files()) != {}:
|
||||
logger_easyhaproxy.info('New configuration found. Reloading...')
|
||||
logger_easyhaproxy.debug(f'Object Found: {processor_obj.get_parsed_object()}')
|
||||
processor_obj.save_config(Consts.haproxy_config)
|
||||
processor_obj.save_certs(Consts.certs_haproxy)
|
||||
certbot_certs_found = processor_obj.get_certbot_hosts()
|
||||
logger_easyhaproxy.info(f'Found hosts: {", ".join(processor_obj.get_hosts())}') # Needs to after save_config
|
||||
old_haproxy = haproxy
|
||||
haproxy = DaemonizeHAProxy()
|
||||
current_custom_config_files = haproxy.get_custom_config_files()
|
||||
haproxy.haproxy(DaemonizeHAProxy.HAPROXY_RELOAD)
|
||||
old_haproxy.terminate()
|
||||
|
||||
except Exception as e:
|
||||
logger_easyhaproxy.fatal(f"Err: {e}")
|
||||
|
||||
logger_easyhaproxy.info('Heartbeat')
|
||||
haproxy.sleep()
|
||||
|
||||
|
||||
def main():
|
||||
haproxy_bin = shutil.which('haproxy')
|
||||
if haproxy_bin is None:
|
||||
print("ERROR: HAProxy is not installed or not in PATH.")
|
||||
print("Please install HAProxy before running easy-haproxy.")
|
||||
print(" Debian/Ubuntu: sudo apt install haproxy")
|
||||
print(" RHEL/Fedora: sudo dnf install haproxy")
|
||||
print(" macOS: brew install haproxy")
|
||||
sys.exit(1)
|
||||
|
||||
args = _build_parser().parse_args()
|
||||
_apply_args_to_env(args)
|
||||
|
||||
# Reset cached base_path so it re-evaluates after --base-path may have been applied
|
||||
Consts.reset()
|
||||
|
||||
Functions.run_bash(logger_init, f'{haproxy_bin} -v')
|
||||
|
||||
logger_init.info(r".........................__.....................................")
|
||||
logger_init.info(r"..___ ____ ________ __/ /_ ____ _____ _________ _ ____ __")
|
||||
logger_init.info(r"./ _ \/ __ `/ ___/ / / / __ \/ __ `/ __ \/ ___/ __ \| |/_/ / / /")
|
||||
logger_init.info(r"/ __/ /_/ (__ ) /_/ / / / / /_/ / /_/ / / / /_/ /> </ /_/ /.")
|
||||
logger_init.info(r"\___/\__,_/____/\__, /_/ /_/\__,_/ .___/_/ \____/_/|_|\__, /..")
|
||||
logger_init.info(r".............../____/.........../_/..................../____/...")
|
||||
|
||||
logger_init.info(f"Release: {os.getenv('RELEASE_VERSION')}")
|
||||
logger_init.debug('Environment:')
|
||||
for name, value in os.environ.items():
|
||||
if "HAPROXY" in name:
|
||||
logger_init.debug(f"- {name}: {value}")
|
||||
|
||||
start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from multiprocessing import Process
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import psutil
|
||||
|
|
@ -291,7 +293,13 @@ class Consts:
|
|||
def base_path(cls):
|
||||
"""Base directory for all EasyHAProxy files."""
|
||||
if cls._base_path is None:
|
||||
cls._base_path = os.getenv("EASYHAPROXY_BASE_PATH", "/etc/easyhaproxy")
|
||||
if os.getenv("EASYHAPROXY_BASE_PATH"):
|
||||
default = os.getenv("EASYHAPROXY_BASE_PATH")
|
||||
elif os.getuid() == 0:
|
||||
default = "/etc/easyhaproxy"
|
||||
else:
|
||||
default = str(Path.home() / "easyhaproxy")
|
||||
cls._base_path = default
|
||||
return cls._base_path
|
||||
|
||||
@classmethod
|
||||
|
|
@ -347,17 +355,18 @@ class DaemonizeHAProxy:
|
|||
self.thread.start()
|
||||
|
||||
def get_haproxy_command(self, action, pid_file="/run/haproxy.pid"):
|
||||
haproxy_bin = shutil.which('haproxy') or '/usr/sbin/haproxy'
|
||||
custom_config_files = ""
|
||||
if len(list(self.get_custom_config_files().keys())) != 0:
|
||||
custom_config_files = f"-f {self.custom_config_folder}"
|
||||
|
||||
if action == DaemonizeHAProxy.HAPROXY_START or not os.path.exists(pid_file):
|
||||
return f"/usr/sbin/haproxy -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -S /var/run/haproxy.sock"
|
||||
return f"{haproxy_bin} -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -S /var/run/haproxy.sock"
|
||||
else:
|
||||
return_code, output = Functions().run_bash(logger_haproxy, f"cat {pid_file}", log_output=False)
|
||||
pid = "".join(output).rstrip()
|
||||
if psutil.pid_exists(int(pid)):
|
||||
return f"/usr/sbin/haproxy -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -x /var/run/haproxy.sock -sf {pid}"
|
||||
return f"{haproxy_bin} -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -x /var/run/haproxy.sock -sf {pid}"
|
||||
else:
|
||||
os.unlink(pid_file)
|
||||
logger_haproxy.warning(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue