Newer
Older
EnvoyControlPlane / deployment / deploy.sh
#!/bin/bash
#
# SCRIPT: envoy-deploy.sh
# DESCRIPTION: Sets up .env, docker-compose.yaml, and config files for
#              an Envoy Control Plane (xDS) and Envoy Proxy deployment.
#              The Control Plane config files (CDS/LDS) are placed under
#              ${ENVOY_DATA_PATH}/data/config.
#              Designed to be run repeatedly with resource cleanup.
#
# USAGE: bash envoy-deploy.sh

# --- Configuration Defaults ---
# Path to the directory where all configuration files and persistent data will be stored
DEFAULT_DATA_PATH="."
# Ports
DEFAULT_DASHBOARD_PORT="8090" # For the Control Plane web dashboard
DEFAULT_HTTP_PORT="10000"     # Envoy HTTP Listener
DEFAULT_HTTPS_PORT="10001"    # Envoy HTTPS Listener
DEFAULT_ADMIN_PORT="11111"    # Envoy Admin Interface
# Envoy Node ID
DEFAULT_NODE_ID="home"

# --- Global Variables for File Paths (Set in generate_env_file) ---
# DEPLOY_BASE_PATH: The chosen path for config/data storage, derived from ENVOY_DATA_PATH
DEPLOY_BASE_PATH=""
# ENV_FILE_PATH: Full path to the .env file
ENV_FILE_PATH=""
# HOST_PATH_PREFIX: Retained for the check_path_type logic, but volumes now rely on absolute ENVOY_DATA_PATH.
HOST_PATH_PREFIX=""


# --- Functions ---

# Function to handle Docker login and repository access check
check_docker_access() {
    echo ""
    echo "################################################"
    echo "## πŸ”’ Checking Docker Repository Access"
    echo "################################################"
    echo "You need to log in to the private Docker registry: docker.jerxie.com"
    
    # Run docker login
    docker login docker.jerxie.com
    
    # Check the exit status of the docker login command
    if [ $? -ne 0 ]; then
        echo ""
        echo "## ⚠️ DOCKER LOGIN FAILED"
        echo "It appears the login to docker.jerxie.com was unsuccessful."
        echo "If you do not have credentials to access the repo 'docker.jerxie.com/xds-server:latest',"
        echo "please contact the author **axieyangb@gmail.com** for assistance."
        echo "Exiting script."
        exit 1
    fi
    echo "    -> Docker login successful."
}


# Function to prompt the user for an environment variable
prompt_for_env_var() {
    local var_name=$1
    local default_value=$2
    local prompt_msg=$3
    read -rp "Enter ${prompt_msg} (Default: ${default_value}): " user_input
    # Use the input or the default value
    eval "${var_name}='${user_input:-${default_value}}'"
}

# Function to prompt the user for LETSENCRYPT_STAGING
prompt_for_letsencrypt_staging() {
    local var_name=$1
    local prompt_msg=$2
    
    # Loop until a valid choice is made
    while true; do
        read -rp "${prompt_msg} (y/N): " user_choice
        
        # FIX: Use 'tr' for cross-shell compatibility to convert to lowercase
        user_choice=$(echo "${user_choice}" | tr '[:upper:]' '[:lower:]')  
        
        if [[ "${user_choice}" == "y" || "${user_choice}" == "yes" ]]; then
            eval "${var_name}='true'"
            break
        elif [[ "${user_choice}" == "n" || "${user_choice}" == "no" || -z "${user_choice}" ]]; then
            # An empty input defaults to "N" (no)
            eval "${var_name}='false'"
            break
        else
            echo "Invalid input. Please enter 'y' for Yes or 'n' for No."
        fi
    done
}

