380 lines
14 KiB
Bash
Executable File
380 lines
14 KiB
Bash
Executable File
#!/bin/bash
|
|
set -e
|
|
|
|
# Source diagnostic logger if available
|
|
if [ -f "/usr/local/bin/diagnostic-logger.sh" ]; then
|
|
. /usr/local/bin/diagnostic-logger.sh
|
|
fi
|
|
|
|
# Helper function to log messages
|
|
log() {
|
|
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
|
echo "[${timestamp}] $*" >&2
|
|
}
|
|
|
|
# Sanitize environment variables by removing invisible Unicode characters
|
|
# This fixes the Portainer deployment issue where U+200E and similar characters
|
|
# cause "unexpected character" errors in variable names
|
|
sanitize_env_vars() {
|
|
log "Sanitizing environment variables..."
|
|
|
|
# Create a secure temporary file
|
|
local temp_env
|
|
temp_env=$(mktemp /tmp/sanitized_env.XXXXXX)
|
|
|
|
# Export current environment to a file, then clean it
|
|
export -p > "$temp_env"
|
|
|
|
# Remove common invisible Unicode characters in a single sed command:
|
|
# - U+200E (Left-to-Right Mark) - E2 80 8E
|
|
# - U+200F (Right-to-Left Mark) - E2 80 8F
|
|
# - U+200B (Zero Width Space) - E2 80 8B
|
|
# - U+FEFF (Zero Width No-Break Space / BOM) - EF BB BF
|
|
# - U+202A-202E (Directional formatting characters)
|
|
sed -i \
|
|
-e 's/\xE2\x80\x8E//g' \
|
|
-e 's/\xE2\x80\x8F//g' \
|
|
-e 's/\xE2\x80\x8B//g' \
|
|
-e 's/\xEF\xBB\xBF//g' \
|
|
-e 's/\xE2\x80\xAA//g' \
|
|
-e 's/\xE2\x80\xAB//g' \
|
|
-e 's/\xE2\x80\xAC//g' \
|
|
-e 's/\xE2\x80\xAD//g' \
|
|
-e 's/\xE2\x80\xAE//g' \
|
|
"$temp_env" 2>/dev/null
|
|
|
|
# Source the sanitized environment
|
|
# If sourcing fails, log a warning but continue (environment may be partially set)
|
|
if ! source "$temp_env" 2>/dev/null; then
|
|
log "WARNING: Failed to source sanitized environment. Some variables may not be set correctly."
|
|
fi
|
|
|
|
# Clean up temporary file
|
|
rm -f "$temp_env"
|
|
|
|
log "Environment variables sanitized successfully"
|
|
}
|
|
|
|
# Sanitize environment variables on startup
|
|
log "=== STARTUP INITIALIZATION ==="
|
|
log "Container ID: $(hostname 2>/dev/null || 'unknown')"
|
|
log "Timestamp: $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
|
|
|
# Initialize diagnostic logging only if file exists and we're in debug mode
|
|
if [ -f "/usr/local/bin/diagnostic-logger.sh" ] && [ "${DEBUG_LOGGING:-false}" = "true" ]; then
|
|
. /usr/local/bin/diagnostic-logger.sh
|
|
log_system_info
|
|
validate_environment
|
|
rotate_logs
|
|
fi
|
|
|
|
sanitize_env_vars
|
|
|
|
log "=== ENVIRONMENT SANITIZATION COMPLETE ==="
|
|
|
|
# Repository configuration
|
|
REPO_URL="${REPO_URL:-}"
|
|
REPO_DIR="/home/web/data"
|
|
REPO_BRANCH="${REPO_BRANCH:-main}"
|
|
GITHUB_USERNAME="${GITHUB_USERNAME:-}"
|
|
GITHUB_PAT="${GITHUB_PAT:-}"
|
|
|
|
# Helper function to get authenticated repository URL
|
|
get_auth_url() {
|
|
local url="$1"
|
|
if [ -n "$GITHUB_USERNAME" ] && [ -n "$GITHUB_PAT" ]; then
|
|
# Extract the repository path from the URL
|
|
local repo_path=$(echo "$url" | sed 's|https://github.com/||')
|
|
echo "https://${GITHUB_USERNAME}:${GITHUB_PAT}@github.com/${repo_path}"
|
|
else
|
|
echo "$url"
|
|
fi
|
|
}
|
|
|
|
log "Initializing Shopify AI App Builder..."
|
|
|
|
# Log repository configuration details
|
|
log "Repository Configuration:"
|
|
log " URL: ${REPO_URL:-'NOT SET'}"
|
|
log " Branch: ${REPO_BRANCH:-'NOT SET'}"
|
|
log " Directory: ${REPO_DIR}"
|
|
log " GitHub Username: ${GITHUB_USERNAME:-'NOT SET'}"
|
|
log " GitHub PAT: ${GITHUB_PAT:+SET (hidden)}${GITHUB_PAT:-NOT SET}"
|
|
|
|
# Only clone/pull if REPO_URL is set
|
|
if [ -n "$REPO_URL" ]; then
|
|
log "Repository URL: $REPO_URL"
|
|
log "Repository directory: $REPO_DIR"
|
|
log "Default branch: $REPO_BRANCH"
|
|
|
|
# Check if authentication credentials are available
|
|
if [ -n "$GITHUB_USERNAME" ] && [ -n "$GITHUB_PAT" ]; then
|
|
log "GitHub authentication credentials found for user: $GITHUB_USERNAME"
|
|
else
|
|
log "WARNING: No GitHub authentication credentials found. Private repository access may fail."
|
|
fi
|
|
|
|
# Check if git is available
|
|
if ! command -v git &> /dev/null; then
|
|
log "ERROR: git is not available in the container"
|
|
exit 1
|
|
fi
|
|
|
|
# Check if the repository directory is empty or doesn't have .git
|
|
if [ ! -d "$REPO_DIR/.git" ]; then
|
|
# Directory doesn't exist or .git is missing - need to clone
|
|
if [ -d "$REPO_DIR" ] && [ "$(ls -A "$REPO_DIR")" ]; then
|
|
# Directory exists but is not a git repo - back it up
|
|
log "WARNING: $REPO_DIR exists but is not a git repository. Backing up to ${REPO_DIR}.backup"
|
|
mv "$REPO_DIR" "${REPO_DIR}.backup"
|
|
fi
|
|
|
|
log "Repository not found. Cloning $REPO_URL into $REPO_DIR..."
|
|
auth_url=$(get_auth_url "$REPO_URL")
|
|
git clone "$auth_url" "$REPO_DIR"
|
|
cd "$REPO_DIR"
|
|
log "Successfully cloned repository"
|
|
else
|
|
# Repository exists, pull latest changes
|
|
cd "$REPO_DIR"
|
|
log "Repository found at $REPO_DIR. Pulling latest changes from $REPO_BRANCH..."
|
|
|
|
# Update remote URL to use authentication if credentials are available
|
|
if [ -n "$GITHUB_USERNAME" ] && [ -n "$GITHUB_PAT" ]; then
|
|
auth_url=$(get_auth_url "$REPO_URL")
|
|
log "Updating remote URL with authentication credentials"
|
|
git remote set-url origin "$auth_url"
|
|
fi
|
|
|
|
# Check if we're on a detached HEAD or have uncommitted changes
|
|
if git diff --quiet && git diff --cached --quiet; then
|
|
git fetch origin
|
|
# Use plain git pull per policy (do not force origin/branch explicitly here)
|
|
git pull || {
|
|
log "WARNING: Failed to pull from $REPO_BRANCH, attempting to pull from any available branch"
|
|
git pull || log "WARNING: Pull operation failed, continuing anyway"
|
|
}
|
|
log "Successfully pulled latest changes"
|
|
else
|
|
log "WARNING: Repository has uncommitted changes. Skipping pull to avoid conflicts."
|
|
log "Run 'git status' to see changes, then 'git pull' manually if desired"
|
|
fi
|
|
fi
|
|
log "Repository is ready at $REPO_DIR"
|
|
else
|
|
log "No REPO_URL set - starting with empty workspace"
|
|
mkdir -p "$REPO_DIR"
|
|
fi
|
|
|
|
log "Starting Shopify AI App Builder service..."
|
|
|
|
# Use /opt/webchat directly as it contains the actual server.js with node_modules
|
|
# /opt/webchat_v2 is just a wrapper and causes module loading failures
|
|
CHAT_APP_DIR="${CHAT_APP_DIR:-/opt/webchat}"
|
|
CHAT_APP_FALLBACK="/opt/webchat_v2"
|
|
CHAT_PORT="${CHAT_PORT:-4500}"
|
|
CHAT_HOST="${CHAT_HOST:-0.0.0.0}"
|
|
ACCESS_PASSWORD="${ACCESS_PASSWORD:-}"
|
|
|
|
# Persist opencode installation & user config
|
|
# Move/Copy /root/.opencode to $REPO_DIR/.opencode if not already present
|
|
# Then symlink /root/.opencode -> $REPO_DIR/.opencode so opencode state (connections, providers)
|
|
# are persisted on container restarts while keeping the install accessible for runtime.
|
|
PERSISTED_OPENCODE_DIR="$REPO_DIR/.opencode"
|
|
OPENCODE_INSTALL_DIR="/root/.opencode"
|
|
if [ -d "$OPENCODE_INSTALL_DIR" ]; then
|
|
# If persisted dir does not exist, copy initial files so the app continues to work
|
|
if [ ! -d "$PERSISTED_OPENCODE_DIR" ] || [ -z "$(ls -A $PERSISTED_OPENCODE_DIR 2>/dev/null)" ]; then
|
|
log "Persisting opencode into $PERSISTED_OPENCODE_DIR"
|
|
mkdir -p "$PERSISTED_OPENCODE_DIR"
|
|
# Copy installed files to the persisted folder to preserve both the binary and state
|
|
cp -a "$OPENCODE_INSTALL_DIR/." "$PERSISTED_OPENCODE_DIR/" || true
|
|
chown -R root:root "$PERSISTED_OPENCODE_DIR" || true
|
|
fi
|
|
# Replace the install dir with a symlink to the persisted directory (only if not a symlink already)
|
|
if [ -e "$OPENCODE_INSTALL_DIR" ] && [ ! -L "$OPENCODE_INSTALL_DIR" ]; then
|
|
log "Symlinking $OPENCODE_INSTALL_DIR -> $PERSISTED_OPENCODE_DIR"
|
|
rm -rf "$OPENCODE_INSTALL_DIR"
|
|
ln -s "$PERSISTED_OPENCODE_DIR" "$OPENCODE_INSTALL_DIR"
|
|
elif [ -L "$OPENCODE_INSTALL_DIR" ]; then
|
|
log "$OPENCODE_INSTALL_DIR already symlinked; skipping"
|
|
fi
|
|
fi
|
|
|
|
# Only ensure opencode command exists - qwen and gemini commands should not be aliased to opencode
|
|
ensure_cli_wrappers() {
|
|
local bin_dir="$PERSISTED_OPENCODE_DIR/bin"
|
|
mkdir -p "$bin_dir"
|
|
# Only create symlink for opencode command itself
|
|
if [ ! -L "/usr/local/bin/opencode" ]; then
|
|
ln -sf "$bin_dir/opencode" "/usr/local/bin/opencode"
|
|
fi
|
|
}
|
|
ensure_cli_wrappers
|
|
|
|
# Ensure a root-level opencode.json exists so OpenCode can discover the configured
|
|
# Ollama/OpenAI-compatible model. This file lives in the persisted storage root
|
|
# (/home/web/data) and is overwritten on every container start.
|
|
ensure_root_opencode_config() {
|
|
local config_path="$REPO_DIR/opencode.json"
|
|
|
|
# Allow overrides while defaulting to the PluginCompass Ollama gateway + qwen3 model
|
|
export OPENCODE_OLLAMA_BASE_URL="${OPENCODE_OLLAMA_BASE_URL:-https://ollama.plugincompass.com}"
|
|
export OPENCODE_OLLAMA_MODEL="${OPENCODE_OLLAMA_MODEL:-qwen3:0.6b}"
|
|
|
|
# Prefer an explicit Ollama key, fall back to OPENCODE_API_KEY (existing env var)
|
|
export OPENCODE_OLLAMA_API_KEY="${OPENCODE_OLLAMA_API_KEY:-${OPENCODE_API_KEY:-}}"
|
|
|
|
mkdir -p "$(dirname "$config_path")"
|
|
|
|
log "Writing OpenCode config: ${config_path} (baseURL=${OPENCODE_OLLAMA_BASE_URL}, model=${OPENCODE_OLLAMA_MODEL})"
|
|
|
|
python3 - <<'PY' > "$config_path"
|
|
import json, os
|
|
|
|
base_url = (os.environ.get("OPENCODE_OLLAMA_BASE_URL") or "https://ollama.plugincompass.com").rstrip("/")
|
|
|
|
model_id = os.environ.get("OPENCODE_OLLAMA_MODEL") or "qwen3:0.6b"
|
|
api_key = (os.environ.get("OPENCODE_OLLAMA_API_KEY") or "").strip()
|
|
provider_name = os.environ.get("OPENCODE_OLLAMA_PROVIDER") or "ollama"
|
|
|
|
provider_cfg = {
|
|
"options": {
|
|
"baseURL": base_url,
|
|
},
|
|
"models": {
|
|
model_id: {
|
|
"id": model_id,
|
|
"name": model_id,
|
|
"tool_call": True,
|
|
"temperature": True,
|
|
}
|
|
},
|
|
}
|
|
|
|
if api_key:
|
|
provider_cfg["options"]["apiKey"] = api_key
|
|
|
|
cfg = {
|
|
"$schema": "https://opencode.ai/config.json",
|
|
"model": f"{provider_name}/{model_id}",
|
|
"small_model": f"{provider_name}/{model_id}",
|
|
"provider": {
|
|
provider_name: provider_cfg,
|
|
},
|
|
}
|
|
|
|
print(json.dumps(cfg, indent=2))
|
|
PY
|
|
|
|
chmod 600 "$config_path" 2>/dev/null || true
|
|
}
|
|
ensure_root_opencode_config
|
|
|
|
# Set up signal handlers to properly clean up background processes
|
|
cleanup() {
|
|
local signal="$1"
|
|
log "=== SIGNAL RECEIVED: ${signal} ==="
|
|
log "Initiating graceful shutdown..."
|
|
|
|
# Log final resource snapshot before shutdown
|
|
if type monitor_resources &>/dev/null; then
|
|
log "Final resource snapshot:"
|
|
monitor_resources
|
|
fi
|
|
|
|
if [ -n "$MONITOR_PID" ] && kill -0 "$MONITOR_PID" 2>/dev/null; then
|
|
log "Terminating monitor process (PID: $MONITOR_PID)"
|
|
kill "$MONITOR_PID" 2>/dev/null || true
|
|
wait "$MONITOR_PID" 2>/dev/null || true
|
|
log "Monitor process terminated"
|
|
fi
|
|
|
|
if [ -n "$CHAT_PID" ] && kill -0 "$CHAT_PID" 2>/dev/null; then
|
|
log "Terminating chat service (PID: $CHAT_PID) - giving it time for graceful shutdown"
|
|
kill "$CHAT_PID" 2>/dev/null || true
|
|
|
|
# Wait up to 25 seconds for graceful shutdown (Docker stop_grace_period is 30s)
|
|
for i in $(seq 1 25); do
|
|
if ! kill -0 "$CHAT_PID" 2>/dev/null; then
|
|
log "Chat service terminated gracefully (${i} seconds)"
|
|
if type diag_log &>/dev/null; then
|
|
diag_log "INFO" "Chat service shutdown complete"
|
|
fi
|
|
exit 0
|
|
fi
|
|
sleep 1
|
|
done
|
|
log "WARNING: Chat service did not terminate gracefully, forcing exit"
|
|
kill -9 "$CHAT_PID" 2>/dev/null || true
|
|
fi
|
|
|
|
log "=== SHUTDOWN COMPLETE ==="
|
|
exit 0
|
|
}
|
|
|
|
# Set up traps for common signals
|
|
trap cleanup SIGTERM SIGINT SIGQUIT SIGHUP
|
|
|
|
if [ -f "$CHAT_APP_DIR/server.js" ]; then
|
|
log "Launching chat service on ${CHAT_HOST}:${CHAT_PORT} from $CHAT_APP_DIR"
|
|
log "Environment: CHAT_PORT=${CHAT_PORT} CHAT_HOST=${CHAT_HOST} CHAT_DATA_ROOT=${REPO_DIR} CHAT_REPO_ROOT=${REPO_DIR}"
|
|
CHAT_PORT=$CHAT_PORT CHAT_HOST=$CHAT_HOST CHAT_DATA_ROOT=$REPO_DIR CHAT_REPO_ROOT=$REPO_DIR node "$CHAT_APP_DIR/server.js" 2>&1 &
|
|
CHAT_PID=$!
|
|
log "Chat service started with PID: $CHAT_PID"
|
|
elif [ -f "$CHAT_APP_FALLBACK/server.js" ]; then
|
|
log "Primary chat service not found at $CHAT_APP_DIR, trying fallback at $CHAT_APP_FALLBACK"
|
|
log "Launching chat service on ${CHAT_HOST}:${CHAT_PORT} from $CHAT_APP_FALLBACK"
|
|
log "Environment: CHAT_PORT=${CHAT_PORT} CHAT_HOST=${CHAT_HOST} CHAT_DATA_ROOT=${REPO_DIR} CHAT_REPO_ROOT=${REPO_DIR}"
|
|
CHAT_PORT=$CHAT_PORT CHAT_HOST=$CHAT_HOST CHAT_DATA_ROOT=$REPO_DIR CHAT_REPO_ROOT=$REPO_DIR node "$CHAT_APP_FALLBACK/server.js" 2>&1 &
|
|
CHAT_PID=$!
|
|
log "Chat service started with PID: $CHAT_PID"
|
|
else
|
|
log "ERROR: Chat service not found at $CHAT_APP_DIR or $CHAT_APP_FALLBACK; skipping startup"
|
|
exit 1
|
|
fi
|
|
|
|
# Log initial service status after startup
|
|
sleep 2
|
|
if type check_service_status &>/dev/null; then
|
|
check_service_status "chat" "$CHAT_PORT" "$CHAT_PID"
|
|
fi
|
|
|
|
# Monitor chat service health
|
|
if [ -n "$CHAT_PID" ]; then
|
|
(
|
|
# Initial check
|
|
sleep 5
|
|
if type check_service_status &>/dev/null; then
|
|
check_service_status "chat" "$CHAT_PORT" "$CHAT_PID"
|
|
fi
|
|
|
|
# Periodic monitoring
|
|
check_count=0
|
|
while kill -0 "$CHAT_PID" 2>/dev/null; do
|
|
sleep 30
|
|
check_count=$((check_count + 1))
|
|
|
|
# Every 2 minutes (4 checks), do resource monitoring
|
|
if [ $((check_count % 4)) -eq 0 ] && type monitor_resources &>/dev/null; then
|
|
monitor_resources
|
|
fi
|
|
|
|
# Every 5 minutes (10 checks), do service status check
|
|
if [ $((check_count % 10)) -eq 0 ] && type check_service_status &>/dev/null; then
|
|
check_service_status "chat" "$CHAT_PORT" "$CHAT_PID"
|
|
fi
|
|
done
|
|
log "ERROR: Chat service (PID: $CHAT_PID) has exited unexpectedly"
|
|
if type diag_log &>/dev/null; then
|
|
diag_log "ERROR" "Chat service exited unexpectedly after ${check_count} checks"
|
|
fi
|
|
) &
|
|
MONITOR_PID=$!
|
|
log "Health monitor started with PID: $MONITOR_PID"
|
|
fi
|
|
|
|
# Keep the container running
|
|
log "Container started successfully. Waiting for chat service..."
|
|
wait "$CHAT_PID" || true
|