MCP/mcp.sh

1135 lines
37 KiB
Bash
Raw Normal View History

2025-07-30 17:02:30 +00:00
#!/bin/bash
# MCP - Master Control Process for Discord bot instances
# Uses INI-style configuration file
#
# Program Flow:
# 1. Check dependencies and validate arguments
# 2. Load and validate configuration from INI file
# 3. Ensure only one MCP instance is running
# 4. Set up signal handlers for graceful shutdown and control
# 5. Clean up any stale PID files from previous runs
# 6. Validate all client configurations
# 7. Enter main control loop:
# - Check for crashed clients and restart them
# - Periodically check for git updates (selective updates)
# - Monitor configuration file changes
# - Sleep and repeat
set -euo pipefail
#=============================================================================
# DEPENDENCY CHECKING
#=============================================================================
# Check for required tools at startup.
# Exits if any required dependencies are missing.
check_dependencies() {
local missing_deps=()
command -v git >/dev/null || missing_deps+=("git")
command -v awk >/dev/null || missing_deps+=("awk")
command -v grep >/dev/null || missing_deps+=("grep")
command -v sed >/dev/null || missing_deps+=("sed")
command -v stat >/dev/null || missing_deps+=("stat")
if [[ ${#missing_deps[@]} -gt 0 ]]; then
echo "Error: Missing required dependencies: ${missing_deps[*]}" >&2
echo "Please install the missing tools and try again." >&2
exit 1
fi
}
# Run dependency check immediately.
check_dependencies
#=============================================================================
# ARGUMENT HANDLING
#=============================================================================
# Handle built-in commands before normal startup.
case "${1:-}" in
"status")
# Status command will be handled after configuration loading
;;
"help"|"-h"|"--help")
echo "MCP - Master Control Process for Discord bot instances"
echo ""
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " status Show current MCP and client status"
echo " help Show this help message"
echo ""
echo "Configuration:"
echo " Set MCP_CONFIG_FILE environment variable to use custom config file"
echo " Default: ./mcp.conf"
echo ""
echo "Signals:"
echo " TERM/INT/QUIT Graceful shutdown"
echo " HUP Reload configuration (restart required)"
echo " USR1 Show status in logs"
echo " USR2 Toggle git updates on/off"
exit 0
;;
esac
#=============================================================================
# CONFIGURATION LOADING
#=============================================================================
# Determine the absolute path of the directory containing this script.
# This is used to locate the default configuration file.
# Exits on failure to determine directory.
if ! SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; then
echo "Error: Cannot determine script directory" >&2
exit 1
fi
readonly SCRIPT_DIR
# Set the configuration file path, using MCP_CONFIG_FILE environment variable
# if set, otherwise defaults to mcp.conf in the script directory.
# Exits on failure to construct path.
if ! CONFIG_FILE="${MCP_CONFIG_FILE:-${SCRIPT_DIR}/mcp.conf}"; then
echo "Error: Cannot set configuration file path" >&2
exit 1
fi
readonly CONFIG_FILE
# Parse INI-style configuration file to extract a value.
# Uses awk to find [section] then locate key=value pair.
# Parameters:
# $1 - section: INI section name (without brackets)
# $2 - key: Configuration key name
# $3 - default: Default value if key not found
# Returns: Configuration value or default
get_config_value() {
local section="$1"
local key="$2"
local default="${3:-}"
awk -F= -v section="[$section]" -v key="$key" '
$0 == section { in_section = 1; next }
/^\[/ { in_section = 0; next }
in_section && $1 ~ "^[[:space:]]*" key "[[:space:]]*$" {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2)
print $2
exit
}
' "$CONFIG_FILE" || echo "$default"
}
# Extract all [client.*] section names from configuration file.
# Uses grep and sed to find and extract client names.
# Returns: List of client names (without [client.] prefix)
get_client_sections() {
if ! grep -o '^\[client\.[^]]*\]' "$CONFIG_FILE" | sed 's/^\[client\.\(.*\)\]$/\1/'; then
# Return empty if no client sections found (not an error).
true
fi
}
# Check if the configuration file exists and is readable.
# Exits if configuration file cannot be found or read.
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "Error: Configuration file not found: $CONFIG_FILE" >&2
exit 1
fi
# Load core MCP configuration settings into readonly variables.
# Loads: MCP_PID_FILE, MCP_INTERVAL, MCP_RESTART_DELAY, MCP_GIT_CHECK_INTERVAL,
# MCP_DEFAULT_GIT_BRANCH, BOT_DEFAULT_SCRIPT_NAME, BOT_DEFAULT_PID_FILENAME,
# BOT_DEFAULT_START_STAGGER, MCP_LOG_LEVEL
# Exits on failure to read any configuration value.
load_mcp_config() {
local temp_value
if ! temp_value=$(get_config_value "mcp" "pid_file" "./mcp.pid"); then
echo "Error: Failed to read MCP PID file setting" >&2
exit 1
fi
readonly MCP_PID_FILE="$temp_value"
if ! temp_value=$(get_config_value "mcp" "interval" "30"); then
echo "Error: Failed to read MCP interval setting" >&2
exit 1
fi
readonly MCP_INTERVAL="$temp_value"
if ! temp_value=$(get_config_value "mcp" "restart_delay" "5"); then
echo "Error: Failed to read MCP restart delay setting" >&2
exit 1
fi
readonly MCP_RESTART_DELAY="$temp_value"
if ! temp_value=$(get_config_value "mcp" "log_level" "INFO"); then
echo "Error: Failed to read MCP log level setting" >&2
exit 1
fi
readonly MCP_LOG_LEVEL="$temp_value"
if ! temp_value=$(get_config_value "git.default" "check_interval" "300"); then
echo "Error: Failed to read git check interval setting" >&2
exit 1
fi
readonly MCP_GIT_CHECK_INTERVAL="$temp_value"
if ! temp_value=$(get_config_value "git.default" "branch" "main"); then
echo "Error: Failed to read default git branch setting" >&2
exit 1
fi
readonly MCP_DEFAULT_GIT_BRANCH="$temp_value"
if ! temp_value=$(get_config_value "git.default" "enabled" "true"); then
echo "Error: Failed to read git updates enabled setting" >&2
exit 1
fi
readonly MCP_GIT_UPDATES_DEFAULT="$temp_value"
if ! temp_value=$(get_config_value "bot.default" "script_name" "discord-bot.js"); then
echo "Error: Failed to read default bot script name setting" >&2
exit 1
fi
readonly BOT_DEFAULT_SCRIPT_NAME="$temp_value"
if ! temp_value=$(get_config_value "bot.default" "pid_filename" "bot.pid"); then
echo "Error: Failed to read default bot PID filename setting" >&2
exit 1
fi
readonly BOT_DEFAULT_PID_FILENAME="$temp_value"
if ! temp_value=$(get_config_value "bot.default" "start_stagger" "2"); then
echo "Error: Failed to read default bot start stagger setting" >&2
exit 1
fi
readonly BOT_DEFAULT_START_STAGGER="$temp_value"
}
# Associative arrays for client configuration
declare -A CLIENT_DIRECTORY
declare -A CLIENT_SCRIPT_NAME
declare -A CLIENT_PID_FILENAME
declare -A CLIENT_START_STAGGER
declare -A CLIENT_GIT_BRANCH
declare -A CLIENT_ENABLED
# Load configuration for each client into associative arrays.
# Populates: CLIENT_DIRECTORY, CLIENT_SCRIPT_NAME, CLIENT_PID_FILENAME,
# CLIENT_START_STAGGER, CLIENT_GIT_BRANCH, CLIENT_ENABLED
# Each client inherits from bot.default unless explicitly overridden.
# Exits on failure to read any client configuration.
load_client_configs() {
local client_names
local temp_value
if ! client_names=$(get_client_sections); then
echo "Error: Failed to get client sections from config" >&2
exit 1
fi
for client_name in $client_names; do
if ! temp_value=$(get_config_value "client.$client_name" "directory" ""); then
echo "Error: Failed to read directory for client $client_name" >&2
exit 1
fi
CLIENT_DIRECTORY["$client_name"]="$temp_value"
if ! temp_value=$(get_config_value "client.$client_name" "script_name" "$BOT_DEFAULT_SCRIPT_NAME"); then
echo "Error: Failed to read script name for client $client_name" >&2
exit 1
fi
CLIENT_SCRIPT_NAME["$client_name"]="$temp_value"
if ! temp_value=$(get_config_value "client.$client_name" "pid_filename" "$BOT_DEFAULT_PID_FILENAME"); then
echo "Error: Failed to read PID filename for client $client_name" >&2
exit 1
fi
CLIENT_PID_FILENAME["$client_name"]="$temp_value"
if ! temp_value=$(get_config_value "client.$client_name" "start_stagger" "$BOT_DEFAULT_START_STAGGER"); then
echo "Error: Failed to read start stagger for client $client_name" >&2
exit 1
fi
CLIENT_START_STAGGER["$client_name"]="$temp_value"
if ! temp_value=$(get_config_value "client.$client_name" "git_branch" "$MCP_DEFAULT_GIT_BRANCH"); then
echo "Error: Failed to read git branch for client $client_name" >&2
exit 1
fi
CLIENT_GIT_BRANCH["$client_name"]="$temp_value"
if ! temp_value=$(get_config_value "client.$client_name" "enabled" "true"); then
echo "Error: Failed to read enabled status for client $client_name" >&2
exit 1
fi
CLIENT_ENABLED["$client_name"]="$temp_value"
done
}
# Validate configuration values for correctness.
# Checks numeric ranges, path validity, and logical constraints.
# Exits on any validation failure.
validate_config_values() {
# Validate numeric values
if [[ ! "$MCP_INTERVAL" =~ ^[0-9]+$ ]] || ((MCP_INTERVAL < 1)); then
echo "Error: MCP interval must be a positive integer: $MCP_INTERVAL" >&2
exit 1
fi
if [[ ! "$MCP_RESTART_DELAY" =~ ^[0-9]+$ ]] || ((MCP_RESTART_DELAY < 0)); then
echo "Error: MCP restart delay must be a non-negative integer: $MCP_RESTART_DELAY" >&2
exit 1
fi
if [[ ! "$MCP_GIT_CHECK_INTERVAL" =~ ^[0-9]+$ ]] || ((MCP_GIT_CHECK_INTERVAL < 60)); then
echo "Error: Git check interval must be at least 60 seconds: $MCP_GIT_CHECK_INTERVAL" >&2
exit 1
fi
if [[ ! "$BOT_DEFAULT_START_STAGGER" =~ ^[0-9]+$ ]] || ((BOT_DEFAULT_START_STAGGER < 0)); then
echo "Error: Default start stagger must be a non-negative integer: $BOT_DEFAULT_START_STAGGER" >&2
exit 1
fi
# Validate log level
case "$MCP_LOG_LEVEL" in
DEBUG|INFO|WARN|ERROR) ;;
*)
echo "Error: Invalid log level '$MCP_LOG_LEVEL'. Must be: DEBUG, INFO, WARN, ERROR" >&2
exit 1
;;
esac
# Validate PID file directory is writable
local pid_dir
pid_dir=$(dirname "$MCP_PID_FILE")
if [[ ! -d "$pid_dir" ]]; then
echo "Error: PID file directory does not exist: $pid_dir" >&2
exit 1
fi
if [[ ! -w "$pid_dir" ]]; then
echo "Error: PID file directory is not writable: $pid_dir" >&2
exit 1
fi
}
# Load all configuration at startup.
load_mcp_config
load_client_configs
validate_config_values
# Global variable for git updates toggle (can be changed by signal).
GIT_UPDATES_ENABLED="$MCP_GIT_UPDATES_DEFAULT"
#=============================================================================
# LOGGING
#=============================================================================
# Output timestamped debug message to stdout if debug logging is enabled.
# Format: [YYYY-MM-DD HH:MM:SS] [DEBUG] message
# Parameters: All parameters are concatenated as the message.
log_debug() {
[[ "$MCP_LOG_LEVEL" == "DEBUG" ]] && echo "[$(date '+%Y-%m-%d %H:%M:%S')] [DEBUG] $*"
}
# Output timestamped informational message to stdout.
# Format: [YYYY-MM-DD HH:MM:SS] [MCP] message
# Parameters: All parameters are concatenated as the message.
log_info() {
case "$MCP_LOG_LEVEL" in
DEBUG|INFO) echo "[$(date '+%Y-%m-%d %H:%M:%S')] [MCP] $*" ;;
esac
}
# Alias for backward compatibility.
log() { log_info "$@"; }
# Output timestamped warning message to stdout.
# Format: [YYYY-MM-DD HH:MM:SS] [WARN] message
# Parameters: All parameters are concatenated as the warning message.
log_warn() {
case "$MCP_LOG_LEVEL" in
DEBUG|INFO|WARN) echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" ;;
esac
}
# Output timestamped error message to stderr.
# Format: [YYYY-MM-DD HH:MM:SS] [ERROR] message
# Parameters: All parameters are concatenated as the error message.
log_error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2
}
#=============================================================================
# ATOMIC FILE OPERATIONS
#=============================================================================
# Atomically update a file with new content.
# Uses temporary file and move operation for atomicity.
# Parameters:
# $1 - target_file: File to update
# $2 - content: Content to write to file
# Returns: 0 on success, 1 on failure
atomic_file_update() {
local target_file="$1"
local content="$2"
local temp_file="${target_file}.tmp.$$"
if echo "$content" > "$temp_file" && mv "$temp_file" "$target_file"; then
return 0
else
rm -f "$temp_file" 2>/dev/null
return 1
fi
}
# Atomically update timestamp file.
# Parameters:
# $1 - timestamp_file: File to update with timestamp
# $2 - current_time: Timestamp value to write
# Returns: 0 on success, 1 on failure
update_timestamp_file() {
local timestamp_file="$1"
local current_time="$2"
if ! atomic_file_update "$timestamp_file" "$current_time"; then
log_error "Failed to update timestamp file: $timestamp_file"
return 1
fi
}
#=============================================================================
# SINGLE INSTANCE ENFORCEMENT
#=============================================================================
# Enforce single MCP instance by checking PID file.
# Process:
# 1. Check if MCP PID file exists
# 2. If it exists, verify the process is still running
# 3. If running, exit with error (another MCP is active)
# 4. If not running, remove stale PID file
# 5. Write current process PID to PID file
# Exits if another MCP instance is already running.
check_mcp_instance() {
if [[ -f "$MCP_PID_FILE" ]]; then
local pid
if ! pid=$(cat "$MCP_PID_FILE"); then
log_error "Cannot read MCP PID file: $MCP_PID_FILE"
exit 1
fi
if kill -0 "$pid" 2>/dev/null; then
log_error "MCP is already running (PID: $pid)"
exit 1
else
log_info "Removing stale MCP PID file"
rm -f "$MCP_PID_FILE"
fi
fi
if ! atomic_file_update "$MCP_PID_FILE" "$$"; then
log_error "Failed to create MCP PID file: $MCP_PID_FILE"
exit 1
fi
log_info "MCP started (PID: $$)"
}
# Graceful shutdown handler for MCP process.
# Process:
# 1. Log shutdown message
# 2. Remove MCP PID file
# 3. Exit cleanly
# Called by signal handlers and normal shutdown.
cleanup_mcp() {
log_info "MCP shutting down"
rm -f "$MCP_PID_FILE"
exit 0
}
#=============================================================================
# CONFIGURATION MONITORING
#=============================================================================
# Monitor configuration file for changes.
# Logs warning if configuration file has been modified since startup.
# Used to notify operators that a restart might be needed.
check_config_changes() {
local config_mtime_file="${SCRIPT_DIR}/.config_mtime"
local config_mtime
if ! config_mtime=$(stat -c %Y "$CONFIG_FILE" 2>/dev/null); then
log_warn "Cannot stat configuration file: $CONFIG_FILE"
return 1
fi
if [[ -f "$config_mtime_file" ]]; then
local last_mtime
if last_mtime=$(cat "$config_mtime_file") && [[ "$config_mtime" != "$last_mtime" ]]; then
log_warn "Configuration file has been modified - restart recommended"
fi
fi
if ! atomic_file_update "$config_mtime_file" "$config_mtime"; then
log_debug "Failed to update config mtime file (non-critical)"
fi
}
#=============================================================================
# STATUS REPORTING
#=============================================================================
# Show current MCP and client status.
# Displays MCP process status and status of all configured clients.
# Used by status command and USR1 signal handler.
show_status() {
echo "=== MCP Status ==="
echo "Configuration: $CONFIG_FILE"
echo "Log level: $MCP_LOG_LEVEL"
echo "Git updates: $([ "$GIT_UPDATES_ENABLED" = "true" ] && echo "enabled" || echo "disabled")"
echo ""
# MCP status
if [[ -f "$MCP_PID_FILE" ]]; then
local mcp_pid
if mcp_pid=$(cat "$MCP_PID_FILE") && kill -0 "$mcp_pid" 2>/dev/null; then
echo "✓ MCP running (PID: $mcp_pid)"
else
echo "✗ MCP PID file exists but process is dead"
fi
else
echo "✗ MCP not running"
fi
echo ""
echo "=== Client Status ==="
local found_clients=false
for client_name in "${!CLIENT_ENABLED[@]}"; do
found_clients=true
local status_icon="○"
local status_text="stopped"
local pid_info=""
if [[ "${CLIENT_ENABLED[$client_name]}" != "true" ]]; then
status_icon="⊝"
status_text="disabled"
else
local pid_file
pid_file=$(get_client_pid_file "$client_name")
if [[ -f "$pid_file" ]]; then
local client_pid
if client_pid=$(cat "$pid_file") && kill -0 "$client_pid" 2>/dev/null; then
status_icon="✓"
status_text="running"
pid_info=" (PID: $client_pid)"
else
status_icon="✗"
status_text="crashed"
pid_info=" (stale PID: $client_pid)"
fi
fi
fi
printf "%s %-20s %s%s - %s\n" \
"$status_icon" \
"$client_name" \
"$status_text" \
"$pid_info" \
"${CLIENT_DIRECTORY[$client_name]}"
done
if [[ "$found_clients" == false ]]; then
echo "No clients configured"
fi
}
# Handle status command if requested.
if [[ "${1:-}" == "status" ]]; then
show_status
exit 0
fi
#=============================================================================
# SIGNAL HANDLERS
#=============================================================================
# Reload configuration (placeholder - requires restart).
# Logs message indicating restart is required for config changes.
reload_config() {
log_warn "Configuration reload requested - restart MCP to apply changes"
}
# Show status in logs.
# Outputs status information to log instead of stdout.
log_status() {
log_info "=== Status Check Requested ==="
log_info "Git updates: $([ "$GIT_UPDATES_ENABLED" = "true" ] && echo "enabled" || echo "disabled")"
local enabled_count=0
local running_count=0
for client_name in "${!CLIENT_ENABLED[@]}"; do
if [[ "${CLIENT_ENABLED[$client_name]}" == "true" ]]; then
((enabled_count++))
if is_client_running "$client_name"; then
((running_count++))
fi
fi
done
log_info "Clients: $running_count/$enabled_count running"
}
# Toggle git updates on/off.
# Switches between enabled and disabled states for git update checking.
toggle_git_updates() {
if [[ "$GIT_UPDATES_ENABLED" == "true" ]]; then
GIT_UPDATES_ENABLED="false"
log_info "Git updates disabled by signal"
else
GIT_UPDATES_ENABLED="true"
log_info "Git updates enabled by signal"
fi
}
# Configure signal handlers for proper cleanup and control.
# Handlers:
# TERM/INT/QUIT: Call cleanup_mcp() for graceful shutdown
# HUP: Reload configuration (restart required)
# USR1: Show status in logs
# USR2: Toggle git updates on/off
# ERR: Log error with line number when script fails
setup_signal_handlers() {
trap cleanup_mcp TERM INT QUIT
trap reload_config SIGHUP
trap log_status SIGUSR1
trap toggle_git_updates SIGUSR2
trap 'log_error "MCP crashed on line $LINENO"' ERR
}
#=============================================================================
# CLIENT MANAGEMENT FUNCTIONS
#=============================================================================
# Construct full path to a client's PID file.
# Parameters: $1 - client_name: Name of the client
# Returns: Full path to client's PID file (e.g., /path/to/client/bot.pid)
get_client_pid_file() {
local client_name="$1"
local client_dir="${CLIENT_DIRECTORY[$client_name]}"
local pid_filename="${CLIENT_PID_FILENAME[$client_name]}"
echo "${client_dir}/${pid_filename}"
}
# Construct full path to a client's executable script.
# Parameters: $1 - client_name: Name of the client
# Returns: Full path to client's executable (e.g., /path/to/client/discord-bot.js)
get_client_script() {
local client_name="$1"
local client_dir="${CLIENT_DIRECTORY[$client_name]}"
local script_name="${CLIENT_SCRIPT_NAME[$client_name]}"
echo "${client_dir}/${script_name}"
}
# Check if a client process is currently running.
# Process:
# 1. Get client's PID file path
# 2. Check if PID file exists (no file = not supposed to be running)
# 3. Read PID from file
# 4. Check if process with that PID exists
# Parameters: $1 - client_name: Name of the client
# Returns: 0 if running, 1 if not running
# Side effects: Logs errors for unreadable PID files.
is_client_running() {
local client_name="$1"
local pid_file
if ! pid_file=$(get_client_pid_file "$client_name"); then
log_error "Failed to get PID file path for client $client_name"
return 1
fi
if [[ ! -f "$pid_file" ]]; then
return 1
fi
local pid
if ! pid=$(cat "$pid_file"); then
log_error "Cannot read PID file for client $client_name: $pid_file"
return 1
fi
if kill -0 "$pid" 2>/dev/null; then
return 0
else
return 1
fi
}
# Clean up stale PID files for all clients.
# Removes PID files where the referenced process no longer exists.
# Called during startup to clean up after crashes or improper shutdowns.
cleanup_stale_pid_files() {
log_debug "Cleaning up stale PID files..."
for client_name in "${!CLIENT_ENABLED[@]}"; do
[[ "${CLIENT_ENABLED[$client_name]}" == "true" ]] || continue
local pid_file
if ! pid_file=$(get_client_pid_file "$client_name"); then
continue
fi
if [[ -f "$pid_file" ]]; then
local pid
if pid=$(cat "$pid_file") && ! kill -0 "$pid" 2>/dev/null; then
log_info "Removing stale PID file for client $client_name"
rm -f "$pid_file"
fi
fi
done
}
# Validate that a client's configuration is correct and files exist.
# Checks:
# 1. Directory is specified and exists
# 2. Script file exists in directory
# 3. Script file is executable
# Parameters: $1 - client_name: Name of the client
# Returns: 0 if valid, 1 if invalid
# Side effects: Logs specific validation errors.
validate_client_config() {
local client_name="$1"
local client_dir="${CLIENT_DIRECTORY[$client_name]}"
if [[ -z "$client_dir" ]]; then
log_error "Client $client_name: No directory specified"
return 1
fi
if [[ ! -d "$client_dir" ]]; then
log_error "Client $client_name: Directory does not exist: $client_dir"
return 1
fi
local client_script
if ! client_script=$(get_client_script "$client_name"); then
log_error "Client $client_name: Failed to get script path"
return 1
fi
if [[ ! -f "$client_script" ]]; then
log_error "Client $client_name: Script not found: $client_script"
return 1
fi
if [[ ! -x "$client_script" ]]; then
log_error "Client $client_name: Script not executable: $client_script"
return 1
fi
return 0
}
# Start a client process in its configured directory.
# Process:
# 1. Log startup message
# 2. Validate client configuration
# 3. Change to client directory
# 4. Execute client script in background
# 5. Wait for stagger delay
# 6. Log completion
# Parameters: $1 - client_name: Name of the client
# Returns: 0 on success, 1 on failure
# Note: Client is responsible for writing its own PID file.
start_client_instance() {
local client_name="$1"
local client_dir="${CLIENT_DIRECTORY[$client_name]}"
local script_name="${CLIENT_SCRIPT_NAME[$client_name]}"
local start_stagger="${CLIENT_START_STAGGER[$client_name]}"
log_info "Starting client: $client_name in $client_dir"
if ! validate_client_config "$client_name"; then
return 1
fi
(
cd "$client_dir" || exit 1
"./$script_name" &
)
sleep "$start_stagger"
log_info "Client $client_name startup initiated"
}
# Gracefully stop a running client process.
# Process:
# 1. Get client's PID file
# 2. Read PID from file
# 3. Send TERM signal for graceful shutdown
# 4. Wait up to 10 seconds for process to exit
# 5. If still running, send KILL signal
# 6. Log all actions
# Parameters: $1 - client_name: Name of the client
# Side effects: Removes process but leaves PID file (client should clean up).
stop_client_instance() {
local client_name="$1"
local pid_file
if ! pid_file=$(get_client_pid_file "$client_name"); then
log_error "Failed to get PID file path for client $client_name"
return 1
fi
if [[ -f "$pid_file" ]]; then
local pid
if ! pid=$(cat "$pid_file"); then
log_error "Cannot read PID file for client $client_name: $pid_file"
return 1
fi
log_info "Stopping client: $client_name (PID: $pid)"
if kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid"
local timeout=10
while kill -0 "$pid" 2>/dev/null && ((timeout > 0)); do
sleep 1
((timeout--))
done
if kill -0 "$pid" 2>/dev/null; then
log_warn "Force killing client: $client_name"
kill -KILL "$pid" 2>/dev/null || true
fi
fi
fi
}
# Return list of client names that have enabled=true in configuration.
# Used to filter which clients should be managed by MCP.
# Returns: Space-separated list of enabled client names
get_enabled_clients() {
for client_name in "${!CLIENT_ENABLED[@]}"; do
if [[ "${CLIENT_ENABLED[$client_name]}" == "true" ]]; then
echo "$client_name"
fi
done
}
# Main client management function - checks all enabled clients.
# Process for each enabled client:
# 1. Check if client has a PID file (should be running)
# 2. If PID file exists, verify process is actually running
# 3. If process crashed (PID file exists but no process), restart it
# 4. If no PID file exists, client is intentionally stopped - ignore
# Called every MCP_INTERVAL seconds from main loop.
manage_client_instances() {
local enabled_clients
if ! enabled_clients=$(get_enabled_clients); then
log_error "Failed to get enabled clients list"
return 1
fi
while IFS= read -r client_name; do
[[ -z "$client_name" ]] && continue
local pid_file
if ! pid_file=$(get_client_pid_file "$client_name"); then
log_error "Failed to get PID file for client $client_name"
continue
fi
if [[ -f "$pid_file" ]]; then
if ! is_client_running "$client_name"; then
log_info "Client $client_name crashed, restarting..."
sleep "$MCP_RESTART_DELAY"
start_client_instance "$client_name"
fi
fi
done <<< "$enabled_clients"
}
#=============================================================================
# GIT UPDATE FUNCTIONS
#=============================================================================
# Periodically check all client repositories for git updates.
# Process:
# 1. Check if git updates are enabled
# 2. Check if enough time has passed since last git check
# 3. If not time yet, return early
# 4. Record current timestamp for next check
# 5. Check each enabled client for updates
# 6. If any updates found, trigger perform_selective_update()
# Uses .last_git_check file to track timing.
# Called every MCP_INTERVAL seconds from main loop.
check_for_updates() {
# Skip if git updates are disabled
if [[ "$GIT_UPDATES_ENABLED" != "true" ]]; then
log_debug "Git updates disabled, skipping check"
return 0
fi
local current_time
if ! current_time=$(date +%s); then
log_error "Failed to get current timestamp"
return 1
fi
local last_check_file="${SCRIPT_DIR}/.last_git_check"
if [[ -f "$last_check_file" ]]; then
local last_check
if ! last_check=$(cat "$last_check_file"); then
log_error "Cannot read last git check file: $last_check_file"
return 1
fi
local time_diff=$((current_time - last_check))
if ((time_diff < MCP_GIT_CHECK_INTERVAL)); then
return 0
fi
fi
if ! update_timestamp_file "$last_check_file" "$current_time"; then
return 1
fi
log_info "Checking for git updates..."
local clients_needing_updates=()
local enabled_clients
if ! enabled_clients=$(get_enabled_clients); then
log_error "Failed to get enabled clients for git update check"
return 1
fi
while IFS= read -r client_name; do
[[ -z "$client_name" ]] && continue
if check_client_for_updates "$client_name"; then
clients_needing_updates+=("$client_name")
fi
done <<< "$enabled_clients"
if [[ ${#clients_needing_updates[@]} -gt 0 ]]; then
log_info "Updates detected for clients: ${clients_needing_updates[*]}"
perform_selective_update "${clients_needing_updates[@]}"
else
log_debug "No git updates needed"
fi
}
# Check a single client repository for available updates.
# Process:
# 1. Verify client directory contains .git repository
# 2. Change to client directory
# 3. Fetch latest changes from origin
# 4. Compare local HEAD with remote branch HEAD
# 5. Return success if updates are available
# Parameters: $1 - client_name: Name of the client
# Returns: 0 if updates available, 1 if no updates or error
# Side effects: Logs update availability and errors.
check_client_for_updates() {
local client_name="$1"
local client_dir="${CLIENT_DIRECTORY[$client_name]}"
local git_branch="${CLIENT_GIT_BRANCH[$client_name]}"
if [[ ! -d "$client_dir/.git" ]]; then
log_debug "Client $client_name: Not a git repository"
return 1
fi
if ! cd "$client_dir"; then
log_error "Cannot access client directory for git check: $client_dir"
return 1
fi
if ! git fetch origin 2>/dev/null; then
log_error "Git fetch failed for client $client_name"
return 1
fi
local local_commit remote_commit
if ! local_commit=$(git rev-parse HEAD 2>/dev/null); then
log_error "Failed to get local commit for client $client_name"
return 1
fi
if ! remote_commit=$(git rev-parse "origin/$git_branch" 2>/dev/null); then
log_error "Failed to get remote commit for client $client_name (branch: $git_branch)"
return 1
fi
if [[ -n "$local_commit" && -n "$remote_commit" && "$local_commit" != "$remote_commit" ]]; then
log_info "Updates available for client $client_name (branch: $git_branch)"
return 0
fi
return 1
}
# Perform selective updates on only the clients that need them.
# This is more efficient than the old approach of stopping all clients.
# Process:
# 1. For each client needing updates:
# a. Stop the client if it's running
# b. Update the client's repository
# c. Restart the client if it was running before
# 2. Track success/failure for each client
# 3. Log overall results
# Parameters: List of client names that need updates
perform_selective_update() {
local clients_to_update=("$@")
local update_success=true
log_info "Performing selective update on ${#clients_to_update[@]} client(s)"
for client_name in "${clients_to_update[@]}"; do
local client_dir="${CLIENT_DIRECTORY[$client_name]}"
local git_branch="${CLIENT_GIT_BRANCH[$client_name]}"
local was_running=false
# Check if client was running before update
local pid_file
if pid_file=$(get_client_pid_file "$client_name") && [[ -f "$pid_file" ]]; then
was_running=true
log_info "Stopping client $client_name for update"
stop_client_instance "$client_name"
sleep 2 # Brief pause for clean shutdown
fi
# Perform git update
log_info "Updating client $client_name (branch: $git_branch)"
if ! cd "$client_dir"; then
log_error "Cannot access client directory for update: $client_dir"
update_success=false
continue
fi
if ! git pull origin "$git_branch"; then
log_error "Git pull failed for client $client_name"
update_success=false
# Still try to restart if it was running
fi
# Restart client if it was running before
if [[ "$was_running" == true ]]; then
log_info "Restarting client $client_name after update"
start_client_instance "$client_name"
fi
done
if [[ "$update_success" == true ]]; then
log_info "All selective updates completed successfully"
else
log_error "Some selective updates failed - check logs for details"
return 1
fi
}
#=============================================================================
# MAIN CONTROL LOOP
#=============================================================================
# Primary entry point and control loop for MCP.
# Initialization:
# 1. Log startup banner with configuration summary
# 2. Enforce single instance (check_mcp_instance)
# 3. Set up signal handlers
# 4. Clean up any stale PID files
# 5. Validate all enabled client configurations
# 6. Exit if any validation failures
#
# Main Loop (runs indefinitely):
# 1. Check and restart any crashed clients (manage_client_instances)
# 2. Check for and apply git updates (check_for_updates)
# 3. Monitor configuration file changes (check_config_changes)
# 4. Every 20 loops, log "all systems operational" heartbeat
# 5. Sleep for MCP_INTERVAL seconds
# 6. Repeat
#
# The loop continues until:
# - Signal received (TERM/INT/QUIT) triggers cleanup_mcp()
# - Fatal error occurs (script exits due to set -e)
# - Manual termination
main() {
log_info "=== MCP (Master Control Process) Starting ==="
log_info "Configuration file: $CONFIG_FILE"
log_info "MCP interval: ${MCP_INTERVAL}s"
log_info "Git check interval: ${MCP_GIT_CHECK_INTERVAL}s"
log_info "Default script: $BOT_DEFAULT_SCRIPT_NAME"
log_info "Default PID file: $BOT_DEFAULT_PID_FILENAME"
log_info "Log level: $MCP_LOG_LEVEL"
log_info "Git updates: $([ "$GIT_UPDATES_ENABLED" = "true" ] && echo "enabled" || echo "disabled")"
check_mcp_instance
setup_signal_handlers
cleanup_stale_pid_files
log_info "Validating client configurations..."
local validation_failed=false
local enabled_clients
if ! enabled_clients=$(get_enabled_clients); then
log_error "Failed to get enabled clients for validation"
exit 1
fi
if [[ -z "$enabled_clients" ]]; then
log_warn "No enabled clients found in configuration"
fi
while IFS= read -r client_name; do
[[ -z "$client_name" ]] && continue
if validate_client_config "$client_name"; then
log_info "Client $client_name validated: ${CLIENT_DIRECTORY[$client_name]}"
else
validation_failed=true
fi
done <<< "$enabled_clients"
if [[ "$validation_failed" == true ]]; then
log_error "Some client configurations failed validation"
exit 1
fi
# Initialize config monitoring
check_config_changes
log_info "MCP initialization complete, entering main loop"
local loop_count=0
while true; do
((loop_count++))
manage_client_instances
check_for_updates
check_config_changes
if ((loop_count % 20 == 0)); then
log_info "MCP loop $loop_count - all systems operational"
fi
sleep "$MCP_INTERVAL"
done
}
# Script Entry Point
# Ensures main() only runs when script is executed directly
# (not when sourced by another script).
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi