#!/usr/bin/env bash set -euo pipefail # ═══════════════════════════════════════════════════════════════════════ # ByteLyst Investment Trading - Production Deployment Script # ═══════════════════════════════════════════════════════════════════════ # Usage: ./deploy-invttrdg.sh [--force] [--skip-health-check] [--no-cache] # (No arguments = interactive menu) # # Deployment Target: This VM is the single production environment # - No separate staging/development environments # - All deployments go directly to production on this VM # # What it does: # 1. Dirty check: uncommitted changes, unpushed commits # 2. Pull and rebase origin/main # 3. Check @bytelyst package publication # 4. Build and deploy Docker containers on this VM # 5. Verify endpoints: https://api.bytelyst.com/invttrdg, https://invttrdg.bytelyst.com # # Options: # --force Skip dirty checks and force deployment # --skip-health-check Skip endpoint health verification # --no-cache Build Docker images without cache # # Interactive Menu (no arguments): # 1 - Normal deployment (with cache, with health checks) # 2 - Force deployment (skip dirty checks, with cache) # 3 - Skip health checks (with cache) # 4 - No-cache build (force rebuild, with health checks) # 5 - Force + No-cache (skip checks, force rebuild) # 6 - Force + Skip health checks (skip both) # 7 - All options: Force + Skip health + No-cache # ═══════════════════════════════════════════════════════════════════════ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' log() { echo -e "${CYAN}[$(date +%H:%M:%S)]${NC} $*"; } ok() { echo -e "${GREEN}[$(date +%H:%M:%S)] ✓${NC} $*"; } warn() { echo -e "${YELLOW}[$(date +%H:%M:%S)] ⚠${NC} $*"; } fail() { echo -e "${RED}[$(date +%H:%M:%S)] ✗${NC} $*"; exit 1; } # ── Configuration ──────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="${SCRIPT_DIR}/../learning_ai_invt_trdg" cd "$SCRIPT_DIR" FORCE=false SKIP_HEALTH_CHECK=false NO_CACHE=false INTERACTIVE=true while [[ $# -gt 0 ]]; do case "$1" in --force) FORCE=true; INTERACTIVE=false; shift ;; --skip-health-check) SKIP_HEALTH_CHECK=true; INTERACTIVE=false; shift ;; --no-cache) NO_CACHE=true; INTERACTIVE=false; shift ;; *) fail "Unknown option: $1" ;; esac done # ── Interactive Menu ──────────────────────────────────────────────────── if [ "$INTERACTIVE" = true ]; then echo "" echo -e "${CYAN}╔═══════════════════════════════════════════════════════════════╗${NC}" echo -e "${CYAN}║${NC} ByteLyst Investment Trading - Deployment Options ${CYAN}║${NC}" echo -e "${CYAN}╚═══════════════════════════════════════════════════════════════╝${NC}" echo "" echo -e " ${GREEN}1${NC} - Normal deployment (with cache, with health checks)" echo -e " ${GREEN}2${NC} - Force deployment (skip dirty checks, with cache)" echo -e " ${GREEN}3${NC} - Skip health checks (with cache)" echo -e " ${GREEN}4${NC} - No-cache build (force rebuild, with health checks)" echo -e " ${GREEN}5${NC} - Force + No-cache (skip checks, force rebuild)" echo -e " ${GREEN}6${NC} - Force + Skip health checks (skip both)" echo -e " ${GREEN}7${NC} - All options: Force + Skip health + No-cache" echo "" read -r -p "Select option [1-7]: " choice case "$choice" in 1) # Normal deployment (defaults) ;; 2) FORCE=true ;; 3) SKIP_HEALTH_CHECK=true ;; 4) NO_CACHE=true ;; 5) FORCE=true NO_CACHE=true ;; 6) FORCE=true SKIP_HEALTH_CHECK=true ;; 7) FORCE=true SKIP_HEALTH_CHECK=true NO_CACHE=true ;; *) fail "Invalid option. Please run again and select 1-7." ;; esac echo "" log "Selected configuration:" [ "$FORCE" = true ] && echo " - Force deployment (skip dirty checks)" [ "$SKIP_HEALTH_CHECK" = true ] && echo " - Skip health checks" [ "$NO_CACHE" = true ] && echo " - No-cache build (force rebuild)" [ "$FORCE" = false ] && [ "$SKIP_HEALTH_CHECK" = false ] && [ "$NO_CACHE" = false ] && echo " - Normal deployment" echo "" read -r -p "Press Enter to continue or Ctrl+C to cancel..." echo "" fi # ── Prerequisites ──────────────────────────────────────────────────── if [ ! -d "$REPO_DIR" ]; then fail "Repo directory not found: $REPO_DIR" fi cd "$REPO_DIR" # ── Dirty Check ─────────────────────────────────────────────────────── if [ "$FORCE" = false ]; then log "Running dirty checks..." # Check for uncommitted changes if ! git diff-index --quiet HEAD --; then fail "Uncommitted changes detected. Commit or stash first, or use --force" fi # Check for untracked files if [ -n "$(git ls-files --others --exclude-standard)" ]; then fail "Untracked files detected. Commit or remove them, or use --force" fi # Check for unpushed commits LOCAL_COMMIT=$(git rev-parse @) REMOTE_COMMIT=$(git rev-parse '@{u}' 2>/dev/null || echo "") if [ -n "$REMOTE_COMMIT" ] && [ "$LOCAL_COMMIT" != "$REMOTE_COMMIT" ]; then fail "Unpushed commits detected. Push first or use --force" fi ok "Dirty checks passed" else warn "Skipping dirty checks (--force enabled)" fi # ── Pull and Rebase ─────────────────────────────────────────────────── log "Pulling latest changes from origin/main..." git fetch origin LOCAL_MAIN=$(git rev-parse main) REMOTE_MAIN=$(git rev-parse origin/main) if [ "$LOCAL_MAIN" != "$REMOTE_MAIN" ]; then log "Local main is behind origin/main, rebasing..." git rebase origin/main || { fail "Rebase failed. Resolve conflicts and run: git rebase --continue" } ok "Rebase completed successfully" else ok "Already up to date with origin/main" fi # ── Run Smoke Tests ───────────────────────────────────────────────────── if [ "$SKIP_HEALTH_CHECK" = false ]; then log "Running smoke tests before deployment..." if [ -f "scripts/smoke-release.sh" ]; then chmod +x scripts/smoke-release.sh ./scripts/smoke-release.sh || { fail "Smoke tests failed. Fix issues before deploying or use --skip-health-check" } ok "Smoke tests passed" else warn "Smoke test script not found, skipping pre-deployment tests" fi fi # ── Check Package Publication ────────────────────────────────────────── log "Checking @bytelyst package publication..." # Check if required packages are published to Gitea registry GITEA_REGISTRY="http://localhost:3300/api/packages/bytelyst/npm/" REQUIRED_PACKAGES=( "@bytelyst/config" "@bytelyst/cosmos" "@bytelyst/auth" "@bytelyst/llm" "@bytelyst/telemetry-client" "@bytelyst/devops" "@bytelyst/errors" "@bytelyst/logger" ) MISSING_PACKAGES=() for package in "${REQUIRED_PACKAGES[@]}"; do # Check if package is published (Gitea API returns 200 if package exists) if ! curl -sf "${GITEA_REGISTRY}${package}" > /dev/null 2>&1; then MISSING_PACKAGES+=("$package") fi done if [ ${#MISSING_PACKAGES[@]} -gt 0 ]; then fail "Required @bytelyst packages not published to Gitea registry: ${MISSING_PACKAGES[*]} Please publish the packages first by running: python3 /opt/bytelyst/republish_packages.py This will publish all @bytelyst/* packages to https://gitea.bytelyst.com/" fi ok "All required @bytelyst packages are published" # ── Build and Deploy ────────────────────────────────────────────────── log "Building and deploying Docker containers..." # Check if docker-compose files exist if [ ! -f "docker-compose.yml" ]; then fail "docker-compose.yml not found in $REPO_DIR" fi # Build and start services log "Building Docker images..." if [ "$NO_CACHE" = true ]; then warn "Building without cache (--no-cache enabled)" else log "Building with cache (use --no-cache to force rebuild)" fi # Resolve Gitea npm token for BuildKit secret # Unset any existing environment variable to ensure we use file values unset GITEA_NPM_TOKEN if [ -f "/opt/bytelyst/.gitea_token" ]; then GITEA_NPM_TOKEN=$(cat /opt/bytelyst/.gitea_token) export GITEA_NPM_TOKEN elif [ -f "$HOME/.gitea_npm_token" ]; then GITEA_NPM_TOKEN=$(cat "$HOME/.gitea_npm_token") export GITEA_NPM_TOKEN else fail "GITEA_NPM_TOKEN not set and no token file found" fi # Collect build metadata (consumed by @bytelyst/devops) # Declare separately so set -e + $(…) don't mask non-zero exit codes (SC2155). BYTELYST_COMMIT_SHA= BYTELYST_COMMIT_SHA_FULL= BYTELYST_BRANCH= BYTELYST_BUILT_AT= BYTELYST_COMMIT_AUTHOR= BYTELYST_COMMIT_MESSAGE= # Change to repo directory to collect correct git metadata cd "$REPO_DIR" log "Collecting git metadata from: $(pwd)" BYTELYST_COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) BYTELYST_COMMIT_SHA_FULL=$(git rev-parse HEAD 2>/dev/null || echo unknown) BYTELYST_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown) BYTELYST_BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) BYTELYST_COMMIT_AUTHOR=$(git log -1 --pretty=format:'%an' 2>/dev/null || echo unknown) BYTELYST_COMMIT_MESSAGE=$(git log -1 --pretty=format:'%s' 2>/dev/null | head -c 200 || echo unknown) log "Collected commit SHA: $BYTELYST_COMMIT_SHA" log "Collected commit message: $BYTELYST_COMMIT_MESSAGE" cd "$SCRIPT_DIR" build_image() { local dockerfile="$1" local tag="$2" local cache_flag="" if [ "$NO_CACHE" = true ]; then cache_flag="--no-cache" fi # Build from repo directory to ensure correct source code is used log " Building from directory: $REPO_DIR" log " Commit SHA being passed: ${BYTELYST_COMMIT_SHA}" (cd "$REPO_DIR" && log " Current git HEAD: $(git rev-parse --short HEAD)" && docker build --network host $cache_flag \ --build-arg "GITEA_NPM_TOKEN=${GITEA_NPM_TOKEN}" \ --build-arg "BYTELYST_COMMIT_SHA=${BYTELYST_COMMIT_SHA}" \ --build-arg "BYTELYST_COMMIT_SHA_FULL=${BYTELYST_COMMIT_SHA_FULL}" \ --build-arg "BYTELYST_BRANCH=${BYTELYST_BRANCH}" \ --build-arg "BYTELYST_BUILT_AT=${BYTELYST_BUILT_AT}" \ --build-arg "BYTELYST_COMMIT_AUTHOR=${BYTELYST_COMMIT_AUTHOR}" \ --build-arg "BYTELYST_COMMIT_MESSAGE=${BYTELYST_COMMIT_MESSAGE}" \ --build-arg "BYTELYST_DOCKER_IMAGE=${tag}" \ -f "$dockerfile" -t "$tag" .) || fail "Docker build failed for $dockerfile" } build_image backend/Dockerfile invttrdg-backend:latest || fail "Backend build failed" build_image web/Dockerfile invttrdg-web:latest || fail "Web build failed" log "Starting services..." # Stop and remove existing containers to ensure fresh deployment cd "$REPO_DIR" docker compose down || true # Start services with force-recreate to ensure new images are used docker compose up -d --force-recreate || fail "Docker compose up failed" cd "$SCRIPT_DIR" ok "Deployment completed" # ── Health Check ────────────────────────────────────────────────────── if [ "$SKIP_HEALTH_CHECK" = true ]; then warn "Skipping health checks (--skip-health-check enabled)" exit 0 fi log "Waiting for services to be healthy..." sleep 10 # Check backend health BACKEND_HEALTH=false for _ in {1..30}; do if curl -sf http://localhost:4025/health/live > /dev/null 2>&1; then BACKEND_HEALTH=true break fi echo -n "." sleep 2 done echo "" if [ "$BACKEND_HEALTH" = true ]; then ok "Backend health check passed (http://localhost:4025)" else fail "Backend health check failed" fi # Check web health WEB_HEALTH=false for _ in {1..30}; do if curl -sf http://localhost:3085 > /dev/null 2>&1; then WEB_HEALTH=true break fi echo -n "." sleep 2 done echo "" if [ "$WEB_HEALTH" = true ]; then ok "Web health check passed (http://localhost:3085)" else warn "Web health check failed (may be starting up)" fi # ── Endpoint Verification ───────────────────────────────────────────── log "Verifying production endpoints..." API_ENDPOINT="https://api.bytelyst.com/invttrdg" WEB_ENDPOINT="https://invttrdg.bytelyst.com" # Check API endpoint if curl -sf "$API_ENDPOINT/health/live" > /dev/null 2>&1; then ok "API endpoint accessible: $API_ENDPOINT" else warn "API endpoint not accessible: $API_ENDPOINT (may need DNS propagation)" fi # Check web endpoint if curl -sf "$WEB_ENDPOINT" > /dev/null 2>&1; then ok "Web endpoint accessible: $WEB_ENDPOINT" else warn "Web endpoint not accessible: $WEB_ENDPOINT (may need DNS propagation)" fi # ── API Smoke Tests (Post-Deployment) ─────────────────────────────────── log "Running post-deployment API smoke tests..." # Test backend health endpoint BACKEND_URL="http://localhost:4025" if curl -sf "$BACKEND_URL/health/live" > /dev/null 2>&1; then ok "Backend health endpoint responding" # Try to get health details HEALTH_RESPONSE=$(curl -s "$BACKEND_URL/health/live" 2>/dev/null || echo "{}") log "Health response: $HEALTH_RESPONSE" else fail "Backend health endpoint not responding" fi # Test web is serving content WEB_URL="http://localhost:3085" if curl -sf "$WEB_URL" > /dev/null 2>&1; then ok "Web frontend is serving content" # Check if it's actually HTML (nginx serving the SPA) CONTENT_TYPE=$(curl -sI "$WEB_URL" | grep -i content-type || echo "") if echo "$CONTENT_TYPE" | grep -qi "text/html"; then ok "Web frontend is serving HTML content" else warn "Web frontend content type unexpected: $CONTENT_TYPE" fi else fail "Web frontend not responding" fi log "══════════════════════════════════════════════════════════════════════" ok "Deployment completed successfully!" log "Backend: http://localhost:4025 → $API_ENDPOINT" log "Web: http://localhost:3085 → $WEB_ENDPOINT" log "══════════════════════════════════════════════════════════════════════" # ── Verify Deployed Commit Hash ───────────────────────────────────────────── log "Verifying deployed commit hash..." BACKEND_URL="http://localhost:4025" DEVOPS_RESPONSE=$(curl -s "$BACKEND_URL/api/devops/version" 2>/dev/null || echo "{}") if [ "$DEVOPS_RESPONSE" != "{}" ] && [ -n "$DEVOPS_RESPONSE" ]; then if command -v jq &> /dev/null; then DEPLOYED_COMMIT_SHA=$(echo "$DEVOPS_RESPONSE" | jq -r '.commitSha // empty') DEPLOYED_COMMIT_FULL=$(echo "$DEVOPS_RESPONSE" | jq -r '.commitShaFull // empty') DEPLOYED_MESSAGE=$(echo "$DEVOPS_RESPONSE" | jq -r '.commitMessage // empty') if [ -n "$DEPLOYED_COMMIT_SHA" ]; then ok "Deployed commit: $DEPLOYED_COMMIT_SHA" log "Full SHA: $DEPLOYED_COMMIT_FULL" log "Message: $DEPLOYED_MESSAGE" # Compare with expected latest commit cd "$REPO_DIR" LATEST_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") if [ "$DEPLOYED_COMMIT_SHA" = "$LATEST_COMMIT" ]; then ok "✓ Deployed commit matches latest commit ($LATEST_COMMIT)" else warn "✗ Deployed commit ($DEPLOYED_COMMIT_SHA) differs from latest commit ($LATEST_COMMIT)" warn " This indicates the deployment may not have used the latest code" warn " Consider running: ./deploy-invttrdg.sh --force --no-cache" fi else warn "Could not extract commit SHA from devops endpoint" fi else warn "jq not installed, showing raw devops response:" echo "$DEVOPS_RESPONSE" fi else warn "Could not retrieve deployment info from devops endpoint" fi log "══════════════════════════════════════════════════════════════════════"