chore: add install scripts, doctor command, and AGENTS.md
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

- Add one-line installer (scripts/install.sh) with platform detection
- Add doctor command (scripts/commands/doctor.sh) for environment diagnostics
- Add shared libraries: dependencies, docker, platform, validation
- Update README with quick-start installer instructions
- Add AGENTS.md with codebase patterns for AI agent context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 11:04:36 -06:00
parent 0ca3945061
commit ab52827d9c
8 changed files with 4290 additions and 4 deletions

747
scripts/lib/validation.sh Normal file
View File

@@ -0,0 +1,747 @@
#!/bin/bash
# Validation functions for Mosaic Stack installer
# Post-install validation and health checks
# shellcheck source=lib/platform.sh
source "${BASH_SOURCE[0]%/*}/platform.sh"
# ============================================================================
# Validation Result Codes
# ============================================================================
readonly CHECK_PASS=0
readonly CHECK_WARN=1
readonly CHECK_FAIL=2
# ============================================================================
# Port Validation
# ============================================================================
# Check if a port is in use
check_port_in_use() {
local port="$1"
# Try ss first (most common on modern Linux)
if command -v ss &>/dev/null; then
ss -tuln 2>/dev/null | grep -q ":${port} "
return $?
fi
# Fall back to netstat
if command -v netstat &>/dev/null; then
netstat -tuln 2>/dev/null | grep -q ":${port} "
return $?
fi
# Fall back to lsof
if command -v lsof &>/dev/null; then
lsof -i ":$port" &>/dev/null
return $?
fi
# Can't check, assume port is free
return 1
}
# Get process using a port
get_process_on_port() {
local port="$1"
if command -v lsof &>/dev/null; then
lsof -i ":$port" -t 2>/dev/null | head -1
elif command -v ss &>/dev/null; then
ss -tulnp 2>/dev/null | grep ":${port} " | grep -oP 'pid=\K[0-9]+' | head -1
else
echo "unknown"
fi
}
# Validate port number
validate_port() {
local port="$1"
if [[ "$port" =~ ^[0-9]+$ ]] && [[ "$port" -ge 1 ]] && [[ "$port" -le 65535 ]]; then
return 0
fi
return 1
}
# Check all configured ports
check_all_ports() {
local env_file="${1:-.env}"
local errors=0
local warnings=0
# Load env file if it exists
if [[ -f "$env_file" ]]; then
set -a
# shellcheck source=/dev/null
source "$env_file" 2>/dev/null || true
set +a
fi
# Default ports
declare -A default_ports=(
[WEB_PORT]=3000
[API_PORT]=3001
[POSTGRES_PORT]=5432
[VALKEY_PORT]=6379
[AUTHENTIK_PORT_HTTP]=9000
[AUTHENTIK_PORT_HTTPS]=9443
[OLLAMA_PORT]=11434
[TRAEFIK_HTTP_PORT]=80
[TRAEFIK_HTTPS_PORT]=443
[TRAEFIK_DASHBOARD_PORT]=8080
)
echo -e "${BOLD}Checking ports...${NC}"
echo ""
for port_var in "${!default_ports[@]}"; do
local port="${!port_var:-${default_ports[$port_var]}}"
if check_port_in_use "$port"; then
local process
process=$(get_process_on_port "$port")
echo -e "${WARN}${NC} $port_var: Port $port is in use (PID: $process)"
((warnings++))
else
echo -e "${SUCCESS}${NC} $port_var: Port $port available"
fi
done
echo ""
if [[ $warnings -gt 0 ]]; then
return $CHECK_WARN
fi
return $CHECK_PASS
}
# ============================================================================
# Environment Validation
# ============================================================================
# Required environment variables
REQUIRED_ENV_VARS=(
"DATABASE_URL"
"JWT_SECRET"
"BETTER_AUTH_SECRET"
"ENCRYPTION_KEY"
)
# Optional but recommended environment variables
RECOMMENDED_ENV_VARS=(
"POSTGRES_PASSWORD"
"VALKEY_URL"
"NEXT_PUBLIC_API_URL"
"NEXT_PUBLIC_APP_URL"
)
# Check if env file exists
check_env_file() {
local env_file="${1:-.env}"
if [[ -f "$env_file" ]]; then
echo -e "${SUCCESS}${NC} .env file exists"
return 0
else
echo -e "${ERROR}${NC} .env file not found"
return $CHECK_FAIL
fi
}
# Check required environment variables
check_required_env() {
local env_file="${1:-.env}"
local errors=0
echo -e "${BOLD}Checking required environment variables...${NC}"
echo ""
# Load env file
if [[ -f "$env_file" ]]; then
set -a
# shellcheck source=/dev/null
source "$env_file" 2>/dev/null || true
set +a
fi
for var in "${REQUIRED_ENV_VARS[@]}"; do
local value="${!var:-}"
if [[ -z "$value" ]]; then
echo -e "${ERROR}${NC} $var: Not set"
((errors++))
elif is_placeholder "$value"; then
echo -e "${WARN}${NC} $var: Contains placeholder value"
((errors++))
else
echo -e "${SUCCESS}${NC} $var: Set"
fi
done
echo ""
if [[ $errors -gt 0 ]]; then
return $CHECK_FAIL
fi
return $CHECK_PASS
}
# Check recommended environment variables
check_recommended_env() {
local env_file="${1:-.env}"
local warnings=0
echo -e "${BOLD}Checking recommended environment variables...${NC}"
echo ""
# Load env file
if [[ -f "$env_file" ]]; then
set -a
# shellcheck source=/dev/null
source "$env_file" 2>/dev/null || true
set +a
fi
for var in "${RECOMMENDED_ENV_VARS[@]}"; do
local value="${!var:-}"
if [[ -z "$value" ]]; then
echo -e "${WARN}${NC} $var: Not set (using default)"
((warnings++))
elif is_placeholder "$value"; then
echo -e "${WARN}${NC} $var: Contains placeholder value"
((warnings++))
else
echo -e "${SUCCESS}${NC} $var: Set"
fi
done
echo ""
if [[ $warnings -gt 0 ]]; then
return $CHECK_WARN
fi
return $CHECK_PASS
}
# Check if a value is a placeholder
is_placeholder() {
local value="$1"
if [[ -z "$value" ]]; then
return 0
fi
# Common placeholder patterns
case "$value" in
*"REPLACE_WITH"*|*"CHANGE_ME"*|*"changeme"*|*"your-"*|*"example"*|*"placeholder"*|*"TODO"*|*"FIXME"*)
return 0
;;
*"xxx"*|*"<"*">"*|*"\${"*|*"$${"*)
return 0
;;
esac
return 1
}
# ============================================================================
# Secret Validation
# ============================================================================
# Minimum secret lengths
declare -A MIN_SECRET_LENGTHS=(
[JWT_SECRET]=32
[BETTER_AUTH_SECRET]=32
[ENCRYPTION_KEY]=64
[AUTHENTIK_SECRET_KEY]=50
[COORDINATOR_API_KEY]=32
[ORCHESTRATOR_API_KEY]=32
)
# Check secret strength
check_secrets() {
local env_file="${1:-.env}"
local errors=0
local warnings=0
echo -e "${BOLD}Checking secret strength...${NC}"
echo ""
# Load env file
if [[ -f "$env_file" ]]; then
set -a
# shellcheck source=/dev/null
source "$env_file" 2>/dev/null || true
set +a
fi
for secret_var in "${!MIN_SECRET_LENGTHS[@]}"; do
local value="${!secret_var:-}"
local min_len="${MIN_SECRET_LENGTHS[$secret_var]}"
if [[ -z "$value" ]]; then
echo -e "${WARN}${NC} $secret_var: Not set"
((warnings++))
elif is_placeholder "$value"; then
echo -e "${ERROR}${NC} $secret_var: Contains placeholder (MUST change)"
((errors++))
elif [[ ${#value} -lt $min_len ]]; then
echo -e "${WARN}${NC} $secret_var: Too short (${#value} chars, minimum $min_len)"
((warnings++))
else
echo -e "${SUCCESS}${NC} $secret_var: Strong (${#value} chars)"
fi
done
echo ""
if [[ $errors -gt 0 ]]; then
return $CHECK_FAIL
fi
if [[ $warnings -gt 0 ]]; then
return $CHECK_WARN
fi
return $CHECK_PASS
}
# ============================================================================
# Docker Validation
# ============================================================================
# Check Docker containers are running
check_docker_containers() {
local compose_file="${1:-docker-compose.yml}"
local errors=0
echo -e "${BOLD}Checking Docker containers...${NC}"
echo ""
# Expected container names
local containers=("mosaic-postgres" "mosaic-valkey" "mosaic-api" "mosaic-web")
for container in "${containers[@]}"; do
local status
status=$(docker inspect --format='{{.State.Status}}' "$container" 2>/dev/null || echo "not_found")
case "$status" in
running)
echo -e "${SUCCESS}${NC} $container: Running"
;;
exited)
echo -e "${ERROR}${NC} $container: Exited"
((errors++))
;;
not_found)
# Container might not be in current profile
echo -e "${MUTED}${NC} $container: Not found (may not be in profile)"
;;
*)
echo -e "${WARN}${NC} $container: $status"
((errors++))
;;
esac
done
echo ""
if [[ $errors -gt 0 ]]; then
return $CHECK_FAIL
fi
return $CHECK_PASS
}
# Check container health
check_container_health() {
local errors=0
echo -e "${BOLD}Checking container health...${NC}"
echo ""
# Get all mosaic containers
local containers
containers=$(docker ps --filter "name=mosaic-" --format "{{.Names}}" 2>/dev/null)
for container in $containers; do
local health
health=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || echo "no_healthcheck")
case "$health" in
healthy)
echo -e "${SUCCESS}${NC} $container: Healthy"
;;
unhealthy)
echo -e "${ERROR}${NC} $container: Unhealthy"
((errors++))
;;
starting)
echo -e "${WARN}${NC} $container: Starting..."
;;
no_healthcheck)
echo -e "${INFO}${NC} $container: No health check"
;;
*)
echo -e "${WARN}${NC} $container: $health"
;;
esac
done
echo ""
if [[ $errors -gt 0 ]]; then
return $CHECK_FAIL
fi
return $CHECK_PASS
}
# ============================================================================
# Service Connectivity
# ============================================================================
# Check if a URL responds
check_url_responds() {
local url="$1"
local expected_status="${2:-200}"
local timeout="${3:-10}"
if command -v curl &>/dev/null; then
local status
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time "$timeout" "$url" 2>/dev/null)
if [[ "$status" == "$expected_status" ]]; then
return 0
fi
fi
return 1
}
# Check API health endpoint
check_api_health() {
local api_url="${1:-http://localhost:3001}"
echo -e "${BOLD}Checking API health...${NC}"
echo ""
if check_url_responds "${api_url}/health" 200 10; then
echo -e "${SUCCESS}${NC} API health check passed"
return $CHECK_PASS
else
echo -e "${ERROR}${NC} API health check failed"
return $CHECK_FAIL
fi
}
# Check Web frontend
check_web_health() {
local web_url="${1:-http://localhost:3000}"
echo -e "${BOLD}Checking Web frontend...${NC}"
echo ""
if check_url_responds "$web_url" 200 10; then
echo -e "${SUCCESS}${NC} Web frontend responding"
return $CHECK_PASS
else
echo -e "${WARN}${NC} Web frontend not responding (may still be starting)"
return $CHECK_WARN
fi
}
# Check database connectivity
check_database_connection() {
local host="${1:-localhost}"
local port="${2:-5432}"
local user="${3:-mosaic}"
local database="${4:-mosaic}"
echo -e "${BOLD}Checking database connection...${NC}"
echo ""
# Try via Docker if postgres container exists
if docker exec mosaic-postgres pg_isready -U "$user" -d "$database" &>/dev/null; then
echo -e "${SUCCESS}${NC} Database connection successful"
return $CHECK_PASS
fi
# Try via psql if available
if command -v psql &>/dev/null; then
if PGPASSWORD="${POSTGRES_PASSWORD:-}" psql -h "$host" -p "$port" -U "$user" -d "$database" -c "SELECT 1" &>/dev/null; then
echo -e "${SUCCESS}${NC} Database connection successful"
return $CHECK_PASS
fi
fi
# Try via TCP
if command -v nc &>/dev/null; then
if nc -z "$host" "$port" 2>/dev/null; then
echo -e "${WARN}${NC} Database port open but could not verify connection"
return $CHECK_WARN
fi
fi
echo -e "${ERROR}${NC} Database connection failed"
return $CHECK_FAIL
}
# Check Valkey/Redis connectivity
check_valkey_connection() {
local host="${1:-localhost}"
local port="${2:-6379}"
echo -e "${BOLD}Checking Valkey/Redis connection...${NC}"
echo ""
# Try via Docker if valkey container exists
if docker exec mosaic-valkey valkey-cli ping 2>/dev/null | grep -q PONG; then
echo -e "${SUCCESS}${NC} Valkey connection successful"
return $CHECK_PASS
fi
# Try via redis-cli if available
if command -v redis-cli &>/dev/null; then
if redis-cli -h "$host" -p "$port" ping 2>/dev/null | grep -q PONG; then
echo -e "${SUCCESS}${NC} Valkey/Redis connection successful"
return $CHECK_PASS
fi
fi
# Try via TCP
if command -v nc &>/dev/null; then
if nc -z "$host" "$port" 2>/dev/null; then
echo -e "${WARN}${NC} Valkey port open but could not verify connection"
return $CHECK_WARN
fi
fi
echo -e "${ERROR}${NC} Valkey/Redis connection failed"
return $CHECK_FAIL
}
# ============================================================================
# System Requirements
# ============================================================================
# Check minimum system requirements
check_system_requirements() {
local min_ram="${1:-2048}"
local min_disk="${2:-10}"
local errors=0
local warnings=0
echo -e "${BOLD}Checking system requirements...${NC}"
echo ""
# RAM check
local ram
ram=$(get_total_ram)
if [[ "$ram" -lt "$min_ram" ]]; then
echo -e "${ERROR}${NC} RAM: ${ram}MB (minimum: ${min_ram}MB)"
((errors++))
else
echo -e "${SUCCESS}${NC} RAM: ${ram}MB"
fi
# Disk check
local disk
disk=$(get_available_disk "$HOME")
if [[ "$disk" -lt "$min_disk" ]]; then
echo -e "${WARN}${NC} Disk: ${disk}GB available (recommended: ${min_disk}GB+)"
((warnings++))
else
echo -e "${SUCCESS}${NC} Disk: ${disk}GB available"
fi
# Docker disk (if using Docker)
if command -v docker &>/dev/null && docker info &>/dev/null; then
local docker_disk
docker_disk=$(docker system df --format "{{.Total}}" 2>/dev/null | head -1 || echo "unknown")
echo -e "${INFO}${NC} Docker storage: $docker_disk"
fi
echo ""
if [[ $errors -gt 0 ]]; then
return $CHECK_FAIL
fi
if [[ $warnings -gt 0 ]]; then
return $CHECK_WARN
fi
return $CHECK_PASS
}
# ============================================================================
# File Permissions
# ============================================================================
# Check .env file permissions
check_env_permissions() {
local env_file="${1:-.env}"
echo -e "${BOLD}Checking file permissions...${NC}"
echo ""
if [[ ! -f "$env_file" ]]; then
echo -e "${WARN}${NC} .env file not found"
return $CHECK_WARN
fi
local perms
perms=$(stat -c "%a" "$env_file" 2>/dev/null || stat -f "%OLp" "$env_file" 2>/dev/null)
# Check if world-readable
if [[ "$perms" =~ [0-7][0-7][4-7]$ ]]; then
echo -e "${WARN}${NC} .env is world-readable (permissions: $perms)"
echo -e " ${INFO}Fix: chmod 600 $env_file${NC}"
return $CHECK_WARN
fi
echo -e "${SUCCESS}${NC} .env permissions: $perms"
return $CHECK_PASS
}
# ============================================================================
# Comprehensive Doctor Check
# ============================================================================
# Run all checks and report results
run_doctor() {
local env_file="${1:-.env}"
local compose_file="${2:-docker-compose.yml}"
local mode="${3:-docker}"
local errors=0
local warnings=0
echo ""
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD} Mosaic Stack Doctor${NC}"
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
echo ""
# System requirements
run_doctor_check "System Requirements" check_system_requirements 2048 10
collect_result $?
# Environment file
run_doctor_check "Environment File" check_env_file "$env_file"
collect_result $?
# Required environment variables
run_doctor_check "Required Variables" check_required_env "$env_file"
collect_result $?
# Secret strength
run_doctor_check "Secret Strength" check_secrets "$env_file"
collect_result $?
# File permissions
run_doctor_check "File Permissions" check_env_permissions "$env_file"
collect_result $?
if [[ "$mode" == "docker" ]]; then
# Docker containers
run_doctor_check "Docker Containers" check_docker_containers "$compose_file"
collect_result $?
# Container health
run_doctor_check "Container Health" check_container_health
collect_result $?
# Database connection
run_doctor_check "Database" check_database_connection
collect_result $?
# Valkey connection
run_doctor_check "Cache (Valkey)" check_valkey_connection
collect_result $?
# API health
run_doctor_check "API" check_api_health
collect_result $?
# Web frontend
run_doctor_check "Web Frontend" check_web_health
collect_result $?
fi
echo ""
echo -e "${BOLD}════════════════════════════════════════════════════════════${NC}"
# Summary
if [[ $errors -gt 0 ]]; then
echo -e "${ERROR}${NC} ${BOLD}Failed${NC}: $errors errors, $warnings warnings"
echo ""
echo "Fix the errors above and run doctor again."
return $CHECK_FAIL
elif [[ $warnings -gt 0 ]]; then
echo -e "${WARN}${NC} ${BOLD}Warnings${NC}: $warnings warnings"
echo ""
echo "System is operational but some optimizations are recommended."
return $CHECK_WARN
else
echo -e "${SUCCESS}${NC} ${BOLD}All checks passed${NC}"
echo ""
echo "Mosaic Stack is healthy and ready to use."
return $CHECK_PASS
fi
}
# Helper to run a check and print result
run_doctor_check() {
local name="$1"
shift
echo -e "${BOLD}Checking: $name${NC}"
echo ""
"$@"
return $?
}
# Helper to collect check results
collect_result() {
local result=$1
case $result in
$CHECK_PASS) ;;
$CHECK_WARN) ((warnings++)) ;;
$CHECK_FAIL) ((errors++)) ;;
esac
}
# ============================================================================
# Quick Health Check
# ============================================================================
# Quick check for CI/CD or scripts
quick_health_check() {
local api_url="${1:-http://localhost:3001}"
check_url_responds "${api_url}/health" 200 5
}
# Wait for healthy state
wait_for_healthy() {
local timeout="${1:-120}"
local interval="${2:-5}"
echo -e "${INFO}${NC} Waiting for healthy state..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
if quick_health_check &>/dev/null; then
echo -e "${SUCCESS}${NC} System is healthy"
return 0
fi
sleep "$interval"
((elapsed += interval))
echo -n "."
done
echo ""
echo -e "${ERROR}${NC} Timeout waiting for healthy state"
return 1
}