feat: Claude Code Monitor — lanes, pipelines and a merged workspace

Internal SmartGift build of a Claude Code monitoring dashboard.

Lanes: a durable unit of parallel agent work, one per working directory,
tracked across session restarts. Managed lanes are git worktrees the
dashboard provisions and can reset or remove behind a three-check destroy
guard and a counted preflight; adopted lanes are directories you already
own and are never destroyable.

Pipelines: a lane moves through pipeline stages. A stage the agent declares
with evidence renders green; a stage inferred from the tool-event stream
renders dashed amber and never counts as done. Detection is forward-only
within a 30-minute window, and never writes the declared stage.

Workspace: one page at /run with a lane grid, the selected lane's pipeline,
and a full Claude console behind a disclosure.
This commit is contained in:
2026-07-29 17:07:45 +07:00
commit 57dc91585d
783 changed files with 221743 additions and 0 deletions
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# blue-green-switch.sh Switch traffic between blue/green deployment slots
#
# Usage:
# ./blue-green-switch.sh --env production --target green
# ./blue-green-switch.sh --env production --target blue --skip-health
# ./blue-green-switch.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="agent-monitor"
readonly APP_PORT=4820
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
fatal() { err "$@"; exit 1; }
# ── Defaults ────────────────────────────────────────────────────────────────
ENVIRONMENT=""
TARGET=""
NAMESPACE=""
SERVICE_NAME="${APP_NAME}"
SKIP_HEALTH_CHECK=false
DRY_RUN=false
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}Usage:${NC}
$(basename "$0") --env <environment> --target <blue|green> [options]
${BOLD}Required:${NC}
--env, -e Environment: dev, staging, production
--target, -t Target slot: blue, green
${BOLD}Options:${NC}
--namespace, -n Kubernetes namespace (default: agent-monitor-<env>)
--service Service name (default: ${APP_NAME})
--skip-health Skip health check on target before switching
--dry-run Show what would change without applying
--help, -h Show this help message
${BOLD}Examples:${NC}
$(basename "$0") --env production --target green
$(basename "$0") --env production --target blue # instant rollback
EOF
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e) ENVIRONMENT="$2"; shift 2 ;;
--target|-t) TARGET="$2"; shift 2 ;;
--namespace|-n) NAMESPACE="$2"; shift 2 ;;
--service) SERVICE_NAME="$2"; shift 2 ;;
--skip-health) SKIP_HEALTH_CHECK=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--help|-h) usage ;;
*) fatal "Unknown option: $1" ;;
esac
done
[[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env"
[[ -z "$TARGET" ]] && fatal "Missing required argument: --target"
[[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}"
case "$TARGET" in
blue|green) ;;
*) fatal "Invalid target: $TARGET. Must be 'blue' or 'green'." ;;
esac
}
# ── Detect current active slot ──────────────────────────────────────────────
detect_current_slot() {
local current
current=$(kubectl get svc "${SERVICE_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.spec.selector.slot}' 2>/dev/null || echo "")
if [[ -z "$current" ]]; then
# Try alternative label names
current=$(kubectl get svc "${SERVICE_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.spec.selector.deployment-slot}' 2>/dev/null || echo "")
fi
if [[ -z "$current" ]]; then
current=$(kubectl get svc "${SERVICE_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.spec.selector.color}' 2>/dev/null || echo "unknown")
fi
echo "$current"
}
# ── Check target slot is healthy ────────────────────────────────────────────
check_target_health() {
if [[ "$SKIP_HEALTH_CHECK" == true ]]; then
info "Skipping target health check (--skip-health)"
return 0
fi
info "Checking health of ${BOLD}${TARGET}${NC} slot..."
# Verify pods exist and are ready
local ready_pods
ready_pods=$(kubectl get pods -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME},slot=${TARGET}" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].metadata.name}' 2>/dev/null || echo "")
if [[ -z "$ready_pods" ]]; then
# Try with 'color' label
ready_pods=$(kubectl get pods -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME},color=${TARGET}" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].metadata.name}' 2>/dev/null || echo "")
fi
if [[ -z "$ready_pods" ]]; then
# Try with 'deployment-slot' label
ready_pods=$(kubectl get pods -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME},deployment-slot=${TARGET}" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[*].metadata.name}' 2>/dev/null || echo "")
fi
if [[ -z "$ready_pods" ]]; then
fatal "No running pods found for ${TARGET} slot. Deploy first."
fi
info "Found running pods in ${TARGET} slot: ${ready_pods}"
# Health check via port-forward to first pod
local first_pod
first_pod=$(echo "$ready_pods" | awk '{print $1}')
local local_port=14821
kubectl port-forward "pod/${first_pod}" "${local_port}:${APP_PORT}" -n "${NAMESPACE}" &
local pf_pid=$!
sleep 3
local healthy=false
for attempt in 1 2 3 4 5; do
if curl -sf --max-time 5 "http://localhost:${local_port}/api/health" | grep -q '"status":"ok"'; then
healthy=true
break
fi
info "Health check attempt ${attempt}/5..."
sleep 2
done
kill "$pf_pid" 2>/dev/null || true
if [[ "$healthy" != true ]]; then
fatal "${TARGET} slot is NOT healthy. Aborting traffic switch."
fi
ok "${TARGET} slot is healthy"
}
# ── Switch traffic ──────────────────────────────────────────────────────────
switch_traffic() {
local current_slot
current_slot=$(detect_current_slot)
info "Current active slot: ${BOLD}${current_slot}${NC}"
info "Switching to: ${BOLD}${TARGET}${NC}"
if [[ "$current_slot" == "$TARGET" ]]; then
warn "Traffic is already pointing to ${TARGET}. Nothing to do."
return 0
fi
if [[ "$ENVIRONMENT" == "production" ]] && [[ "$DRY_RUN" == false ]]; then
echo ""
warn "Switching ${BOLD}PRODUCTION${NC} traffic from ${current_slot}${TARGET}"
read -r -p "$(echo -e "${YELLOW}Type 'yes' to confirm:${NC} ")" confirm
[[ "$confirm" == "yes" ]] || fatal "Switch cancelled."
fi
# Build the patch try common label conventions
local label_key="slot"
local current_labels
current_labels=$(kubectl get svc "${SERVICE_NAME}" -n "${NAMESPACE}" -o json 2>/dev/null)
if echo "$current_labels" | grep -q '"color"'; then
label_key="color"
elif echo "$current_labels" | grep -q '"deployment-slot"'; then
label_key="deployment-slot"
fi
local patch="{\"spec\":{\"selector\":{\"${label_key}\":\"${TARGET}\"}}}"
if [[ "$DRY_RUN" == true ]]; then
info "[DRY-RUN] Would patch service '${SERVICE_NAME}' with:"
echo " ${patch}"
return
fi
if ! kubectl patch svc "${SERVICE_NAME}" -n "${NAMESPACE}" -p "${patch}"; then
fatal "Failed to patch service selector!"
fi
ok "Service '${SERVICE_NAME}' now routing to ${TARGET} slot"
# Verify the switch
local new_slot
new_slot=$(detect_current_slot)
if [[ "$new_slot" != "$TARGET" ]]; then
err "Verification failed! Service selector shows: ${new_slot}"
warn "Attempting to revert to ${current_slot}..."
kubectl patch svc "${SERVICE_NAME}" -n "${NAMESPACE}" \
-p "{\"spec\":{\"selector\":{\"${label_key}\":\"${current_slot}\"}}}" \
&& ok "Reverted to ${current_slot}" \
|| fatal "Revert failed! Manual intervention needed."
exit 1
fi
ok "Verified: traffic now routes to ${TARGET}"
}
# ── Post-switch health check ───────────────────────────────────────────────
post_switch_health() {
if [[ "$DRY_RUN" == true ]] || [[ "$SKIP_HEALTH_CHECK" == true ]]; then
return
fi
info "Running post-switch health check via service..."
sleep 5 # Let connections drain
if [[ -x "${SCRIPT_DIR}/health-check.sh" ]]; then
local local_port=14822
kubectl port-forward "svc/${SERVICE_NAME}" "${local_port}:${APP_PORT}" -n "${NAMESPACE}" &
local pf_pid=$!
sleep 3
if "${SCRIPT_DIR}/health-check.sh" --url "http://localhost:${local_port}" --retries 5 --interval 3; then
ok "Post-switch health check passed"
else
warn "Post-switch health check failed consider switching back!"
fi
kill "$pf_pid" 2>/dev/null || true
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
echo ""
echo -e "${BOLD}${BLUE}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}║ Claude Code Agent Monitor Blue/Green Switch ║${NC}"
echo -e "${BOLD}${BLUE}╚══════════════════════════════════════════════════╝${NC}"
echo ""
parse_args "$@"
info "Configuration:"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Target slot:${NC} ${TARGET}"
echo -e " ${BOLD}Namespace:${NC} ${NAMESPACE}"
echo -e " ${BOLD}Service:${NC} ${SERVICE_NAME}"
echo ""
check_target_health
switch_traffic
post_switch_health
echo ""
ok "${BOLD}Blue-green switch complete!${NC}"
echo -e " ${BOLD}Active slot:${NC} ${TARGET}"
echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo -e " ${BOLD}Rollback:${NC} $(basename "$0") --env ${ENVIRONMENT} --target $([ "$TARGET" = "blue" ] && echo "green" || echo "blue")"
echo ""
}
main "$@"
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# db-backup.sh SQLite database backup for Claude Code Agent Monitor
#
# Usage:
# ./db-backup.sh --env production --output ./backups/
# ./db-backup.sh --env production --output ./backups/ --upload s3://bucket/path
# ./db-backup.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="agent-monitor"
readonly DB_PATH_IN_CONTAINER="/app/data"
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
fatal() { err "$@"; exit 1; }
# ── Defaults ────────────────────────────────────────────────────────────────
ENVIRONMENT=""
OUTPUT_DIR=""
NAMESPACE=""
UPLOAD_DEST=""
POD_NAME=""
DB_FILENAME=""
COMPRESS=true
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}Usage:${NC}
$(basename "$0") --env <environment> --output <directory> [options]
${BOLD}Required:${NC}
--env, -e Environment: dev, staging, production
--output, -o Local directory for the backup file
${BOLD}Options:${NC}
--namespace, -n Kubernetes namespace (default: agent-monitor-<env>)
--pod Specific pod name to copy from (auto-detected if omitted)
--upload Upload backup to S3/GCS (e.g., s3://bucket/backups/)
--no-compress Skip gzip compression
--help, -h Show this help message
${BOLD}Examples:${NC}
$(basename "$0") --env production --output ./backups/
$(basename "$0") --env staging --output /tmp/backups --upload s3://my-bucket/db-backups/
EOF
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e) ENVIRONMENT="$2"; shift 2 ;;
--output|-o) OUTPUT_DIR="$2"; shift 2 ;;
--namespace|-n) NAMESPACE="$2"; shift 2 ;;
--pod) POD_NAME="$2"; shift 2 ;;
--upload) UPLOAD_DEST="$2"; shift 2 ;;
--no-compress) COMPRESS=false; shift ;;
--help|-h) usage ;;
*) fatal "Unknown option: $1" ;;
esac
done
[[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env"
[[ -z "$OUTPUT_DIR" ]] && fatal "Missing required argument: --output"
[[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}"
}
# ── Find target pod ────────────────────────────────────────────────────────
find_pod() {
if [[ -n "$POD_NAME" ]]; then
info "Using specified pod: ${POD_NAME}"
return
fi
info "Finding running pod in namespace '${NAMESPACE}'..."
POD_NAME=$(kubectl get pods -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME}" \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -z "$POD_NAME" ]]; then
fatal "No running pods found for ${APP_NAME} in ${NAMESPACE}"
fi
info "Selected pod: ${POD_NAME}"
}
# ── Create backup ──────────────────────────────────────────────────────────
create_backup() {
local timestamp
timestamp=$(date -u +%Y%m%d_%H%M%S)
DB_FILENAME="${APP_NAME}_${ENVIRONMENT}_${timestamp}.db"
# Create output directory
mkdir -p "${OUTPUT_DIR}"
info "Creating backup..."
# Use sqlite3 .backup inside the pod for a consistent snapshot
# This avoids copying a potentially locked/in-flight database
local remote_backup_path="/tmp/${DB_FILENAME}"
info "Running SQLite backup inside pod (consistent snapshot)..."
if kubectl exec "${POD_NAME}" -n "${NAMESPACE}" -- \
sh -c "
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 '${DB_PATH_IN_CONTAINER}/dashboard.db' '.backup ${remote_backup_path}'
else
cp '${DB_PATH_IN_CONTAINER}/dashboard.db' '${remote_backup_path}'
fi
" 2>/dev/null; then
ok "In-pod backup created at ${remote_backup_path}"
else
# Fallback: also copy WAL files if present
warn "sqlite3 not available in pod, falling back to file copy"
kubectl exec "${POD_NAME}" -n "${NAMESPACE}" -- \
sh -c "cp '${DB_PATH_IN_CONTAINER}/dashboard.db' '${remote_backup_path}'" \
|| fatal "Failed to copy database file"
# Try to also get WAL and SHM files
kubectl exec "${POD_NAME}" -n "${NAMESPACE}" -- \
sh -c "cp '${DB_PATH_IN_CONTAINER}/dashboard.db-wal' '${remote_backup_path}-wal' 2>/dev/null || true"
kubectl exec "${POD_NAME}" -n "${NAMESPACE}" -- \
sh -c "cp '${DB_PATH_IN_CONTAINER}/dashboard.db-shm' '${remote_backup_path}-shm' 2>/dev/null || true"
fi
# Copy backup from pod to local
info "Copying backup to local filesystem..."
kubectl cp "${NAMESPACE}/${POD_NAME}:${remote_backup_path}" "${OUTPUT_DIR}/${DB_FILENAME}" \
|| fatal "Failed to copy backup from pod"
# Copy WAL if it exists
kubectl cp "${NAMESPACE}/${POD_NAME}:${remote_backup_path}-wal" "${OUTPUT_DIR}/${DB_FILENAME}-wal" 2>/dev/null || true
# Cleanup remote temp file
kubectl exec "${POD_NAME}" -n "${NAMESPACE}" -- \
sh -c "rm -f '${remote_backup_path}' '${remote_backup_path}-wal' '${remote_backup_path}-shm'" 2>/dev/null || true
local file_size
file_size=$(du -sh "${OUTPUT_DIR}/${DB_FILENAME}" 2>/dev/null | awk '{print $1}')
ok "Backup saved: ${OUTPUT_DIR}/${DB_FILENAME} (${file_size})"
}
# ── Validate backup integrity ──────────────────────────────────────────────
validate_backup() {
info "Validating backup integrity..."
local db_file="${OUTPUT_DIR}/${DB_FILENAME}"
if ! command -v sqlite3 &>/dev/null; then
warn "sqlite3 not found locally skipping integrity check"
return
fi
# Check integrity
local integrity
integrity=$(sqlite3 "${db_file}" "PRAGMA integrity_check;" 2>/dev/null || echo "error")
if [[ "$integrity" == "ok" ]]; then
ok "SQLite integrity check: OK"
else
err "SQLite integrity check failed: ${integrity}"
warn "Backup may be corrupted consider re-running the backup"
return 1
fi
# Show basic stats
local table_count
table_count=$(sqlite3 "${db_file}" "SELECT count(*) FROM sqlite_master WHERE type='table';" 2>/dev/null || echo "?")
local page_count
page_count=$(sqlite3 "${db_file}" "PRAGMA page_count;" 2>/dev/null || echo "?")
local page_size
page_size=$(sqlite3 "${db_file}" "PRAGMA page_size;" 2>/dev/null || echo "?")
info "Database stats: ${table_count} tables, ${page_count} pages × ${page_size} bytes"
}
# ── Compress backup ────────────────────────────────────────────────────────
compress_backup() {
if [[ "$COMPRESS" == false ]]; then
info "Compression skipped"
return
fi
local db_file="${OUTPUT_DIR}/${DB_FILENAME}"
if ! command -v gzip &>/dev/null; then
warn "gzip not available skipping compression"
return
fi
info "Compressing backup..."
gzip -k "${db_file}"
DB_FILENAME="${DB_FILENAME}.gz"
local compressed_size
compressed_size=$(du -sh "${OUTPUT_DIR}/${DB_FILENAME}" 2>/dev/null | awk '{print $1}')
ok "Compressed: ${OUTPUT_DIR}/${DB_FILENAME} (${compressed_size})"
}
# ── Upload to cloud storage ────────────────────────────────────────────────
upload_backup() {
if [[ -z "$UPLOAD_DEST" ]]; then
return
fi
local backup_file="${OUTPUT_DIR}/${DB_FILENAME}"
info "Uploading backup to ${UPLOAD_DEST}..."
if [[ "$UPLOAD_DEST" == s3://* ]]; then
if ! command -v aws &>/dev/null; then
fatal "AWS CLI not found. Install it to upload to S3."
fi
aws s3 cp "${backup_file}" "${UPLOAD_DEST}${DB_FILENAME}" \
--storage-class STANDARD_IA \
|| fatal "S3 upload failed"
ok "Uploaded to ${UPLOAD_DEST}${DB_FILENAME}"
elif [[ "$UPLOAD_DEST" == gs://* ]]; then
if ! command -v gsutil &>/dev/null; then
fatal "gsutil not found. Install Google Cloud SDK to upload to GCS."
fi
gsutil cp "${backup_file}" "${UPLOAD_DEST}${DB_FILENAME}" \
|| fatal "GCS upload failed"
ok "Uploaded to ${UPLOAD_DEST}${DB_FILENAME}"
elif [[ "$UPLOAD_DEST" == az://* ]] || [[ "$UPLOAD_DEST" == https://*.blob.core.windows.net/* ]]; then
if ! command -v az &>/dev/null; then
fatal "Azure CLI not found. Install it to upload to Azure Blob."
fi
local container_url="${UPLOAD_DEST}"
az storage blob upload \
--file "${backup_file}" \
--name "${DB_FILENAME}" \
--overwrite \
|| fatal "Azure Blob upload failed"
ok "Uploaded to Azure Blob Storage"
else
warn "Unknown upload destination scheme: ${UPLOAD_DEST}"
warn "Supported: s3://, gs://, az://"
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
echo ""
echo -e "${BOLD}${BLUE}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}║ Claude Code Agent Monitor DB Backup ║${NC}"
echo -e "${BOLD}${BLUE}╚══════════════════════════════════════════════════╝${NC}"
echo ""
parse_args "$@"
info "Configuration:"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Namespace:${NC} ${NAMESPACE}"
echo -e " ${BOLD}Output:${NC} ${OUTPUT_DIR}"
[[ -n "$UPLOAD_DEST" ]] && echo -e " ${BOLD}Upload:${NC} ${UPLOAD_DEST}"
echo ""
find_pod
create_backup
validate_backup
compress_backup
upload_backup
echo ""
ok "${BOLD}Backup complete!${NC}"
echo -e " ${BOLD}File:${NC} ${OUTPUT_DIR}/${DB_FILENAME}"
echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
}
main "$@"
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# db-restore.sh SQLite database restore for Claude Code Agent Monitor
#
# Usage:
# ./db-restore.sh --env production --input ./backups/agent-monitor_production_20240101_120000.db
# ./db-restore.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="agent-monitor"
readonly APP_PORT=4820
readonly DB_PATH_IN_CONTAINER="/app/data"
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
fatal() { err "$@"; exit 1; }
# ── Defaults ────────────────────────────────────────────────────────────────
ENVIRONMENT=""
INPUT_FILE=""
NAMESPACE=""
SKIP_HEALTH_CHECK=false
FORCE=false
BACKUP_BEFORE_RESTORE=true
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}Usage:${NC}
$(basename "$0") --env <environment> --input <backup-file> [options]
${BOLD}Required:${NC}
--env, -e Environment: dev, staging, production
--input, -i Path to backup file (.db or .db.gz)
${BOLD}Options:${NC}
--namespace, -n Kubernetes namespace (default: agent-monitor-<env>)
--no-backup Skip backing up current DB before restore
--skip-health Skip post-restore health check
--force Skip confirmation prompt
--help, -h Show this help message
${BOLD}Examples:${NC}
$(basename "$0") --env production --input ./backups/agent-monitor_production_20240101_120000.db
$(basename "$0") --env staging --input ./backups/backup.db.gz --force
EOF
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e) ENVIRONMENT="$2"; shift 2 ;;
--input|-i) INPUT_FILE="$2"; shift 2 ;;
--namespace|-n) NAMESPACE="$2"; shift 2 ;;
--no-backup) BACKUP_BEFORE_RESTORE=false; shift ;;
--skip-health) SKIP_HEALTH_CHECK=true; shift ;;
--force) FORCE=true; shift ;;
--help|-h) usage ;;
*) fatal "Unknown option: $1" ;;
esac
done
[[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env"
[[ -z "$INPUT_FILE" ]] && fatal "Missing required argument: --input"
[[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}"
# Validate input file exists
[[ -f "$INPUT_FILE" ]] || fatal "Input file not found: ${INPUT_FILE}"
}
# ── Validate backup file ───────────────────────────────────────────────────
validate_input() {
info "Validating input file: ${INPUT_FILE}"
local restore_file="${INPUT_FILE}"
# Decompress if needed
if [[ "$INPUT_FILE" == *.gz ]]; then
info "Decompressing gzipped backup..."
restore_file="${INPUT_FILE%.gz}"
if [[ -f "$restore_file" ]]; then
warn "Decompressed file already exists: ${restore_file}"
else
gzip -dk "${INPUT_FILE}" || fatal "Failed to decompress ${INPUT_FILE}"
fi
fi
RESTORE_FILE="$restore_file"
# Validate with sqlite3 if available
if command -v sqlite3 &>/dev/null; then
local integrity
integrity=$(sqlite3 "${RESTORE_FILE}" "PRAGMA integrity_check;" 2>/dev/null || echo "error")
if [[ "$integrity" == "ok" ]]; then
ok "SQLite integrity check passed"
else
fatal "Input file failed integrity check: ${integrity}"
fi
local table_count
table_count=$(sqlite3 "${RESTORE_FILE}" "SELECT count(*) FROM sqlite_master WHERE type='table';" 2>/dev/null || echo "?")
info "Backup contains ${table_count} tables"
else
warn "sqlite3 not available skipping integrity check"
# Basic file header check
local header
header=$(head -c 16 "${RESTORE_FILE}" | strings 2>/dev/null || echo "")
if echo "$header" | grep -q "SQLite format"; then
ok "File appears to be a valid SQLite database"
else
fatal "File does not appear to be a SQLite database"
fi
fi
local file_size
file_size=$(du -sh "${RESTORE_FILE}" 2>/dev/null | awk '{print $1}')
info "Restore file size: ${file_size}"
}
# ── Safety confirmation ─────────────────────────────────────────────────────
confirm_restore() {
if [[ "$FORCE" == true ]]; then
return
fi
echo ""
warn "${BOLD}⚠ DATABASE RESTORE WARNING ⚠${NC}"
echo ""
echo -e " This will ${RED}${BOLD}REPLACE${NC} the current database in ${BOLD}${ENVIRONMENT}${NC}"
echo -e " with the contents of: ${INPUT_FILE}"
echo ""
echo -e " The deployment will be ${BOLD}scaled down${NC} during restore."
echo ""
if [[ "$ENVIRONMENT" == "production" ]]; then
echo -e " ${RED}${BOLD}THIS IS A PRODUCTION ENVIRONMENT!${NC}"
echo ""
read -r -p "$(echo -e "${YELLOW}Type the environment name to confirm:${NC} ")" confirm
[[ "$confirm" == "$ENVIRONMENT" ]] || fatal "Restore cancelled. You typed '${confirm}', expected '${ENVIRONMENT}'."
else
read -r -p "$(echo -e "${YELLOW}Type 'yes' to confirm:${NC} ")" confirm
[[ "$confirm" == "yes" ]] || fatal "Restore cancelled."
fi
}
# ── Get deployment info ─────────────────────────────────────────────────────
get_deployment_info() {
info "Getting deployment info..."
DEPLOYMENT_NAME=$(kubectl get deployment -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME}" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -z "$DEPLOYMENT_NAME" ]]; then
fatal "No deployment found for ${APP_NAME} in ${NAMESPACE}"
fi
ORIGINAL_REPLICAS=$(kubectl get deployment "${DEPLOYMENT_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.spec.replicas}' 2>/dev/null || echo "1")
info "Deployment: ${DEPLOYMENT_NAME} (${ORIGINAL_REPLICAS} replicas)"
}
# ── Backup current DB before restore ───────────────────────────────────────
backup_current() {
if [[ "$BACKUP_BEFORE_RESTORE" == false ]]; then
info "Skipping pre-restore backup (--no-backup)"
return
fi
info "Backing up current database before restore..."
if [[ -x "${SCRIPT_DIR}/db-backup.sh" ]]; then
local backup_dir="${SCRIPT_DIR}/../../data/pre-restore-backups"
"${SCRIPT_DIR}/db-backup.sh" \
--env "${ENVIRONMENT}" \
--output "${backup_dir}" \
--namespace "${NAMESPACE}" \
--no-compress \
&& ok "Pre-restore backup created in ${backup_dir}" \
|| warn "Pre-restore backup failed proceeding anyway"
else
warn "db-backup.sh not found skipping pre-restore backup"
fi
}
# ── Scale down deployment ───────────────────────────────────────────────────
scale_down() {
info "Scaling down deployment to 0 replicas..."
kubectl scale deployment "${DEPLOYMENT_NAME}" \
--replicas=0 \
-n "${NAMESPACE}" \
|| fatal "Failed to scale down deployment"
# Wait for all pods to terminate
info "Waiting for pods to terminate..."
local wait_count=0
while [[ $wait_count -lt 60 ]]; do
local running
running=$(kubectl get pods -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME}" \
--field-selector=status.phase=Running \
--no-headers 2>/dev/null | wc -l | tr -d ' ')
if [[ "$running" == "0" ]]; then
ok "All pods terminated"
return
fi
wait_count=$((wait_count + 1))
sleep 2
done
warn "Pods did not terminate within timeout"
}
# ── Restore database ───────────────────────────────────────────────────────
restore_database() {
info "Restoring database..."
# We need a temporary pod to access the PVC
# Create a helper pod that mounts the PVC
local helper_pod="${APP_NAME}-db-restore-helper"
# Get PVC name
local pvc_name
pvc_name=$(kubectl get pvc -n "${NAMESPACE}" \
-l "app.kubernetes.io/name=${APP_NAME}" \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "agent-monitor-data")
info "Creating helper pod to access PVC: ${pvc_name}"
kubectl apply -n "${NAMESPACE}" -f - <<YAML
apiVersion: v1
kind: Pod
metadata:
name: ${helper_pod}
labels:
app: db-restore-helper
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: helper
image: alpine:3.19
command: ["sleep", "3600"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: ${pvc_name}
YAML
# Wait for helper pod to be ready
info "Waiting for helper pod..."
if ! kubectl wait --for=condition=ready "pod/${helper_pod}" -n "${NAMESPACE}" --timeout=120s; then
kubectl delete pod "${helper_pod}" -n "${NAMESPACE}" --ignore-not-found=true
fatal "Helper pod did not become ready"
fi
# Backup existing DB in the PVC
info "Moving existing database to .bak..."
kubectl exec "${helper_pod}" -n "${NAMESPACE}" -- \
sh -c "[ -f /data/dashboard.db ] && mv /data/dashboard.db /data/dashboard.db.bak || true"
kubectl exec "${helper_pod}" -n "${NAMESPACE}" -- \
sh -c "rm -f /data/dashboard.db-wal /data/dashboard.db-shm"
# Copy new database to pod, then to PVC path
info "Uploading restore file..."
kubectl cp "${RESTORE_FILE}" "${NAMESPACE}/${helper_pod}:/data/dashboard.db" \
|| { kubectl delete pod "${helper_pod}" -n "${NAMESPACE}" --ignore-not-found=true; fatal "Failed to copy restore file"; }
# Verify copied file
kubectl exec "${helper_pod}" -n "${NAMESPACE}" -- ls -la /data/dashboard.db
# Cleanup helper pod
info "Removing helper pod..."
kubectl delete pod "${helper_pod}" -n "${NAMESPACE}" --ignore-not-found=true --wait=false
ok "Database file restored"
}
# ── Scale up deployment ─────────────────────────────────────────────────────
scale_up() {
info "Scaling deployment back to ${ORIGINAL_REPLICAS} replicas..."
kubectl scale deployment "${DEPLOYMENT_NAME}" \
--replicas="${ORIGINAL_REPLICAS}" \
-n "${NAMESPACE}" \
|| fatal "Failed to scale up deployment"
# Wait for rollout
info "Waiting for pods to start..."
if ! kubectl rollout status "deployment/${DEPLOYMENT_NAME}" -n "${NAMESPACE}" --timeout=300s; then
fatal "Deployment did not stabilize after restore!"
fi
ok "Deployment scaled up successfully"
}
# ── Post-restore health check ──────────────────────────────────────────────
run_health_check() {
if [[ "$SKIP_HEALTH_CHECK" == true ]]; then
info "Skipping health check"
return
fi
info "Running post-restore health check..."
# Wait for pods to be ready
if ! kubectl wait --for=condition=ready pod \
-l "app.kubernetes.io/name=${APP_NAME}" \
-n "${NAMESPACE}" --timeout=120s 2>/dev/null; then
fatal "Pods did not become ready after restore!"
fi
if [[ -x "${SCRIPT_DIR}/health-check.sh" ]]; then
local local_port=14823
kubectl port-forward "svc/${APP_NAME}" "${local_port}:${APP_PORT}" -n "${NAMESPACE}" &
local pf_pid=$!
sleep 3
if "${SCRIPT_DIR}/health-check.sh" --url "http://localhost:${local_port}" --retries 10 --interval 3; then
ok "Post-restore health check passed"
else
err "Post-restore health check failed!"
warn "The application may need manual investigation"
fi
kill "$pf_pid" 2>/dev/null || true
else
ok "Pods are ready"
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
echo ""
echo -e "${BOLD}${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${YELLOW}║ Claude Code Agent Monitor DB Restore ║${NC}"
echo -e "${BOLD}${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
echo ""
parse_args "$@"
validate_input
confirm_restore
get_deployment_info
backup_current
scale_down
restore_database
scale_up
run_health_check
echo ""
ok "${BOLD}Database restore complete!${NC}"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Source:${NC} ${INPUT_FILE}"
echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
}
main "$@"
+572
View File
@@ -0,0 +1,572 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# deploy.sh Main deployment orchestrator for Claude Code Agent Monitor
#
# Usage:
# ./deploy.sh --env dev|staging|production --method helm|kustomize|terraform
# ./deploy.sh --env production --method helm --strategy blue-green|canary|rolling
# ./deploy.sh --env staging --method helm --dry-run
# ./deploy.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
# ── Constants ───────────────────────────────────────────────────────────────
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
readonly DEPLOY_DIR="${PROJECT_ROOT}/deployments"
readonly APP_NAME="agent-monitor"
readonly APP_PORT=4820
readonly DEFAULT_REGISTRY="ghcr.io"
readonly DEFAULT_IMAGE_NAME="claude-code-agent-monitor"
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
fatal() { err "$@"; exit 1; }
banner() {
echo ""
echo -e "${BOLD}${BLUE}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${BLUE}║ Claude Code Agent Monitor Deploy ║${NC}"
echo -e "${BOLD}${BLUE}╚══════════════════════════════════════════════════╝${NC}"
echo ""
}
# ── Default parameter values ────────────────────────────────────────────────
ENVIRONMENT=""
METHOD=""
STRATEGY="rolling"
DRY_RUN=false
IMAGE_TAG=""
REGISTRY="${DOCKER_REGISTRY:-$DEFAULT_REGISTRY}"
IMAGE_NAME="${DOCKER_IMAGE_NAME:-$DEFAULT_IMAGE_NAME}"
NAMESPACE=""
HELM_RELEASE="${APP_NAME}"
HELM_CHART_DIR="${DEPLOY_DIR}/helm/agent-monitor"
KUBE_CONTEXT=""
SKIP_BUILD=false
SKIP_HEALTH_CHECK=false
HEALTH_CHECK_RETRIES=30
HEALTH_CHECK_INTERVAL=5
VALUES_FILE=""
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}Usage:${NC}
$(basename "$0") --env <environment> --method <method> [options]
${BOLD}Required:${NC}
--env, -e Environment: dev, staging, production
--method, -m Deployment method: helm, kustomize, terraform
${BOLD}Options:${NC}
--strategy, -s Deployment strategy: rolling (default), blue-green, canary
--tag, -t Docker image tag (default: git SHA)
--registry Container registry (default: ${DEFAULT_REGISTRY})
--image Image name (default: ${DEFAULT_IMAGE_NAME})
--namespace, -n Kubernetes namespace (default: agent-monitor-<env>)
--release Helm release name (default: ${APP_NAME})
--context Kubernetes context to use
--values Additional Helm values file
--skip-build Skip container image build/push
--skip-health Skip post-deploy health check
--dry-run Preview changes without applying
--help, -h Show this help message
${BOLD}Examples:${NC}
$(basename "$0") --env dev --method helm
$(basename "$0") --env production --method helm --strategy blue-green --tag v1.2.3
$(basename "$0") --env staging --method kustomize --dry-run
$(basename "$0") --env production --method terraform
EOF
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e) ENVIRONMENT="$2"; shift 2 ;;
--method|-m) METHOD="$2"; shift 2 ;;
--strategy|-s) STRATEGY="$2"; shift 2 ;;
--tag|-t) IMAGE_TAG="$2"; shift 2 ;;
--registry) REGISTRY="$2"; shift 2 ;;
--image) IMAGE_NAME="$2"; shift 2 ;;
--namespace|-n) NAMESPACE="$2"; shift 2 ;;
--release) HELM_RELEASE="$2"; shift 2 ;;
--context) KUBE_CONTEXT="$2"; shift 2 ;;
--values) VALUES_FILE="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=true; shift ;;
--skip-health) SKIP_HEALTH_CHECK=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--help|-h) usage ;;
*) fatal "Unknown option: $1. Use --help for usage." ;;
esac
done
}
# ── Validation ──────────────────────────────────────────────────────────────
validate_args() {
[[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env"
[[ -z "$METHOD" ]] && fatal "Missing required argument: --method"
case "$ENVIRONMENT" in
dev|staging|production) ;;
*) fatal "Invalid environment: $ENVIRONMENT. Must be dev, staging, or production." ;;
esac
case "$METHOD" in
helm|kustomize|terraform) ;;
*) fatal "Invalid method: $METHOD. Must be helm, kustomize, or terraform." ;;
esac
case "$STRATEGY" in
rolling|blue-green|canary) ;;
*) fatal "Invalid strategy: $STRATEGY. Must be rolling, blue-green, or canary." ;;
esac
# Default namespace
[[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}"
# Default image tag from git
if [[ -z "$IMAGE_TAG" ]]; then
IMAGE_TAG="$(git -C "$PROJECT_ROOT" rev-parse --short HEAD 2>/dev/null || echo 'latest')"
fi
readonly FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
readonly MCP_IMAGE="${REGISTRY}/${IMAGE_NAME}-mcp:${IMAGE_TAG}"
}
# ── Prerequisite checks ────────────────────────────────────────────────────
check_prerequisites() {
info "Checking prerequisites..."
local missing=()
# Always need docker for building
if [[ "$SKIP_BUILD" == false ]]; then
command -v docker &>/dev/null || missing+=("docker")
fi
case "$METHOD" in
helm)
command -v kubectl &>/dev/null || missing+=("kubectl")
command -v helm &>/dev/null || missing+=("helm")
;;
kustomize)
command -v kubectl &>/dev/null || missing+=("kubectl")
command -v kustomize &>/dev/null || missing+=("kustomize")
;;
terraform)
command -v terraform &>/dev/null || missing+=("terraform")
;;
esac
if [[ ${#missing[@]} -gt 0 ]]; then
fatal "Missing required tools: ${missing[*]}. Please install them and retry."
fi
# Validate kube context if specified
if [[ -n "$KUBE_CONTEXT" ]] && [[ "$METHOD" != "terraform" ]]; then
if ! kubectl config get-contexts "$KUBE_CONTEXT" &>/dev/null; then
fatal "Kubernetes context '$KUBE_CONTEXT' not found."
fi
kubectl config use-context "$KUBE_CONTEXT"
fi
# Validate Helm chart exists
if [[ "$METHOD" == "helm" ]] && [[ ! -f "${HELM_CHART_DIR}/Chart.yaml" ]]; then
fatal "Helm chart not found at ${HELM_CHART_DIR}"
fi
ok "All prerequisites satisfied"
}
# ── Build & push container images ───────────────────────────────────────────
build_and_push() {
if [[ "$SKIP_BUILD" == true ]]; then
info "Skipping image build (--skip-build)"
return
fi
info "Building container images..."
local docker_cmd="docker build"
local push_cmd="docker push"
if [[ "$DRY_RUN" == true ]]; then
info "[DRY-RUN] Would build: ${FULL_IMAGE}"
info "[DRY-RUN] Would build: ${MCP_IMAGE}"
return
fi
# Build main application image
log "Building main app image: ${FULL_IMAGE}"
docker build \
--file "${PROJECT_ROOT}/Dockerfile" \
--tag "${FULL_IMAGE}" \
--label "org.opencontainers.image.revision=$(git -C "$PROJECT_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')" \
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"${PROJECT_ROOT}"
ok "Main app image built: ${FULL_IMAGE}"
# Build MCP sidecar image
log "Building MCP sidecar image: ${MCP_IMAGE}"
docker build \
--file "${PROJECT_ROOT}/mcp/Dockerfile" \
--tag "${MCP_IMAGE}" \
"${PROJECT_ROOT}"
ok "MCP sidecar image built: ${MCP_IMAGE}"
# Push images
log "Pushing images to registry..."
docker push "${FULL_IMAGE}"
docker push "${MCP_IMAGE}"
ok "Images pushed to ${REGISTRY}"
}
# ── Helm deployment ─────────────────────────────────────────────────────────
deploy_helm() {
info "Deploying via Helm (strategy: ${STRATEGY})..."
local helm_args=(
upgrade --install "${HELM_RELEASE}" "${HELM_CHART_DIR}"
--namespace "${NAMESPACE}"
--create-namespace
--set "image.repository=${REGISTRY}/${IMAGE_NAME}"
--set "image.tag=${IMAGE_TAG}"
--set "environment=${ENVIRONMENT}"
--set "mcp.image.repository=${REGISTRY}/${IMAGE_NAME}-mcp"
--set "mcp.image.tag=${IMAGE_TAG}"
--timeout 600s
--wait
--atomic
)
# Environment-specific values
local env_values="${HELM_CHART_DIR}/values-${ENVIRONMENT}.yaml"
if [[ -f "$env_values" ]]; then
helm_args+=(--values "$env_values")
fi
# User-provided values file
if [[ -n "$VALUES_FILE" ]] && [[ -f "$VALUES_FILE" ]]; then
helm_args+=(--values "$VALUES_FILE")
fi
# Strategy-specific settings
case "$STRATEGY" in
blue-green)
helm_args+=(--set "strategy.type=blue-green")
;;
canary)
helm_args+=(--set "strategy.type=canary")
helm_args+=(--set "strategy.canary.weight=10")
;;
rolling)
helm_args+=(--set "strategy.type=rolling")
helm_args+=(--set "strategy.rolling.maxUnavailable=25%")
helm_args+=(--set "strategy.rolling.maxSurge=25%")
;;
esac
if [[ "$DRY_RUN" == true ]]; then
helm_args+=(--dry-run --debug)
info "[DRY-RUN] Helm command:"
echo " helm ${helm_args[*]}"
helm "${helm_args[@]}" 2>&1 | head -100
return
fi
# Store current revision for rollback
local current_revision
current_revision=$(helm history "${HELM_RELEASE}" -n "${NAMESPACE}" --max 1 -o json 2>/dev/null \
| grep -o '"revision":[0-9]*' | head -1 | cut -d: -f2 || echo "0")
info "Current Helm revision: ${current_revision}"
# Execute deployment
if ! helm "${helm_args[@]}"; then
err "Helm deployment failed!"
if [[ "$current_revision" != "0" ]]; then
warn "Attempting auto-rollback to revision ${current_revision}..."
helm rollback "${HELM_RELEASE}" "${current_revision}" -n "${NAMESPACE}" --wait --timeout 300s \
&& ok "Auto-rollback to revision ${current_revision} succeeded" \
|| fatal "Auto-rollback also failed! Manual intervention required."
fi
exit 1
fi
ok "Helm deployment succeeded"
}
# ── Kustomize deployment ───────────────────────────────────────────────────
deploy_kustomize() {
info "Deploying via Kustomize (overlay: ${ENVIRONMENT})..."
local overlay_dir="${DEPLOY_DIR}/kubernetes/overlays/${ENVIRONMENT}"
if [[ ! -d "$overlay_dir" ]]; then
fatal "Kustomize overlay not found at ${overlay_dir}"
fi
# Set image in kustomization
local kustomize_cmd="kubectl apply -k ${overlay_dir}"
if [[ "$DRY_RUN" == true ]]; then
info "[DRY-RUN] Kustomize output:"
kubectl kustomize "${overlay_dir}" | head -100
return
fi
# Update image reference using kustomize edit
pushd "${overlay_dir}" > /dev/null
kustomize edit set image "${APP_NAME}=${FULL_IMAGE}" 2>/dev/null || true
popd > /dev/null
# Apply with server-side apply for safety
if ! kubectl apply -k "${overlay_dir}" --server-side --force-conflicts; then
err "Kustomize deployment failed!"
warn "Run: kubectl rollout undo deployment/${APP_NAME} -n ${NAMESPACE}"
exit 1
fi
# Wait for rollout
info "Waiting for rollout to complete..."
if ! kubectl rollout status "deployment/${APP_NAME}" -n "${NAMESPACE}" --timeout=600s; then
err "Rollout did not complete in time!"
warn "Attempting auto-rollback..."
kubectl rollout undo "deployment/${APP_NAME}" -n "${NAMESPACE}" \
&& ok "Auto-rollback succeeded" \
|| fatal "Auto-rollback failed! Manual intervention required."
exit 1
fi
ok "Kustomize deployment succeeded"
}
# ── Terraform deployment ───────────────────────────────────────────────────
deploy_terraform() {
info "Deploying via Terraform (environment: ${ENVIRONMENT})..."
local tf_dir="${DEPLOY_DIR}/terraform"
local env_vars_file="${tf_dir}/environments/${ENVIRONMENT}/terraform.tfvars"
if [[ ! -d "$tf_dir" ]]; then
fatal "Terraform directory not found at ${tf_dir}"
fi
pushd "${tf_dir}" > /dev/null
# Initialize
info "Running terraform init..."
terraform init -input=false
# Plan
local plan_args=(-input=false -out=tfplan)
if [[ -f "$env_vars_file" ]]; then
plan_args+=(-var-file="$env_vars_file")
fi
plan_args+=(-var "app_container_image=${FULL_IMAGE}")
plan_args+=(-var "environment=${ENVIRONMENT}")
info "Running terraform plan..."
terraform plan "${plan_args[@]}"
if [[ "$DRY_RUN" == true ]]; then
info "[DRY-RUN] Terraform plan complete. Skipping apply."
rm -f tfplan
popd > /dev/null
return
fi
# Apply
info "Applying terraform plan..."
if ! terraform apply -input=false tfplan; then
err "Terraform apply failed!"
fatal "Review state and run 'terraform plan' to diagnose."
fi
rm -f tfplan
popd > /dev/null
ok "Terraform deployment succeeded"
}
# ── Post-deployment health check ───────────────────────────────────────────
run_health_check() {
if [[ "$SKIP_HEALTH_CHECK" == true ]] || [[ "$DRY_RUN" == true ]]; then
info "Skipping health check"
return
fi
info "Running post-deployment health check..."
# Determine health check URL
local health_url=""
if [[ "$METHOD" == "terraform" ]]; then
info "For Terraform deployments, verify health via the load balancer URL in terraform output."
return
fi
# Try to get service URL from cluster
local svc_type
svc_type=$(kubectl get svc "${APP_NAME}" -n "${NAMESPACE}" -o jsonpath='{.spec.type}' 2>/dev/null || echo "")
case "$svc_type" in
LoadBalancer)
local lb_host
lb_host=$(kubectl get svc "${APP_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.status.loadBalancer.ingress[0].hostname}' 2>/dev/null || echo "")
[[ -z "$lb_host" ]] && lb_host=$(kubectl get svc "${APP_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>/dev/null || echo "")
[[ -n "$lb_host" ]] && health_url="http://${lb_host}:${APP_PORT}/api/health"
;;
NodePort)
local node_port
node_port=$(kubectl get svc "${APP_NAME}" -n "${NAMESPACE}" \
-o jsonpath='{.spec.ports[0].nodePort}' 2>/dev/null || echo "")
[[ -n "$node_port" ]] && health_url="http://localhost:${node_port}/api/health"
;;
*)
# Use port-forward for ClusterIP
info "Service type is ClusterIP using kubectl port-forward for health check"
local local_port=14820
kubectl port-forward "svc/${APP_NAME}" "${local_port}:${APP_PORT}" -n "${NAMESPACE}" &
local pf_pid=$!
sleep 3
health_url="http://localhost:${local_port}/api/health"
;;
esac
if [[ -z "$health_url" ]]; then
warn "Could not determine health check URL. Checking pod readiness instead."
if kubectl wait --for=condition=ready pod -l "app.kubernetes.io/name=${APP_NAME}" \
-n "${NAMESPACE}" --timeout=120s; then
ok "Pods are ready"
else
err "Pods did not become ready"
trigger_auto_rollback
fi
return
fi
# Run health check script
if [[ -x "${SCRIPT_DIR}/health-check.sh" ]]; then
if ! "${SCRIPT_DIR}/health-check.sh" \
--url "${health_url}" \
--retries "${HEALTH_CHECK_RETRIES}" \
--interval "${HEALTH_CHECK_INTERVAL}"; then
err "Health check failed after deployment!"
# Kill port-forward if running
[[ -n "${pf_pid:-}" ]] && kill "$pf_pid" 2>/dev/null || true
trigger_auto_rollback
fi
else
# Inline health check
local attempt=0
while [[ $attempt -lt $HEALTH_CHECK_RETRIES ]]; do
if curl -sf --max-time 5 "${health_url}" | grep -q '"status":"ok"'; then
ok "Health check passed"
[[ -n "${pf_pid:-}" ]] && kill "$pf_pid" 2>/dev/null || true
return
fi
attempt=$((attempt + 1))
info "Health check attempt ${attempt}/${HEALTH_CHECK_RETRIES}..."
sleep "${HEALTH_CHECK_INTERVAL}"
done
err "Health check failed after ${HEALTH_CHECK_RETRIES} attempts!"
[[ -n "${pf_pid:-}" ]] && kill "$pf_pid" 2>/dev/null || true
trigger_auto_rollback
fi
# Cleanup port-forward
[[ -n "${pf_pid:-}" ]] && kill "$pf_pid" 2>/dev/null || true
}
trigger_auto_rollback() {
warn "Triggering auto-rollback..."
case "$METHOD" in
helm)
helm rollback "${HELM_RELEASE}" -n "${NAMESPACE}" --wait --timeout 300s \
&& ok "Auto-rollback succeeded" \
|| fatal "Auto-rollback failed! Manual intervention required."
;;
kustomize)
kubectl rollout undo "deployment/${APP_NAME}" -n "${NAMESPACE}" \
&& ok "Auto-rollback succeeded" \
|| fatal "Auto-rollback failed! Manual intervention required."
;;
terraform)
warn "Terraform auto-rollback not supported. Review state manually."
;;
esac
exit 1
}
# ── Production safety gate ──────────────────────────────────────────────────
confirm_production() {
if [[ "$ENVIRONMENT" == "production" ]] && [[ "$DRY_RUN" == false ]]; then
echo ""
warn "You are about to deploy to ${BOLD}PRODUCTION${NC}"
echo -e " ${BOLD}Method:${NC} ${METHOD}"
echo -e " ${BOLD}Strategy:${NC} ${STRATEGY}"
echo -e " ${BOLD}Image:${NC} ${FULL_IMAGE}"
echo -e " ${BOLD}Namespace:${NC} ${NAMESPACE}"
echo ""
read -r -p "$(echo -e "${YELLOW}Type 'yes' to confirm:${NC} ")" confirm
[[ "$confirm" == "yes" ]] || fatal "Deployment cancelled."
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
banner
parse_args "$@"
validate_args
info "Deployment configuration:"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Method:${NC} ${METHOD}"
echo -e " ${BOLD}Strategy:${NC} ${STRATEGY}"
echo -e " ${BOLD}Image:${NC} ${FULL_IMAGE}"
echo -e " ${BOLD}Namespace:${NC} ${NAMESPACE}"
echo -e " ${BOLD}Dry run:${NC} ${DRY_RUN}"
echo ""
check_prerequisites
confirm_production
build_and_push
case "$METHOD" in
helm) deploy_helm ;;
kustomize) deploy_kustomize ;;
terraform) deploy_terraform ;;
esac
run_health_check
echo ""
ok "${BOLD}Deployment complete!${NC}"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Image:${NC} ${FULL_IMAGE}"
echo -e " ${BOLD}Namespace:${NC} ${NAMESPACE}"
echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
}
main "$@"
+342
View File
@@ -0,0 +1,342 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# health-check.sh Comprehensive health check for Claude Code Agent Monitor
#
# Usage:
# ./health-check.sh --url http://localhost:4820
# ./health-check.sh --url http://host:port --retries 30 --interval 5
# ./health-check.sh --url http://host:port --json
# ./health-check.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { [[ "$JSON_OUTPUT" == true ]] && return; echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { [[ "$JSON_OUTPUT" == true ]] && return; echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { [[ "$JSON_OUTPUT" == true ]] && return; echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { [[ "$JSON_OUTPUT" == true ]] && return; echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { [[ "$JSON_OUTPUT" == true ]] && return; echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
# ── Defaults ────────────────────────────────────────────────────────────────
BASE_URL=""
RETRIES=30
INTERVAL=5
TIMEOUT=5
RESPONSE_THRESHOLD=2000 # milliseconds
JSON_OUTPUT=false
CHECK_WEBSOCKET=true
HEALTH_PATH="/api/health"
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<HELP
${BOLD}Usage:${NC}
$(basename "$0") --url <base-url> [options]
${BOLD}Required:${NC}
--url, -u Base URL (e.g., http://localhost:4820)
${BOLD}Options:${NC}
--retries, -r Max retry attempts (default: 30)
--interval, -i Seconds between retries (default: 5)
--timeout HTTP request timeout in seconds (default: 5)
--threshold Max response time in ms (default: 2000)
--path Health endpoint path (default: /api/health)
--no-websocket Skip WebSocket connectivity check
--json Output results as JSON
--help, -h Show this help message
${BOLD}Exit codes:${NC}
0 All checks passed
1 One or more checks failed
${BOLD}Examples:${NC}
$(basename "$0") --url http://localhost:4820
$(basename "$0") --url https://monitor.example.com --retries 10 --json
$(basename "$0") --url http://10.0.1.5:4820 --threshold 500 --no-websocket
HELP
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--url|-u) BASE_URL="$2"; shift 2 ;;
--retries|-r) RETRIES="$2"; shift 2 ;;
--interval|-i) INTERVAL="$2"; shift 2 ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--threshold) RESPONSE_THRESHOLD="$2"; shift 2 ;;
--path) HEALTH_PATH="$2"; shift 2 ;;
--no-websocket) CHECK_WEBSOCKET=false; shift ;;
--json) JSON_OUTPUT=true; shift ;;
--help|-h) usage ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ -z "$BASE_URL" ]] && { echo "Missing required argument: --url" >&2; exit 1; }
# Strip trailing slash
BASE_URL="${BASE_URL%/}"
}
# ── HTTP health check ──────────────────────────────────────────────────────
check_http_health() {
local url="${BASE_URL}${HEALTH_PATH}"
local attempt=0
local http_ok=false
local status_code=""
local response_body=""
local response_time_ms=0
info "Checking HTTP health: ${url}"
while [[ $attempt -lt $RETRIES ]]; do
attempt=$((attempt + 1))
# Measure response time and capture output
local start_ns
start_ns=$(date +%s%N 2>/dev/null || echo "0")
local http_response
http_response=$(curl -sf \
--max-time "${TIMEOUT}" \
--write-out "\n%{http_code}\n%{time_total}" \
"${url}" 2>/dev/null) || true
local end_ns
end_ns=$(date +%s%N 2>/dev/null || echo "0")
if [[ -n "$http_response" ]]; then
response_body=$(echo "$http_response" | head -n -2)
status_code=$(echo "$http_response" | tail -2 | head -1)
local time_total
time_total=$(echo "$http_response" | tail -1)
# Convert seconds to milliseconds
response_time_ms=$(echo "$time_total" | awk '{printf "%.0f", $1 * 1000}' 2>/dev/null || echo "0")
if [[ "$status_code" == "200" ]] && echo "$response_body" | grep -q '"status":"ok"'; then
http_ok=true
break
fi
fi
if [[ $attempt -lt $RETRIES ]]; then
info "Attempt ${attempt}/${RETRIES} waiting ${INTERVAL}s..."
sleep "${INTERVAL}"
fi
done
# Results
HTTP_OK="$http_ok"
HTTP_STATUS="$status_code"
HTTP_BODY="$response_body"
HTTP_RESPONSE_TIME_MS="$response_time_ms"
HTTP_ATTEMPTS="$attempt"
if [[ "$http_ok" == true ]]; then
ok "HTTP health check passed (${response_time_ms}ms, ${attempt} attempt(s))"
else
err "HTTP health check failed after ${attempt} attempts"
fi
}
# ── Response time check ────────────────────────────────────────────────────
check_response_time() {
if [[ "$HTTP_OK" != true ]]; then
LATENCY_OK=false
return
fi
if [[ "$HTTP_RESPONSE_TIME_MS" -le "$RESPONSE_THRESHOLD" ]]; then
LATENCY_OK=true
ok "Response time ${HTTP_RESPONSE_TIME_MS}ms within threshold (${RESPONSE_THRESHOLD}ms)"
else
LATENCY_OK=false
warn "Response time ${HTTP_RESPONSE_TIME_MS}ms exceeds threshold (${RESPONSE_THRESHOLD}ms)"
fi
}
# ── WebSocket connectivity check ───────────────────────────────────────────
check_websocket() {
WS_OK=false
if [[ "$CHECK_WEBSOCKET" == false ]]; then
info "WebSocket check skipped"
WS_OK=true # treat as pass when skipped
return
fi
# Construct WebSocket URL
local ws_url="${BASE_URL}"
ws_url="${ws_url/http:/ws:}"
ws_url="${ws_url/https:/wss:}"
ws_url="${ws_url}/ws"
info "Checking WebSocket: ${ws_url}"
# Check if we have a WebSocket testing tool
if command -v websocat &>/dev/null; then
if echo "" | websocat --one-message -t "${ws_url}" 2>/dev/null; then
WS_OK=true
ok "WebSocket connection succeeded (websocat)"
return
fi
fi
# Fallback: use curl with upgrade headers to test the handshake
local ws_status
ws_status=$(curl -sf \
--max-time "${TIMEOUT}" \
-o /dev/null \
-w "%{http_code}" \
-H "Upgrade: websocket" \
-H "Connection: Upgrade" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
-H "Sec-WebSocket-Version: 13" \
"${BASE_URL}/ws" 2>/dev/null) || ws_status="000"
# 101 = Switching Protocols (WebSocket upgrade success)
# 400 = Bad Request (server recognized WS but rejected still proves WS is available)
case "$ws_status" in
101)
WS_OK=true
ok "WebSocket handshake succeeded (HTTP 101)"
;;
400|426)
WS_OK=true
ok "WebSocket endpoint reachable (HTTP ${ws_status} server recognized upgrade)"
;;
*)
# Try Node.js one-liner as last resort
if command -v node &>/dev/null; then
local node_result
node_result=$(node -e "
const ws = new (require('ws'))('${ws_url}');
const t = setTimeout(() => { process.stdout.write('timeout'); process.exit(1); }, 5000);
ws.on('open', () => { clearTimeout(t); process.stdout.write('ok'); ws.close(); process.exit(0); });
ws.on('error', (e) => { clearTimeout(t); process.stdout.write('error:' + e.message); process.exit(1); });
" 2>/dev/null) || node_result="error"
if [[ "$node_result" == "ok" ]]; then
WS_OK=true
ok "WebSocket connection verified (node)"
else
WS_OK=false
warn "WebSocket check failed: ${node_result}"
fi
else
warn "WebSocket check inconclusive (no ws testing tool available, HTTP status: ${ws_status})"
WS_OK=true # Don't fail the whole check for this
fi
;;
esac
}
# ── Output results ──────────────────────────────────────────────────────────
output_results() {
local overall_healthy=true
[[ "$HTTP_OK" != true ]] && overall_healthy=false
[[ "$LATENCY_OK" != true ]] && overall_healthy=false
[[ "$WS_OK" != true ]] && overall_healthy=false
if [[ "$JSON_OUTPUT" == true ]]; then
cat <<JSON
{
"healthy": ${overall_healthy},
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"url": "${BASE_URL}",
"checks": {
"http": {
"passed": ${HTTP_OK},
"status_code": "${HTTP_STATUS:-null}",
"response_time_ms": ${HTTP_RESPONSE_TIME_MS:-0},
"attempts": ${HTTP_ATTEMPTS:-0},
"body": $(echo "${HTTP_BODY:-null}" | head -c 500 | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))' 2>/dev/null || echo '"null"')
},
"latency": {
"passed": ${LATENCY_OK},
"response_time_ms": ${HTTP_RESPONSE_TIME_MS:-0},
"threshold_ms": ${RESPONSE_THRESHOLD}
},
"websocket": {
"passed": ${WS_OK},
"checked": ${CHECK_WEBSOCKET}
}
}
}
JSON
else
echo ""
echo -e "${BOLD}Health Check Summary${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
local http_icon=$([[ "$HTTP_OK" == true ]] && echo "${GREEN}${NC}" || echo "${RED}${NC}")
local latency_icon=$([[ "$LATENCY_OK" == true ]] && echo "${GREEN}${NC}" || echo "${YELLOW}${NC}")
local ws_icon=$([[ "$WS_OK" == true ]] && echo "${GREEN}${NC}" || echo "${RED}${NC}")
echo -e " ${http_icon} HTTP /api/health (${HTTP_STATUS:-???}, ${HTTP_RESPONSE_TIME_MS:-?}ms, ${HTTP_ATTEMPTS:-?} attempts)"
echo -e " ${latency_icon} Response time (${HTTP_RESPONSE_TIME_MS:-?}ms / ${RESPONSE_THRESHOLD}ms threshold)"
echo -e " ${ws_icon} WebSocket (checked: ${CHECK_WEBSOCKET})"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ "$overall_healthy" == true ]]; then
echo -e " ${GREEN}${BOLD}Overall: HEALTHY${NC}"
else
echo -e " ${RED}${BOLD}Overall: UNHEALTHY${NC}"
fi
echo ""
fi
if [[ "$overall_healthy" == true ]]; then
return 0
else
return 1
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
parse_args "$@"
# Initialize result variables
HTTP_OK=false
HTTP_STATUS=""
HTTP_BODY=""
HTTP_RESPONSE_TIME_MS=0
HTTP_ATTEMPTS=0
LATENCY_OK=false
WS_OK=false
[[ "$JSON_OUTPUT" != true ]] && {
echo ""
echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ Claude Code Agent Monitor Health Check ║${NC}"
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════╝${NC}"
echo ""
info "Target: ${BASE_URL}"
info "Config: retries=${RETRIES}, interval=${INTERVAL}s, threshold=${RESPONSE_THRESHOLD}ms"
echo ""
}
check_http_health
check_response_time
check_websocket
output_results
}
main "$@"
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# rollback.sh Rollback deployments for Claude Code Agent Monitor
#
# Usage:
# ./rollback.sh --env production --method helm --revision 5
# ./rollback.sh --env staging --method kustomize
# ./rollback.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="agent-monitor"
readonly APP_PORT=4820
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
fatal() { err "$@"; exit 1; }
# ── Defaults ────────────────────────────────────────────────────────────────
ENVIRONMENT=""
METHOD=""
REVISION=""
NAMESPACE=""
HELM_RELEASE="${APP_NAME}"
SKIP_HEALTH_CHECK=false
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}Usage:${NC}
$(basename "$0") --env <environment> --method <method> [options]
${BOLD}Required:${NC}
--env, -e Environment: dev, staging, production
--method, -m Method: helm, kustomize
${BOLD}Options:${NC}
--revision, -r Helm revision or rollout history number to roll back to
--namespace, -n Kubernetes namespace (default: agent-monitor-<env>)
--release Helm release name (default: ${APP_NAME})
--skip-health Skip post-rollback health check
--help, -h Show this help message
${BOLD}Examples:${NC}
$(basename "$0") --env production --method helm --revision 5
$(basename "$0") --env staging --method kustomize
$(basename "$0") --env production --method helm # rolls back to previous
EOF
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e) ENVIRONMENT="$2"; shift 2 ;;
--method|-m) METHOD="$2"; shift 2 ;;
--revision|-r) REVISION="$2"; shift 2 ;;
--namespace|-n) NAMESPACE="$2"; shift 2 ;;
--release) HELM_RELEASE="$2"; shift 2 ;;
--skip-health) SKIP_HEALTH_CHECK=true; shift ;;
--help|-h) usage ;;
*) fatal "Unknown option: $1" ;;
esac
done
[[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env"
[[ -z "$METHOD" ]] && fatal "Missing required argument: --method"
[[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}"
}
# ── Show release history ───────────────────────────────────────────────────
show_history() {
info "Release history:"
case "$METHOD" in
helm)
helm history "${HELM_RELEASE}" -n "${NAMESPACE}" --max 10 2>/dev/null || warn "No history found"
;;
kustomize)
kubectl rollout history "deployment/${APP_NAME}" -n "${NAMESPACE}" 2>/dev/null || warn "No history found"
;;
esac
echo ""
}
# ── Confirm rollback ───────────────────────────────────────────────────────
confirm_rollback() {
if [[ "$ENVIRONMENT" == "production" ]]; then
local target_msg="previous revision"
[[ -n "$REVISION" ]] && target_msg="revision ${REVISION}"
warn "Rolling back ${BOLD}PRODUCTION${NC} to ${target_msg}"
read -r -p "$(echo -e "${YELLOW}Type 'yes' to confirm:${NC} ")" confirm
[[ "$confirm" == "yes" ]] || fatal "Rollback cancelled."
fi
}
# ── Helm rollback ───────────────────────────────────────────────────────────
rollback_helm() {
info "Rolling back Helm release '${HELM_RELEASE}' in namespace '${NAMESPACE}'..."
local rollback_args=(rollback "${HELM_RELEASE}")
[[ -n "$REVISION" ]] && rollback_args+=("${REVISION}")
rollback_args+=(-n "${NAMESPACE}" --wait --timeout 300s)
if ! helm "${rollback_args[@]}"; then
fatal "Helm rollback failed! Manual intervention required."
fi
ok "Helm rollback completed"
# Show current status
info "Current release status:"
helm status "${HELM_RELEASE}" -n "${NAMESPACE}" 2>/dev/null || true
}
# ── Kustomize rollback ─────────────────────────────────────────────────────
rollback_kustomize() {
info "Rolling back deployment '${APP_NAME}' in namespace '${NAMESPACE}'..."
local undo_args=(rollout undo "deployment/${APP_NAME}" -n "${NAMESPACE}")
if [[ -n "$REVISION" ]]; then
undo_args+=(--to-revision="${REVISION}")
fi
if ! kubectl "${undo_args[@]}"; then
fatal "Kubectl rollback failed! Manual intervention required."
fi
# Wait for rollout
info "Waiting for rollout to complete..."
if ! kubectl rollout status "deployment/${APP_NAME}" -n "${NAMESPACE}" --timeout=300s; then
fatal "Rollout did not complete in time!"
fi
ok "Kustomize rollback completed"
}
# ── Post-rollback health check ─────────────────────────────────────────────
run_health_check() {
if [[ "$SKIP_HEALTH_CHECK" == true ]]; then
info "Skipping health check"
return
fi
info "Running post-rollback health check..."
# Wait for pods to be ready
if ! kubectl wait --for=condition=ready pod \
-l "app.kubernetes.io/name=${APP_NAME}" \
-n "${NAMESPACE}" --timeout=120s 2>/dev/null; then
fatal "Pods did not become ready after rollback!"
fi
# Use health-check.sh if available
if [[ -x "${SCRIPT_DIR}/health-check.sh" ]]; then
# Port forward for check
local local_port=14820
kubectl port-forward "svc/${APP_NAME}" "${local_port}:${APP_PORT}" -n "${NAMESPACE}" &
local pf_pid=$!
sleep 3
if "${SCRIPT_DIR}/health-check.sh" --url "http://localhost:${local_port}" --retries 10 --interval 3; then
ok "Health check passed after rollback"
else
err "Health check failed after rollback!"
fi
kill "$pf_pid" 2>/dev/null || true
else
ok "Pods are ready"
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
echo ""
echo -e "${BOLD}${YELLOW}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${YELLOW}║ Claude Code Agent Monitor Rollback ║${NC}"
echo -e "${BOLD}${YELLOW}╚══════════════════════════════════════════════════╝${NC}"
echo ""
parse_args "$@"
show_history
confirm_rollback
case "$METHOD" in
helm) rollback_helm ;;
kustomize) rollback_kustomize ;;
*) fatal "Rollback not supported for method: ${METHOD}" ;;
esac
run_health_check
echo ""
ok "${BOLD}Rollback complete!${NC}"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Method:${NC} ${METHOD}"
echo -e " ${BOLD}Revision:${NC} ${REVISION:-previous}"
echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
}
main "$@"
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# teardown.sh Clean removal of Claude Code Agent Monitor infrastructure
#
# Usage:
# ./teardown.sh --env dev --method helm
# ./teardown.sh --env production --method terraform
# ./teardown.sh --help
# ─────────────────────────────────────────────────────────────────────────────
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
readonly DEPLOY_DIR="${PROJECT_ROOT}/deployments"
readonly APP_NAME="agent-monitor"
# ── Colors & logging ───────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $*"; }
info() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${BLUE}${NC} $*"; }
ok() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${GREEN}${NC} $*"; }
warn() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${YELLOW}${NC} $*" >&2; }
err() { echo -e "${CYAN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} ${RED}${NC} $*" >&2; }
fatal() { err "$@"; exit 1; }
# ── Defaults ────────────────────────────────────────────────────────────────
ENVIRONMENT=""
METHOD=""
NAMESPACE=""
HELM_RELEASE="${APP_NAME}"
SKIP_BACKUP=false
FORCE=false
DELETE_NAMESPACE=false
DELETE_PVC=false
# ── Usage ───────────────────────────────────────────────────────────────────
usage() {
cat <<EOF
${BOLD}Usage:${NC}
$(basename "$0") --env <environment> --method <method> [options]
${BOLD}Required:${NC}
--env, -e Environment: dev, staging, production
--method, -m Method: helm, kustomize, terraform
${BOLD}Options:${NC}
--namespace, -n Kubernetes namespace (default: agent-monitor-<env>)
--release Helm release name (default: ${APP_NAME})
--delete-namespace Also delete the Kubernetes namespace
--delete-pvc Also delete PersistentVolumeClaims (data loss!)
--skip-backup Skip data backup before teardown
--force Skip all confirmation prompts
--help, -h Show this help message
${BOLD}Examples:${NC}
$(basename "$0") --env dev --method helm
$(basename "$0") --env staging --method kustomize --delete-namespace
$(basename "$0") --env production --method terraform
EOF
exit 0
}
# ── Argument parsing ────────────────────────────────────────────────────────
parse_args() {
[[ $# -eq 0 ]] && usage
while [[ $# -gt 0 ]]; do
case "$1" in
--env|-e) ENVIRONMENT="$2"; shift 2 ;;
--method|-m) METHOD="$2"; shift 2 ;;
--namespace|-n) NAMESPACE="$2"; shift 2 ;;
--release) HELM_RELEASE="$2"; shift 2 ;;
--delete-namespace) DELETE_NAMESPACE=true; shift ;;
--delete-pvc) DELETE_PVC=true; shift ;;
--skip-backup) SKIP_BACKUP=true; shift ;;
--force) FORCE=true; shift ;;
--help|-h) usage ;;
*) fatal "Unknown option: $1" ;;
esac
done
[[ -z "$ENVIRONMENT" ]] && fatal "Missing required argument: --env"
[[ -z "$METHOD" ]] && fatal "Missing required argument: --method"
[[ -z "$NAMESPACE" ]] && NAMESPACE="agent-monitor-${ENVIRONMENT}"
}
# ── Confirm teardown ───────────────────────────────────────────────────────
confirm_teardown() {
if [[ "$FORCE" == true ]]; then
return
fi
echo ""
echo -e " ${RED}${BOLD}╔══════════════════════════════════════════╗${NC}"
echo -e " ${RED}${BOLD}║ ⚠ TEARDOWN WARNING ⚠ ║${NC}"
echo -e " ${RED}${BOLD}╚══════════════════════════════════════════╝${NC}"
echo ""
echo -e " This will ${RED}${BOLD}DESTROY${NC} the following resources:"
echo -e " • Environment: ${BOLD}${ENVIRONMENT}${NC}"
echo -e " • Method: ${BOLD}${METHOD}${NC}"
echo -e " • Namespace: ${BOLD}${NAMESPACE}${NC}"
[[ "$DELETE_NAMESPACE" == true ]] && echo -e "${RED}Namespace will be deleted${NC}"
[[ "$DELETE_PVC" == true ]] && echo -e "${RED}PVCs will be deleted (DATA LOSS!)${NC}"
echo ""
if [[ "$ENVIRONMENT" == "production" ]]; then
echo -e " ${RED}${BOLD}THIS IS A PRODUCTION ENVIRONMENT!${NC}"
echo ""
read -r -p "$(echo -e "${RED}Type 'destroy ${ENVIRONMENT}' to confirm:${NC} ")" confirm
[[ "$confirm" == "destroy ${ENVIRONMENT}" ]] || fatal "Teardown cancelled."
# Second confirmation for production
echo ""
read -r -p "$(echo -e "${RED}Are you absolutely sure? Type 'YES' in caps:${NC} ")" confirm2
[[ "$confirm2" == "YES" ]] || fatal "Teardown cancelled."
else
read -r -p "$(echo -e "${YELLOW}Type 'yes' to confirm:${NC} ")" confirm
[[ "$confirm" == "yes" ]] || fatal "Teardown cancelled."
fi
}
# ── Pre-teardown backup ────────────────────────────────────────────────────
backup_data() {
if [[ "$SKIP_BACKUP" == true ]]; then
info "Skipping pre-teardown backup (--skip-backup)"
return
fi
info "Creating backup before teardown..."
if [[ -x "${SCRIPT_DIR}/db-backup.sh" ]]; then
local backup_dir="${PROJECT_ROOT}/data/pre-teardown-backups"
"${SCRIPT_DIR}/db-backup.sh" \
--env "${ENVIRONMENT}" \
--output "${backup_dir}" \
--namespace "${NAMESPACE}" \
&& ok "Pre-teardown backup created" \
|| warn "Backup failed continuing with teardown"
else
warn "db-backup.sh not found skipping backup"
fi
}
# ── Show current resources ──────────────────────────────────────────────────
show_resources() {
info "Current resources in namespace '${NAMESPACE}':"
echo ""
kubectl get all -n "${NAMESPACE}" 2>/dev/null || warn "Could not list resources"
if [[ "$METHOD" != "terraform" ]]; then
echo ""
info "PersistentVolumeClaims:"
kubectl get pvc -n "${NAMESPACE}" 2>/dev/null || warn "No PVCs found"
fi
echo ""
}
# ── Helm teardown ──────────────────────────────────────────────────────────
teardown_helm() {
info "Uninstalling Helm release '${HELM_RELEASE}'..."
if helm status "${HELM_RELEASE}" -n "${NAMESPACE}" &>/dev/null; then
helm uninstall "${HELM_RELEASE}" -n "${NAMESPACE}" --wait --timeout 300s \
|| fatal "Helm uninstall failed"
ok "Helm release '${HELM_RELEASE}' uninstalled"
else
warn "Helm release '${HELM_RELEASE}' not found in namespace '${NAMESPACE}'"
fi
# Also try uninstalling blue/green releases
for color in blue green; do
if helm status "${HELM_RELEASE}-${color}" -n "${NAMESPACE}" &>/dev/null; then
info "Uninstalling ${color} slot release..."
helm uninstall "${HELM_RELEASE}-${color}" -n "${NAMESPACE}" --wait --timeout 300s \
&& ok "Release '${HELM_RELEASE}-${color}' uninstalled" \
|| warn "Failed to uninstall ${color} release"
fi
done
}
# ── Kustomize teardown ─────────────────────────────────────────────────────
teardown_kustomize() {
local overlay_dir="${DEPLOY_DIR}/kubernetes/overlays/${ENVIRONMENT}"
if [[ -d "$overlay_dir" ]]; then
info "Deleting Kustomize resources..."
kubectl delete -k "${overlay_dir}" --ignore-not-found=true --wait=true --timeout=300s \
&& ok "Kustomize resources deleted" \
|| warn "Some resources may not have been deleted"
else
warn "Kustomize overlay not found at ${overlay_dir}"
info "Deleting resources by label..."
kubectl delete all -l "app.kubernetes.io/name=${APP_NAME}" -n "${NAMESPACE}" --wait=true \
|| warn "Could not delete resources by label"
fi
}
# ── Terraform teardown ─────────────────────────────────────────────────────
teardown_terraform() {
local tf_dir="${DEPLOY_DIR}/terraform"
local env_vars_file="${tf_dir}/environments/${ENVIRONMENT}/terraform.tfvars"
info "Destroying Terraform-managed infrastructure..."
pushd "${tf_dir}" > /dev/null
terraform init -input=false
local destroy_args=(-input=false -auto-approve)
if [[ -f "$env_vars_file" ]]; then
destroy_args+=(-var-file="$env_vars_file")
fi
# Need to provide required variables that may not have defaults
destroy_args+=(-var "environment=${ENVIRONMENT}")
if ! terraform destroy "${destroy_args[@]}"; then
popd > /dev/null
fatal "Terraform destroy failed! Review state manually."
fi
popd > /dev/null
ok "Terraform infrastructure destroyed"
}
# ── Cleanup PVCs ────────────────────────────────────────────────────────────
cleanup_pvcs() {
if [[ "$DELETE_PVC" != true ]]; then
local pvc_count
pvc_count=$(kubectl get pvc -n "${NAMESPACE}" --no-headers 2>/dev/null | wc -l | tr -d ' ')
if [[ "$pvc_count" -gt 0 ]]; then
warn "PersistentVolumeClaims still exist. Use --delete-pvc to remove them."
kubectl get pvc -n "${NAMESPACE}" 2>/dev/null
fi
return
fi
info "Deleting PersistentVolumeClaims..."
kubectl delete pvc --all -n "${NAMESPACE}" --wait=true \
&& ok "PVCs deleted" \
|| warn "Some PVCs could not be deleted"
}
# ── Cleanup namespace ──────────────────────────────────────────────────────
cleanup_namespace() {
if [[ "$DELETE_NAMESPACE" != true ]]; then
return
fi
if [[ "$ENVIRONMENT" == "production" ]]; then
warn "Refusing to delete production namespace automatically."
warn "Delete manually: kubectl delete namespace ${NAMESPACE}"
return
fi
info "Deleting namespace '${NAMESPACE}'..."
kubectl delete namespace "${NAMESPACE}" --ignore-not-found=true --wait=true --timeout=120s \
&& ok "Namespace '${NAMESPACE}' deleted" \
|| warn "Namespace deletion may be stuck. Check: kubectl get namespace ${NAMESPACE}"
}
# ── Verify teardown ────────────────────────────────────────────────────────
verify_teardown() {
info "Verifying teardown..."
if [[ "$METHOD" == "terraform" ]]; then
ok "Terraform state should reflect no resources"
return
fi
local remaining
remaining=$(kubectl get all -n "${NAMESPACE}" --no-headers 2>/dev/null | wc -l | tr -d ' ')
if [[ "$remaining" -eq 0 ]]; then
ok "No resources remaining in namespace '${NAMESPACE}'"
else
warn "${remaining} resources still exist in namespace '${NAMESPACE}':"
kubectl get all -n "${NAMESPACE}" 2>/dev/null
fi
}
# ── Main ────────────────────────────────────────────────────────────────────
main() {
echo ""
echo -e "${BOLD}${RED}╔══════════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${RED}║ Claude Code Agent Monitor Teardown ║${NC}"
echo -e "${BOLD}${RED}╚══════════════════════════════════════════════════╝${NC}"
echo ""
parse_args "$@"
info "Teardown configuration:"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Method:${NC} ${METHOD}"
echo -e " ${BOLD}Namespace:${NC} ${NAMESPACE}"
echo ""
if [[ "$METHOD" != "terraform" ]]; then
show_resources
fi
confirm_teardown
backup_data
case "$METHOD" in
helm) teardown_helm ;;
kustomize) teardown_kustomize ;;
terraform) teardown_terraform ;;
*) fatal "Invalid method: ${METHOD}" ;;
esac
if [[ "$METHOD" != "terraform" ]]; then
cleanup_pvcs
cleanup_namespace
verify_teardown
fi
echo ""
ok "${BOLD}Teardown complete!${NC}"
echo -e " ${BOLD}Environment:${NC} ${ENVIRONMENT}"
echo -e " ${BOLD}Method:${NC} ${METHOD}"
echo -e " ${BOLD}Timestamp:${NC} $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
}
main "$@"