# Function to clean up previous deployment resources
cleanup_resources() {
    echo ""
    echo "## 🧹 Cleaning up previous Docker resources in '${DEPLOY_BASE_PATH}'..."

    # NOTE: Check if DEPLOY_BASE_PATH/docker-compose.yaml exists, not just "docker-compose.yaml"
    local compose_file="${DEPLOY_BASE_PATH}/docker-compose.yaml"
    
    # If $ENVOY_DATA_PATH was not set, DEPLOY_BASE_PATH may not be set yet on first run
    if [ -z "${DEPLOY_BASE_PATH}" ]; then
        # Use the default path for cleanup if DEPLOY_BASE_PATH is not yet set
        compose_file="${DEFAULT_DATA_PATH}/docker-compose.yaml"
    fi

    if [ -f "${compose_file}" ]; then
        # Load the .env variables for cleanup since they define volume paths
        export $(grep -v '^#' "${DEPLOY_BASE_PATH}/.env" 2>/dev/null | xargs)

        docker compose -f "${compose_file}" down -v --remove-orphans 2>/dev/null
        echo "    -> Previous containers and networks stopped and removed."
    else
        echo "    -> No previous docker-compose.yaml found, skipping cleanup."
    fi # <--- This was the missing 'fi'
}

# Function to detect if ENVOY_DATA_PATH is absolute or relative
# This is mainly for informational output now that we enforce absolute paths.
check_path_type() {
    # Check if the path starts with a / (Unix absolute path)
    if [[ "${ENVOY_DATA_PATH}" == /* ]]; then
        HOST_PATH_PREFIX=""
        echo "    -> Path '${ENVOY_DATA_PATH}' detected as **Absolute**."
    # Check if the path starts with ~ (home directory) - treat as absolute for Docker bind mounts
    elif [[ "${ENVOY_DATA_PATH}" == \~* ]]; then
        HOST_PATH_PREFIX=""
        echo "    -> Path '${ENVOY_DATA_PATH}' detected as **Absolute** (using ~)."
    else
        HOST_PATH_PREFIX="./"
        echo "    -> Path '${ENVOY_DATA_PATH}' detected as **Relative**."
    fi
}

# Function to generate the .env file
generate_env_file() {
    echo ""
    echo "## βš™οΈ Gathering Configuration for .env file"
    echo "---"

    prompt_for_env_var ENVOY_DATA_PATH "$DEFAULT_DATA_PATH" "Data Path (e.g., '.')"
    prompt_for_env_var CONTROL_PLANE_DASHBOARD_PORT "$DEFAULT_DASHBOARD_PORT" "Control Plane Dashboard Port"
    prompt_for_env_var ENVOY_HTTP_PORT "$DEFAULT_HTTP_PORT" "Envoy HTTP Port"
    prompt_for_env_var ENVOY_HTTPS_PORT "$DEFAULT_HTTPS_PORT" "Envoy HTTPS Port"
    prompt_for_env_var ENVOY_ADMIN_PORT "$DEFAULT_ADMIN_PORT" "Envoy Admin Port"
    prompt_for_env_var ENVOY_NODE_ID "$DEFAULT_NODE_ID" "Envoy Node ID"
    
    # NEW PROMPT for Let's Encrypt Staging
    prompt_for_letsencrypt_staging LETSENCRYPT_STAGING "Use Let's Encrypt **Staging** Environment? (Recommended for testing)"

    # --- FIX: Ensure ENVOY_DATA_PATH is always an absolute path ---
    echo ""
    echo "## πŸ—ΊοΈ Resolving Data Path to Absolute Path"
    
    # Change to the user-specified directory, resolve symbolic links, and get the absolute path
    if pushd "${ENVOY_DATA_PATH}" > /dev/null; then
        ENVOY_DATA_PATH=$(pwd -P)
        popd > /dev/null # Go back to the original directory
        echo "    -> Resolved to: **${ENVOY_DATA_PATH}**"
    else
        echo "    -> ERROR: Could not resolve path '${ENVOY_DATA_PATH}'. Exiting."
        exit 1
    fi
    
    # Determine path type (for info/legacy prefix logic)
    check_path_type

    # Set the deployment base path now that ENVOY_DATA_PATH is defined
    # This ensures all config files are created in the chosen directory (which is now absolute)
    DEPLOY_BASE_PATH="${ENVOY_DATA_PATH}"
    ENV_FILE_PATH="${DEPLOY_BASE_PATH}/.env"

    # Create the base directory if it doesn't exist
    mkdir -p "${DEPLOY_BASE_PATH}"

    echo ""
    echo "## πŸ“ Generating .env file..."

    cat << EOF > "${ENV_FILE_PATH}"
# .env file content
# This sets the default base path to the directory containing this file.
# NOTE: This path is now guaranteed to be absolute.
ENVOY_DATA_PATH=${ENVOY_DATA_PATH}

# External Ports
CONTROL_PLANE_DASHBOARD_PORT=${CONTROL_PLANE_DASHBOARD_PORT}
ENVOY_HTTP_PORT=${ENVOY_HTTP_PORT}
ENVOY_HTTPS_PORT=${ENVOY_HTTPS_PORT}
ENVOY_ADMIN_PORT=${ENVOY_ADMIN_PORT}

# Node ID parameter
ENVOY_NODE_ID=${ENVOY_NODE_ID}

# Let's Encrypt Staging Flag
# Set to 'true' to use the staging environment for testing (avoids rate limits).
LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING}
EOF

    echo "    -> .env file created at '${ENV_FILE_PATH}' with your configuration."
    echo ""
    echo "## πŸ“„ Contents of the generated .env file:"
    cat "${ENV_FILE_PATH}"
    echo "---"
}

# Function to create the directory structure
create_config_dirs() {
    # The control plane needs ${ENVOY_DATA_PATH}/data/config for CDS/LDS
    # The proxy needs ${ENVOY_DATA_PATH}/data/envoy_config for envoy.yaml
    
    # Define the target directory for Control Plane configs
    local cp_config_dir="${ENVOY_DATA_PATH}/data/config"
    # Define the target directory for Proxy configs
    local proxy_config_dir="${ENVOY_DATA_PATH}/data/envoy_config"
    
    echo ""
    echo "## πŸ“ Creating data and config directory structure..."
    mkdir -p "${cp_config_dir}"
    mkdir -p "${proxy_config_dir}"
    echo "    -> Directory '${cp_config_dir}' (for Control Plane) created/ensured."
    echo "    -> Directory '${proxy_config_dir}' (for Envoy Proxy) created/ensured."
    
    # FIX: Ensure the mounted directories have the correct permissions for the container user
    # This is vital for Docker bind mounts where the container runs as a non-root user.
    chmod -R 777 "${ENVOY_DATA_PATH}/data"
    echo "    -> Permissions set to 777 recursively on '${ENVOY_DATA_PATH}/data'."
    
    # Set the variable for file generation functions
    CONTROL_PLANE_CONFIG_PATH="${cp_config_dir}"
}

# Function to generate the docker-compose.yaml file
generate_docker_compose() {
    echo ""
    
    local compose_file="${DEPLOY_BASE_PATH}/docker-compose.yaml"
    echo "## 🐳 Generating ${compose_file}..."

    # NOTE: We use 'EOF' to allow environment variable substitution for the variables 
    # passed to docker-compose (like ${ENVOY_DATA_PATH}), and we rely on ENVOY_DATA_PATH 
    # being an absolute path now.
    cat << EOF > "${compose_file}"
version: "3.9"

services:
  # 1. The Envoy Control Plane
  envoy-control-plane:
    image: docker.jerxie.com/xds-server:latest
    container_name: envoy-control-plane
    restart: unless-stopped
    ports:
      # Exposes the gRPC XDS service port (18000) for the Envoy proxy to connect to
      - "${CONTROL_PLANE_DASHBOARD_PORT}:8080"
    volumes:
      # Maps the host's ${ENVOY_DATA_PATH}/data (now absolute) to the container's /app/data
      - ${ENVOY_DATA_PATH}/data:/app/data:rw
    command: ["--node-id", "${ENVOY_NODE_ID}", "--config-dir", "/app/data/config","--db","file:/app/data/data.db?_foreign_keys=on", "--enable-cert-issuance", "webroot-path=/app/data/acme"]
    environment:
      # Pass the staging flag to the control plane (it will handle using it)
      - LETSENCRYPT_STAGING=${LETSENCRYPT_STAGING}
    # Add a network to ensure both services can communicate
    networks:
      - envoy_network


  # 2. The Envoy Proxy
  envoy-proxy:
    # Use the official Envoy Docker image
    image: envoyproxy/envoy:v1.33.12 # Use a specific, stable version
    container_name: envoy-proxy
    restart: unless-stopped
    # Expose a port where the proxy will listen for client traffic (e.g., 11111 for admin, 10000,10001 for listener)
    ports:
      - "${ENVOY_HTTP_PORT}:10000"
      - "${ENVOY_HTTPS_PORT}:10001"
      - "${ENVOY_ADMIN_PORT}:11111"
    volumes:
      # Maps the host's ${ENVOY_DATA_PATH}/data/envoy_config (now absolute) to the container's /etc/config
      - ${ENVOY_DATA_PATH}/data/envoy_config:/etc/config:rw
    # The starting command you provided
    command: 
      - "envoy"
      - "-c"
      - "/etc/config/envoy.yaml"
    # Ensure this service waits for the control plane to be up
    depends_on:
      - envoy-control-plane
    # Connect to the same network as the control plane
    networks:
      - envoy_network
    
# Define a custom network for inter-service communication
networks:
  envoy_network:
    driver: bridge

EOF
    echo "    -> ${compose_file} created."
}

# Function to generate config/cds.yaml
generate_cds_yaml() {
    echo ""
    echo "## βš™οΈ Generating ${CONTROL_PLANE_CONFIG_PATH}/cds.yaml..."

    # The CONTROL_PLANE_CONFIG_PATH variable is set in create_config_dirs
    cat << 'EOF' > "${CONTROL_PLANE_CONFIG_PATH}/cds.yaml"
resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: _acme_renewer
  connect_timeout: 0.2s
  type: STRICT_DNS
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: acme_renewer
    endpoints:
    - lb_endpoints:
      - endpoint:
          health_check_config:
            port_value: 8080
          address:
            socket_address:
              # Targets the control plane service on the internal Docker network
              address: envoy-control-plane 
              port_value: 8080
EOF

    echo "    -> ${CONTROL_PLANE_CONFIG_PATH}/cds.yaml created."
}

# Function to generate config/lds.yaml
generate_lds_yaml() {
    echo ""
    echo "## βš™οΈ Generating ${CONTROL_PLANE_CONFIG_PATH}/lds.yaml..."

    # The CONTROL_PLANE_CONFIG_PATH variable is set in create_config_dirs
    cat << 'EOF' > "${CONTROL_PLANE_CONFIG_PATH}/lds.yaml"
resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
  name: http_listener
  address:
    socket_address: { address: 0.0.0.0, port_value: 10000 }
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        stat_prefix: ingress_http
        codec_type: AUTO
        route_config:
          name: ingress_generic_insecure
          virtual_hosts:
          - name: http_to_https
            domains: ["*"]
            routes:
            # ACME Challenge handling for certificate renewal
            - match: { prefix : "/.well-known/acme-challenge"}
              route: { cluster: _acme_renewer }
            # Redirect all other traffic to HTTPS
            - match: { prefix: "/" }
              redirect: { https_redirect: true }
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

## --- EMPTY HTTPS LISTENER ---
#- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
#  name: https_listener
#  address:
#    socket_address: { address: 0.0.0.0, port_value: 10001 }
#  listener_filters:
#  # This filter is necessary to inspect the connection for TLS/SNI 
#  # so you can later match specific filter chains based on hostname.
#  - name: "envoy.filters.listener.tls_inspector"
#    typed_config: 
#      "@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
#  filter_chains:
#  # FIX: Add a dummy/placeholder filter chain to satisfy Envoy's requirement
#  - filter_chain_match: {}
#    filters:
#    - name: envoy.filters.network.http_connection_manager
#      typed_config:
#        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
#        stat_prefix: placeholder_https
#        http_filters:
#        - name: envoy.filters.http.router
#          typed_config:
#            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
#        # NOTE: route_config is omitted/empty, so no traffic will be routed by default
#        route_config:
#          name: placeholder_route
#          virtual_hosts: []
EOF

    echo "    -> ${CONTROL_PLANE_CONFIG_PATH}/lds.yaml created."
}

# Function to generate config/envoy.yaml (Envoy Bootstrap Config)
generate_envoy_yaml() {
    # The proxy config directory is derived from ENVOY_DATA_PATH in create_config_dirs
    local proxy_config_dir="${ENVOY_DATA_PATH}/data/envoy_config"
    local config_file="${proxy_config_dir}/envoy.yaml"
    
    echo ""
    echo "## βš™οΈ Generating ${config_file} (Envoy Bootstrap)..."

    # Note: Using 'EOF' ensures shell variables like ${ENVOY_NODE_ID} are substituted
    cat << EOF > "${config_file}"
node:
  id: ${ENVOY_NODE_ID}
  cluster: home-cluster
  
dynamic_resources:
  ads_config:
    api_type: GRPC
    transport_api_version: V3
    grpc_services:
      - envoy_grpc:
          cluster_name: xds_cluster

  cds_config:
    ads: {}

  lds_config:
    ads: {}

admin:
  access_log_path: /dev/null
  address:
    socket_address:
      address: 0.0.0.0
      port_value: ${ENVOY_ADMIN_PORT}

static_resources:
  clusters:
    # xDS management server (used for CDS, LDS, and SDS)
    - name: xds_cluster
      connect_timeout: 1s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      typed_extension_protocol_options:
        envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
          "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
          explicit_http_config:
            http2_protocol_options: {}
      load_assignment:
        cluster_name: xds_cluster
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: envoy-control-plane
                      port_value: 18000
EOF

    echo "    -> ${config_file} created."
}

# Function to deploy the services
deploy_services() {
    echo ""
    echo "################################################"
    echo "## πŸš€ Starting Docker Deployment..."
    echo "################################################"
    
    local compose_file="${DEPLOY_BASE_PATH}/docker-compose.yaml"
    
    # Use -d for detached mode, --build to ensure latest images are used
    # We explicitly pass the .env file path using the --env-file option
    docker compose -f "${compose_file}" --env-file "${DEPLOY_BASE_PATH}/.env" up -d
    
    # Display the final status
    echo ""
    echo "Deployment Status:"
    docker compose -f "${compose_file}" --env-file "${DEPLOY_BASE_PATH}/.env" ps
    
    echo ""
    echo "βœ… **Deployment Complete!**"
    echo "You can access the Control Plane Dashboard at: http://localhost:${CONTROL_PLANE_DASHBOARD_PORT}"
    echo "You can access the Envoy Admin Interface at: http://localhost:${ENVOY_ADMIN_PORT}/stats"
}

# --- Main Script Execution ---

# 1. Check Docker Access before doing anything else
check_docker_access

# 2. Get user input and generate .env file, and establish DEPLOY_BASE_PATH
# This step now ensures ENVOY_DATA_PATH is an absolute path.
generate_env_file

# Load the new environment variables for the current script session
# DEPLOY_BASE_PATH is now set, and contains the .env file
if [ -f "${DEPLOY_BASE_PATH}/.env" ]; then
    export $(grep -v '^#' "${DEPLOY_BASE_PATH}/.env" | xargs)
fi

# 3. Clean up old resources (essential for robust repeat running)
cleanup_resources

# 4. Create necessary folders (and set permissions)
create_config_dirs

# 5. Generate other configuration files
generate_docker_compose
generate_cds_yaml
generate_lds_yaml
generate_envoy_yaml

# 6. Deploy the services
deploy_services