Files
Claude-Code-Monitor/deployments/scripts/deploy.sh
T
nntrivi2001 57dc91585d 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.
2026-07-30 14:39:03 +07:00

573 lines
21 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 "$@"