vibecoded-personal-site/deploy/scripts/common-lib.sh

374 lines
10 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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
}