feat: add Docker deployment with HAProxy and blue-green strategy

This commit is contained in:
badblocks 2025-07-21 23:25:19 -07:00
parent 3cfa59d3a5
commit 5be1e5add5
No known key found for this signature in database
26 changed files with 56198 additions and 582 deletions

374
deploy/scripts/common-lib.sh Executable file
View file

@ -0,0 +1,374 @@
#!/bin/bash
readonly BLUE_COLOR="blue"
readonly GREEN_COLOR="green"
readonly CORE_PROJECT_NAME="portfolio"
readonly DEPLOYMENT_LABEL="deployment.color"
readonly RETRY_MAX_ATTEMPTS="${RETRY_MAX_ATTEMPTS:-5}"
readonly RETRY_DELAY="${RETRY_DELAY:-5}"
execute_if_not_dry() {
local description="$1"
shift
if [ "$DRY_RUN" = true ]; then
indent_output echo "[DRY RUN] Would execute: $description"
indent_output echo " Command: $*"
else
"$@"
fi
}
execute_or_fail() {
local description="$1"
shift
if [ "$DRY_RUN" = true ]; then
indent_output echo "[DRY RUN] Would execute: $description"
indent_output echo " Command: $*"
else
if ! "$@"; then
echo "❌ Error: Failed to $description"
exit 1
fi
fi
}
execute_or_warn() {
local description="$1"
shift
if [ "$DRY_RUN" = true ]; then
indent_output echo "[DRY RUN] Would execute: $description"
indent_output echo " Command: $*"
else
if ! "$@"; then
echo "⚠️ Warning: Failed to $description (continuing anyway)"
fi
fi
}
retry() {
local max_attempts=$RETRY_MAX_ATTEMPTS
local delay=$RETRY_DELAY
local attempt=1
local exit_code=0
until "$@"; do
exit_code=$?
if [ "$attempt" -ge "$max_attempts" ]; then
echo "❌ Command failed after $max_attempts attempts: $*" >&2
return $exit_code
fi
echo "⚠️ Attempt $attempt failed, retrying in ${delay}s..." >&2
sleep "$delay"
delay=$((delay * 2))
attempt=$((attempt + 1))
done
if [ $attempt -gt 1 ]; then
echo "✅ Command succeeded after $attempt attempts"
fi
return 0
}
run_on_target() {
if [[ -n "${DEPLOY_HOST}" ]]; then
ssh deploy -q -E /dev/null "$*"
else
bash -c -- "$*"
fi
}
require_var() {
local var_name=$1
local var_value=${!var_name:-}
if [ -z "$var_value" ]; then
echo "❌ Deployment Error: Required environment variable '${var_name}' is not set or is empty" >&2
echo " Please check your GitHub Actions secrets, workflow environment variables," >&2
echo " or deployment configuration to ensure '${var_name}' is properly defined." >&2
exit 1
fi
}
# Helper function for common error handling pattern
error_exit() {
echo "❌ ERROR: $1" >&2
exit 1
}
# Helper function for command execution with error handling
run_or_exit() {
local description="$1"
shift
if ! "$@"; then
error_exit "$description"
fi
}
get_current_color() {
local blue_count=$(count_color_containers "$BLUE_COLOR")
local green_count=$(count_color_containers "$GREEN_COLOR")
if [ "$blue_count" -gt 0 ] && [ "$green_count" -eq 0 ]; then
echo "$BLUE_COLOR"
elif [ "$green_count" -gt 0 ] && [ "$blue_count" -eq 0 ]; then
echo "$GREEN_COLOR"
elif [ "$blue_count" -gt 0 ] && [ "$green_count" -gt 0 ]; then
local project_name_blue=$(get_project_name "$BLUE_COLOR")
local project_name_green=$(get_project_name "$GREEN_COLOR")
local blue_newest=$(docker inspect --format='{{.Created}}' "$(docker ps -q --filter "label=com.docker.compose.project=$project_name_blue" | head -1)" 2>/dev/null || echo '1970-01-01')
local green_newest=$(docker inspect --format='{{.Created}}' "$(docker ps -q --filter "label=com.docker.compose.project=$project_name_green" | head -1)" 2>/dev/null || echo '1970-01-01')
if [[ "$blue_newest" > "$green_newest" ]]; then
echo "$BLUE_COLOR"
else
echo "$GREEN_COLOR"
fi
else
echo "none"
fi
}
get_deployment_state() {
local blue_count=$(count_color_containers "$BLUE_COLOR")
local green_count=$(count_color_containers "$GREEN_COLOR")
if [ "$blue_count" -gt 0 ] && [ "$green_count" -gt 0 ]; then
echo "both"
elif [ "$blue_count" -gt 0 ]; then
echo "$BLUE_COLOR"
elif [ "$green_count" -gt 0 ]; then
echo "$GREEN_COLOR"
else
echo "none"
fi
}
is_deployment_in_progress() {
local deployment_state=$(get_deployment_state)
[ "$deployment_state" = "both" ]
}
switch_color() {
[ "$1" = "$BLUE_COLOR" ] && echo "$GREEN_COLOR" || echo "$BLUE_COLOR"
}
get_project_name() {
local color=$1
local env_suffix=$([ "${PROD:-}" = "true" ] && echo "prod" || echo "staging")
echo "${CORE_PROJECT_NAME}-${env_suffix}-${color}"
}
get_compose_files() {
echo "-f docker-compose_web.yml"
}
refresh_proxy() {
echo "🔄 Refreshing proxy configuration..."
}
count_containers() {
local filters=$1
docker ps ${filters} -q 2>/dev/null | wc -l | tr -d '\n' || echo 0
}
count_color_containers() {
local color=$1
local project_name=$(get_project_name "$color")
docker ps --filter "label=com.docker.compose.project=$project_name" -q 2>/dev/null | wc -l
}
# Removed get_previous_release_path - no longer needed as we use direct container cleanup
cleanup_color_containers() {
local color=$1
local project_name=$(get_project_name "$color")
# Get container IDs using the same filter logic as get_current_color
local container_ids=$(run_on_target "docker ps --filter 'label=com.docker.compose.project=$project_name' -q 2>/dev/null")
if [ -n "$container_ids" ]; then
echo "🛑 Stopping $color containers from project: $project_name"
# Stop containers directly by ID, with timeout
run_on_target "echo '$container_ids' | xargs -r docker stop --timeout 10 2>/dev/null || true"
echo "🗑️ Removing $color containers from project: $project_name"
# Remove containers directly by ID
run_on_target "echo '$container_ids' | xargs -r docker rm -f 2>/dev/null || true"
else
echo " No $color containers found to clean up"
fi
}
wait_with_countdown() {
local seconds=$1
local message=$2
echo -n "$message"
for ((i=seconds; i>0; i--)); do
echo -n " $i"
sleep 1
done
echo " done!"
}
get_web_service_name() {
echo "portfolio"
}
validate_deployment_env() {
require_var "REPO_PROJECT_PATH"
require_var "PROD"
require_var "DOMAIN"
export CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current"
export RELEASES_PATH="${REPO_PROJECT_PATH}/releases"
}
get_health_check_status() {
local statuses=$(docker ps --format '{{.Names}} {{.Status}}')
local unhealthy_count=0
local IFS=$'\n'
for status in $statuses; do
local name=$(echo $status | cut -d' ' -f1)
local status=$(echo $status | cut -d' ' -f2-)
if [[ "$status" == *"unhealthy"* ]]; then
unhealthy_count=$((unhealthy_count + 1))
echo "❌ Unhealthy: $name [$status]"
else
echo "✅ Healthy: $name [$status]"
fi
done
return $unhealthy_count
}
wait_for_healthy_containers() {
local project_name=$1
local service_name=$2
local expected_count=$3
local max_attempts=60
local attempt=0
echo "⏳ Waiting for $service_name containers to be healthy..."
while [ $attempt -lt $max_attempts ]; do
healthy_count=$(count_containers "--filter label=com.docker.compose.project=${project_name} --filter label=com.docker.compose.service=${service_name} --filter health=healthy")
if [[ "$healthy_count" -eq "$expected_count" ]]; then
echo "✅ All $service_name containers are healthy ($healthy_count/$expected_count)"
return 0
fi
echo "⏳ Healthy containers: $healthy_count/$expected_count (attempt $((attempt+1))/$max_attempts)"
sleep 5
attempt=$((attempt + 1))
done
echo "❌ Timeout waiting for $service_name containers to be healthy"
return 1
}
list_releases() {
local REPO_PROJECT_PATH=$1
local RELEASES_PATH="${REPO_PROJECT_PATH}/releases"
local CURRENT_LINK_PATH="${REPO_PROJECT_PATH}/current"
echo "📋 Available releases:"
if [ -d "$RELEASES_PATH" ]; then
for release in $(ls -dt ${RELEASES_PATH}/*); do
version=$(basename "$release")
status=""
if [ -L "$CURRENT_LINK_PATH" ] && [ "$(readlink -f "$CURRENT_LINK_PATH")" = "$(realpath "$release")" ]; then
status=" [CURRENT]"
fi
if [ -f "${release}/.failed" ]; then
status="${status} [FAILED]"
fi
indent_output echo "- ${version}${status}"
done
else
indent_output echo "No releases found"
fi
}
get_deployment_image_tag() {
local color=$1
local container=$(docker ps --filter "label=com.docker.compose.project=${CORE_PROJECT_NAME}-${color}" --format '{{.Names}}'| head -1)
if [ -n "$container" ]; then
docker inspect "${container}" --format '{{index .Config.Labels "deployment.image_tag"}}'
else
echo "unknown"
fi
}
prefix_output() {
local prefix=" "
if [ $# -lt 2 ]; then
echo "Error: prefix_output requires at least 2 arguments" >&2
return 1
fi
prefix="$1"
shift
"$@" 2>&1 | sed "s/^/${prefix}/"
return ${PIPESTATUS[0]}
}
indent_output() {
local indent=" "
if [[ "$1" =~ ^[[:space:]]+$ ]]; then
indent="$1"
shift
fi
prefix_output "$indent" "$@"
}
run_with_header() {
local header="$1"
shift
echo "$header"
indent_output " " "$@"
}
substitute_env_vars() {
local file_path="$1"
if [ ! -f "$file_path" ]; then
echo "❌ Error: File '$file_path' does not exist" >&2
return 1
fi
while IFS= read -r line; do
while [[ "$line" =~ \$\{([A-Za-z_][A-Za-z0-9_]*)\} ]]; do
local var_name="${BASH_REMATCH[1]}"
local var_value="${!var_name:-}"
line="${line//\$\{${var_name}\}/${var_value}}"
done
echo "$line"
done < "$file_path" > "$file_path.tmp" && mv "$file_path.tmp" "$file_path"
}
substitute_env_vars_remote() {
local file_path="$1"
if [[ -n "${DEPLOY_HOST}" ]]; then
run_on_target "$(declare -f substitute_env_vars); substitute_env_vars '$file_path'"
else
substitute_env_vars "$file_path"
fi
}