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

908
scripts/lib/dependencies.sh Normal file
View File

@@ -0,0 +1,908 @@
#!/bin/bash
# Dependency management functions for Mosaic Stack installer
# Handles installation and verification of all required dependencies
# shellcheck source=lib/platform.sh
source "${BASH_SOURCE[0]%/*}/platform.sh"
# ============================================================================
# Dependency Version Requirements
# ============================================================================
MIN_NODE_VERSION=22
MIN_DOCKER_VERSION=24
MIN_PNPM_VERSION=10
MIN_POSTGRES_VERSION=17
# ============================================================================
# Generic Command Checking
# ============================================================================
# Check if a command exists
check_command() {
command -v "$1" &>/dev/null
}
# Get version of a command (generic)
get_command_version() {
local cmd="$1"
local flag="${2:---version}"
"$cmd" "$flag" 2>/dev/null | head -1
}
# Extract major version number from version string
extract_major_version() {
local version="$1"
echo "$version" | grep -oE '[0-9]+' | head -1
}
# ============================================================================
# Git
# ============================================================================
check_git() {
if check_command git; then
local version
version=$(git --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
echo -e "${SUCCESS}${NC} Git: ${INFO}$version${NC}"
return 0
fi
echo -e "${WARN}${NC} Git not found"
return 1
}
install_git() {
local os pkg
os=$(detect_os)
pkg=$(detect_package_manager "$os")
echo -e "${WARN}${NC} Installing Git..."
case "$pkg" in
brew)
brew install git
;;
apt)
maybe_sudo apt-get update -y
maybe_sudo apt-get install -y git
;;
pacman)
maybe_sudo pacman -Sy --noconfirm git
;;
dnf)
maybe_sudo dnf install -y git
;;
yum)
maybe_sudo yum install -y git
;;
*)
echo -e "${ERROR}Error: Unknown package manager for Git installation${NC}"
return 1
;;
esac
echo -e "${SUCCESS}${NC} Git installed"
}
ensure_git() {
if ! check_git; then
install_git
fi
}
# ============================================================================
# Homebrew (macOS)
# ============================================================================
check_homebrew() {
if check_command brew; then
local prefix
prefix=$(brew --prefix 2>/dev/null)
echo -e "${SUCCESS}${NC} Homebrew: ${INFO}$prefix${NC}"
return 0
fi
return 1
}
install_homebrew() {
echo -e "${WARN}${NC} Installing Homebrew..."
# Download and run the Homebrew installer
local tmp
tmp=$(create_temp_file)
if ! download_file "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh" "$tmp"; then
echo -e "${ERROR}Error: Failed to download Homebrew installer${NC}"
return 1
fi
NONINTERACTIVE=1 /bin/bash "$tmp"
local ret=$?
rm -f "$tmp"
if [[ $ret -ne 0 ]]; then
echo -e "${ERROR}Error: Homebrew installation failed${NC}"
return 1
fi
# Add Homebrew to PATH for this session
if [[ -f "/opt/homebrew/bin/brew" ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [[ -f "/usr/local/bin/brew" ]]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
echo -e "${SUCCESS}${NC} Homebrew installed"
}
ensure_homebrew() {
local os
os=$(detect_os)
if [[ "$os" != "macos" ]]; then
return 0
fi
if ! check_homebrew; then
install_homebrew
fi
}
# ============================================================================
# Node.js
# ============================================================================
check_node() {
local min_version="${1:-$MIN_NODE_VERSION}"
if ! check_command node; then
echo -e "${WARN}${NC} Node.js not found"
return 1
fi
local version
version=$(node --version 2>/dev/null | sed 's/v//')
local major
major=$(extract_major_version "$version")
if [[ "$major" -ge "$min_version" ]]; then
echo -e "${SUCCESS}${NC} Node.js: ${INFO}v$version${NC}"
return 0
else
echo -e "${WARN}${NC} Node.js v$version found, but v${min_version}+ required"
return 1
fi
}
install_node_macos() {
echo -e "${WARN}${NC} Installing Node.js via Homebrew..."
ensure_homebrew
# Install node@22
brew install node@22
# Link it
brew link node@22 --overwrite --force 2>/dev/null || true
# Ensure it's on PATH
local prefix
prefix=$(brew --prefix node@22 2>/dev/null)
if [[ -n "$prefix" && -d "${prefix}/bin" ]]; then
export PATH="${prefix}/bin:$PATH"
fi
echo -e "${SUCCESS}${NC} Node.js installed"
}
install_node_debian() {
echo -e "${WARN}${NC} Installing Node.js via NodeSource..."
require_sudo
local tmp
tmp=$(create_temp_file)
# Download NodeSource setup script for Node.js 22
if ! download_file "https://deb.nodesource.com/setup_22.x" "$tmp"; then
echo -e "${ERROR}Error: Failed to download NodeSource setup script${NC}"
return 1
fi
maybe_sudo -E bash "$tmp"
maybe_sudo apt-get install -y nodejs
rm -f "$tmp"
echo -e "${SUCCESS}${NC} Node.js installed"
}
install_node_fedora() {
echo -e "${WARN}${NC} Installing Node.js via NodeSource..."
require_sudo
local tmp
tmp=$(create_temp_file)
if ! download_file "https://rpm.nodesource.com/setup_22.x" "$tmp"; then
echo -e "${ERROR}Error: Failed to download NodeSource setup script${NC}"
return 1
fi
maybe_sudo bash "$tmp"
if command -v dnf &>/dev/null; then
maybe_sudo dnf install -y nodejs
else
maybe_sudo yum install -y nodejs
fi
rm -f "$tmp"
echo -e "${SUCCESS}${NC} Node.js installed"
}
install_node_arch() {
echo -e "${WARN}${NC} Installing Node.js via pacman..."
maybe_sudo pacman -Sy --noconfirm nodejs npm
echo -e "${SUCCESS}${NC} Node.js installed"
}
install_node() {
local os
os=$(detect_os)
case "$os" in
macos)
install_node_macos
;;
debian)
install_node_debian
;;
arch)
install_node_arch
;;
fedora)
install_node_fedora
;;
*)
echo -e "${ERROR}Error: Unsupported OS for Node.js installation: $os${NC}"
echo "Please install Node.js ${MIN_NODE_VERSION}+ manually: https://nodejs.org"
return 1
;;
esac
}
ensure_node() {
if ! check_node; then
install_node
fi
}
# ============================================================================
# pnpm
# ============================================================================
check_pnpm() {
if check_command pnpm; then
local version
version=$(pnpm --version 2>/dev/null)
local major
major=$(extract_major_version "$version")
if [[ "$major" -ge "$MIN_PNPM_VERSION" ]]; then
echo -e "${SUCCESS}${NC} pnpm: ${INFO}v$version${NC}"
return 0
else
echo -e "${WARN}${NC} pnpm v$version found, but v${MIN_PNPM_VERSION}+ recommended"
return 1
fi
fi
echo -e "${WARN}${NC} pnpm not found"
return 1
}
install_pnpm() {
# Try corepack first (comes with Node.js 22+)
if check_command corepack; then
echo -e "${WARN}${NC} Installing pnpm via Corepack..."
corepack enable 2>/dev/null || true
corepack prepare pnpm@${MIN_PNPM_VERSION} --activate
echo -e "${SUCCESS}${NC} pnpm installed via Corepack"
return 0
fi
# Fall back to npm
echo -e "${WARN}${NC} Installing pnpm via npm..."
# Fix npm permissions on Linux first
local os
os=$(detect_os)
if [[ "$os" != "macos" ]]; then
fix_npm_permissions
fi
npm install -g pnpm@${MIN_PNPM_VERSION}
echo -e "${SUCCESS}${NC} pnpm installed via npm"
}
ensure_pnpm() {
if ! check_pnpm; then
install_pnpm
fi
}
# ============================================================================
# npm Permissions (Linux)
# ============================================================================
fix_npm_permissions() {
local os
os=$(detect_os)
if [[ "$os" == "macos" ]]; then
return 0
fi
local npm_prefix
npm_prefix=$(npm config get prefix 2>/dev/null || true)
if [[ -z "$npm_prefix" ]]; then
return 0
fi
# Check if we can write to the npm prefix
if [[ -w "$npm_prefix" || -w "${npm_prefix}/lib" ]]; then
return 0
fi
echo -e "${WARN}${NC} Configuring npm for user-local installs..."
# Create user-local npm directory
mkdir -p "$HOME/.npm-global"
# Configure npm to use it
npm config set prefix "$HOME/.npm-global"
# Add to shell config
local rc
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
if [[ -f "$rc" ]]; then
# shellcheck disable=SC2016
if ! grep -q ".npm-global" "$rc" 2>/dev/null; then
echo "" >> "$rc"
echo "# Added by Mosaic Stack installer" >> "$rc"
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$rc"
fi
fi
done
# Update PATH for current session
export PATH="$HOME/.npm-global/bin:$PATH"
echo -e "${SUCCESS}${NC} npm configured for user installs"
}
# Get npm global bin directory
npm_global_bin_dir() {
local prefix
prefix=$(npm prefix -g 2>/dev/null || true)
if [[ -n "$prefix" && "$prefix" == /* ]]; then
echo "${prefix%/}/bin"
return 0
fi
prefix=$(npm config get prefix 2>/dev/null || true)
if [[ -n "$prefix" && "$prefix" != "undefined" && "$prefix" != "null" && "$prefix" == /* ]]; then
echo "${prefix%/}/bin"
return 0
fi
return 1
}
# ============================================================================
# Docker
# ============================================================================
check_docker() {
if ! check_command docker; then
echo -e "${WARN}${NC} Docker not found"
return 1
fi
# Check if daemon is accessible
if ! docker info &>/dev/null; then
local error_msg
error_msg=$(docker info 2>&1)
if [[ "$error_msg" =~ "permission denied" ]]; then
echo -e "${WARN}${NC} Docker installed but permission denied"
echo -e " ${INFO}Fix: sudo usermod -aG docker \$USER${NC}"
echo -e " Then log out and back in"
return 2
elif [[ "$error_msg" =~ "Cannot connect to the Docker daemon" ]]; then
echo -e "${WARN}${NC} Docker installed but daemon not running"
return 3
else
echo -e "${WARN}${NC} Docker installed but not accessible"
return 4
fi
fi
local version
version=$(docker --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
local major
major=$(extract_major_version "$version")
if [[ "$major" -ge "$MIN_DOCKER_VERSION" ]]; then
echo -e "${SUCCESS}${NC} Docker: ${INFO}$version${NC}"
return 0
else
echo -e "${WARN}${NC} Docker v$version found, but v${MIN_DOCKER_VERSION}+ recommended"
return 1
fi
}
check_docker_compose() {
# Check for docker compose plugin first
if docker compose version &>/dev/null; then
local version
version=$(docker compose version --short 2>/dev/null || docker compose version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
echo -e "${SUCCESS}${NC} Docker Compose: ${INFO}$version (plugin)${NC}"
return 0
fi
# Check for standalone docker-compose
if check_command docker-compose; then
local version
version=$(docker-compose --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
echo -e "${SUCCESS}${NC} Docker Compose: ${INFO}$version (standalone)${NC}"
return 0
fi
echo -e "${WARN}${NC} Docker Compose not found"
return 1
}
install_docker_macos() {
echo -e "${WARN}${NC} Installing Docker Desktop for macOS..."
ensure_homebrew
brew install --cask docker
echo -e "${SUCCESS}${NC} Docker Desktop installed"
echo -e "${INFO}i${NC} Please open Docker Desktop to complete setup"
}
install_docker_debian() {
echo -e "${WARN}${NC} Installing Docker via docker.com apt repo..."
require_sudo
# Install dependencies
maybe_sudo apt-get update
maybe_sudo apt-get install -y ca-certificates curl gnupg
# Add Docker's official GPG key
local keyring="/usr/share/keyrings/docker-archive-keyring.gpg"
maybe_sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | maybe_sudo gpg --dearmor -o "$keyring"
maybe_sudo chmod a+r "$keyring"
# Add Docker repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=$keyring] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | \
maybe_sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
maybe_sudo apt-get update
maybe_sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Start Docker
maybe_sudo systemctl enable --now docker
# Add user to docker group
maybe_sudo usermod -aG docker "$USER"
echo -e "${SUCCESS}${NC} Docker installed"
echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership"
}
install_docker_ubuntu() {
echo -e "${WARN}${NC} Installing Docker via docker.com apt repo..."
require_sudo
# Install dependencies
maybe_sudo apt-get update
maybe_sudo apt-get install -y ca-certificates curl gnupg
# Add Docker's official GPG key
local keyring="/usr/share/keyrings/docker-archive-keyring.gpg"
maybe_sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | maybe_sudo gpg --dearmor -o "$keyring"
maybe_sudo chmod a+r "$keyring"
# Add Docker repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=$keyring] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
maybe_sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker
maybe_sudo apt-get update
maybe_sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Start Docker
maybe_sudo systemctl enable --now docker
# Add user to docker group
maybe_sudo usermod -aG docker "$USER"
echo -e "${SUCCESS}${NC} Docker installed"
echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership"
}
install_docker_fedora() {
echo -e "${WARN}${NC} Installing Docker via dnf..."
require_sudo
# Add Docker repository
maybe_sudo dnf -y install dnf-plugins-core
maybe_sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo
# Install Docker
maybe_sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# Start Docker
maybe_sudo systemctl enable --now docker
# Add user to docker group
maybe_sudo usermod -aG docker "$USER"
echo -e "${SUCCESS}${NC} Docker installed"
echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership"
}
install_docker_arch() {
echo -e "${WARN}${NC} Installing Docker via pacman..."
maybe_sudo pacman -Sy --noconfirm docker docker-compose
# Start Docker
maybe_sudo systemctl enable --now docker
# Add user to docker group
maybe_sudo usermod -aG docker "$USER"
echo -e "${SUCCESS}${NC} Docker installed"
echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership"
}
install_docker() {
local os
os=$(detect_os)
case "$os" in
macos)
install_docker_macos
;;
debian)
# Check if Ubuntu specifically
if [[ -f /etc/os-release ]]; then
source /etc/os-release
if [[ "$ID" == "ubuntu" ]]; then
install_docker_ubuntu
return $?
fi
fi
install_docker_debian
;;
arch)
install_docker_arch
;;
fedora)
install_docker_fedora
;;
*)
echo -e "${ERROR}Error: Unsupported OS for Docker installation: $os${NC}"
echo "Please install Docker manually: https://docs.docker.com/get-docker/"
return 1
;;
esac
}
ensure_docker() {
local check_result
check_docker
check_result=$?
if [[ $check_result -eq 0 ]]; then
return 0
fi
if [[ $check_result -eq 2 ]]; then
# Permission issue - try to fix
echo -e "${WARN}${NC} Attempting to fix Docker permissions..."
maybe_sudo usermod -aG docker "$USER"
echo -e "${INFO}i${NC} Run 'newgrp docker' or log out/in for group membership"
return 1
fi
if [[ $check_result -eq 3 ]]; then
# Daemon not running - try to start
echo -e "${WARN}${NC} Starting Docker daemon..."
maybe_sudo systemctl start docker
sleep 3
check_docker
return $?
fi
# Docker not installed
install_docker
}
start_docker() {
local os
os=$(detect_os)
if [[ "$os" == "macos" ]]; then
if ! pgrep -x "Docker Desktop" &>/dev/null; then
echo -e "${WARN}${NC} Starting Docker Desktop..."
open -a "Docker Desktop"
sleep 10
fi
else
if ! docker info &>/dev/null; then
echo -e "${WARN}${NC} Starting Docker daemon..."
maybe_sudo systemctl start docker
sleep 3
fi
fi
}
# ============================================================================
# PostgreSQL
# ============================================================================
check_postgres() {
if check_command psql; then
local version
version=$(psql --version 2>/dev/null | grep -oE '[0-9]+')
echo -e "${SUCCESS}${NC} PostgreSQL: ${INFO}v$version${NC}"
return 0
fi
echo -e "${WARN}${NC} PostgreSQL not found"
return 1
}
install_postgres_macos() {
echo -e "${WARN}${NC} Installing PostgreSQL via Homebrew..."
ensure_homebrew
brew install postgresql@17
brew link postgresql@17 --overwrite --force 2>/dev/null || true
# Start PostgreSQL
brew services start postgresql@17
echo -e "${SUCCESS}${NC} PostgreSQL installed"
}
install_postgres_debian() {
echo -e "${WARN}${NC} Installing PostgreSQL via apt..."
require_sudo
# Add PostgreSQL APT repository
maybe_sudo apt-get install -y curl ca-certificates
maybe_sudo install -d /usr/share/postgresql-common/pgdg
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | maybe_sudo gpg --dearmor -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.gpg] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | \
maybe_sudo tee /etc/apt/sources.list.d/pgdg.list > /dev/null
maybe_sudo apt-get update
maybe_sudo apt-get install -y postgresql-17 postgresql-17-pgvector
# Start PostgreSQL
maybe_sudo systemctl enable --now postgresql
echo -e "${SUCCESS}${NC} PostgreSQL installed"
}
install_postgres_arch() {
echo -e "${WARN}${NC} Installing PostgreSQL via pacman..."
maybe_sudo pacman -Sy --noconfirm postgresql
# Initialize database
maybe_sudo -u postgres initdb -D /var/lib/postgres/data 2>/dev/null || true
# Start PostgreSQL
maybe_sudo systemctl enable --now postgresql
echo -e "${SUCCESS}${NC} PostgreSQL installed"
}
install_postgres_fedora() {
echo -e "${WARN}${NC} Installing PostgreSQL via dnf..."
maybe_sudo dnf install -y postgresql-server postgresql-contrib
# Initialize database
maybe_sudo postgresql-setup --initdb 2>/dev/null || true
# Start PostgreSQL
maybe_sudo systemctl enable --now postgresql
echo -e "${SUCCESS}${NC} PostgreSQL installed"
}
install_postgres() {
local os
os=$(detect_os)
case "$os" in
macos)
install_postgres_macos
;;
debian)
install_postgres_debian
;;
arch)
install_postgres_arch
;;
fedora)
install_postgres_fedora
;;
*)
echo -e "${ERROR}Error: Unsupported OS for PostgreSQL installation: $os${NC}"
echo "Please install PostgreSQL ${MIN_POSTGRES_VERSION}+ manually"
return 1
;;
esac
}
# ============================================================================
# Dependency Summary
# ============================================================================
# Check all dependencies for Docker mode
check_docker_dependencies() {
local errors=0
echo -e "${BOLD}Checking Docker dependencies...${NC}"
echo ""
# Git (optional but recommended)
check_git || ((errors++))
# Docker
local docker_result
check_docker
docker_result=$?
if [[ $docker_result -ne 0 ]]; then
((errors++))
fi
# Docker Compose
if [[ $docker_result -eq 0 ]]; then
check_docker_compose || ((errors++))
fi
echo ""
if [[ $errors -gt 0 ]]; then
return 1
fi
return 0
}
# Check all dependencies for Native mode
check_native_dependencies() {
local errors=0
echo -e "${BOLD}Checking native dependencies...${NC}"
echo ""
check_git || ((errors++))
check_node || ((errors++))
check_pnpm || ((errors++))
check_postgres || ((errors++))
echo ""
if [[ $errors -gt 0 ]]; then
return 1
fi
return 0
}
# Install missing dependencies based on mode
install_dependencies() {
local mode="$1"
echo -e "${BOLD}Installing dependencies...${NC}"
echo ""
ensure_git
if [[ "$mode" == "docker" ]]; then
ensure_docker
start_docker
else
ensure_node
ensure_pnpm
local os
os=$(detect_os)
if [[ "$os" != "macos" ]]; then
fix_npm_permissions
fi
# PostgreSQL is optional for native mode (can use Docker or external)
if ! check_postgres; then
echo -e "${INFO}i${NC} PostgreSQL not installed - you can use Docker or external database"
fi
fi
echo ""
echo -e "${SUCCESS}${NC} Dependencies installed"
}
# ============================================================================
# Package Name Mapping
# ============================================================================
# Get platform-specific package name
get_package_name() {
local pkg_manager="$1"
local package="$2"
case "$pkg_manager" in
apt)
case "$package" in
docker) echo "docker-ce" ;;
docker-compose) echo "docker-compose-plugin" ;;
node) echo "nodejs" ;;
postgres) echo "postgresql-17" ;;
*) echo "$package" ;;
esac
;;
pacman)
case "$package" in
docker) echo "docker" ;;
docker-compose) echo "docker-compose" ;;
node) echo "nodejs" ;;
postgres) echo "postgresql" ;;
*) echo "$package" ;;
esac
;;
dnf)
case "$package" in
docker) echo "docker-ce" ;;
docker-compose) echo "docker-compose-plugin" ;;
node) echo "nodejs" ;;
postgres) echo "postgresql-server" ;;
*) echo "$package" ;;
esac
;;
brew)
case "$package" in
docker) echo "docker" ;;
node) echo "node@22" ;;
postgres) echo "postgresql@17" ;;
*) echo "$package" ;;
esac
;;
*)
echo "$package"
;;
esac
}

491
scripts/lib/docker.sh Normal file
View File

@@ -0,0 +1,491 @@
#!/bin/bash
# Docker-specific functions for Mosaic Stack installer
# Handles Docker Compose operations, health checks, and service management
# shellcheck source=lib/platform.sh
source "${BASH_SOURCE[0]%/*}/platform.sh"
# ============================================================================
# Docker Compose Helpers
# ============================================================================
# Get the docker compose command (handles both plugin and standalone)
docker_compose_cmd() {
if docker compose version &>/dev/null; then
echo "docker compose"
elif command -v docker-compose &>/dev/null; then
echo "docker-compose"
else
return 1
fi
}
# Run docker compose with all arguments
docker_compose() {
local cmd
cmd=$(docker_compose_cmd) || {
echo -e "${ERROR}Error: Docker Compose not available${NC}"
return 1
}
# shellcheck disable=SC2086
$cmd "$@"
}
# ============================================================================
# Service Management
# ============================================================================
# Pull all images defined in docker-compose.yml
docker_pull_images() {
local compose_file="${1:-docker-compose.yml}"
local env_file="${2:-.env}"
echo -e "${WARN}${NC} Pulling Docker images..."
if [[ -f "$env_file" ]]; then
docker_compose -f "$compose_file" --env-file "$env_file" pull
else
docker_compose -f "$compose_file" pull
fi
}
# Start services with Docker Compose
docker_compose_up() {
local compose_file="${1:-docker-compose.yml}"
local env_file="${2:-.env}"
local profiles="${3:-}"
local detached="${4:-true}"
echo -e "${WARN}${NC} Starting services..."
local args=("-f" "$compose_file")
if [[ -f "$env_file" ]]; then
args+=("--env-file" "$env_file")
fi
if [[ -n "$profiles" ]]; then
args+=("--profile" "$profiles")
fi
if [[ "$detached" == "true" ]]; then
args+=("up" "-d")
else
args+=("up")
fi
docker_compose "${args[@]}"
}
# Stop services
docker_compose_down() {
local compose_file="${1:-docker-compose.yml}"
local env_file="${2:-.env}"
echo -e "${WARN}${NC} Stopping services..."
if [[ -f "$env_file" ]]; then
docker_compose -f "$compose_file" --env-file "$env_file" down
else
docker_compose -f "$compose_file" down
fi
}
# Restart services
docker_compose_restart() {
local compose_file="${1:-docker-compose.yml}"
local env_file="${2:-.env}"
echo -e "${WARN}${NC} Restarting services..."
if [[ -f "$env_file" ]]; then
docker_compose -f "$compose_file" --env-file "$env_file" restart
else
docker_compose -f "$compose_file" restart
fi
}
# ============================================================================
# Health Checks
# ============================================================================
# Wait for a container to be healthy
wait_for_healthy_container() {
local container_name="$1"
local timeout="${2:-120}"
local interval="${3:-5}"
echo -e "${INFO}i${NC} Waiting for ${INFO}$container_name${NC} to be healthy..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
local status
status=$(docker inspect --format='{{.State.Health.Status}}' "$container_name" 2>/dev/null || echo "not_found")
case "$status" in
healthy)
echo -e "${SUCCESS}${NC} $container_name is healthy"
return 0
;;
unhealthy)
echo -e "${ERROR}${NC} $container_name is unhealthy"
return 1
;;
not_found)
echo -e "${WARN}${NC} Container $container_name not found"
return 1
;;
esac
sleep "$interval"
((elapsed += interval))
done
echo -e "${ERROR}${NC} Timeout waiting for $container_name to be healthy"
return 1
}
# Wait for multiple containers to be healthy
wait_for_healthy_containers() {
local containers=("$@")
local timeout="${containers[-1]}"
unset 'containers[-1]'
for container in "${containers[@]}"; do
if ! wait_for_healthy_container "$container" "$timeout"; then
return 1
fi
done
return 0
}
# Wait for a service to respond on a port
wait_for_service() {
local host="$1"
local port="$2"
local name="$3"
local timeout="${4:-60}"
echo -e "${INFO}i${NC} Waiting for ${INFO}$name${NC} at $host:$port..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
if docker run --rm --network host alpine:latest nc -z "$host" "$port" 2>/dev/null; then
echo -e "${SUCCESS}${NC} $name is responding"
return 0
fi
sleep 2
((elapsed += 2))
done
echo -e "${ERROR}${NC} Timeout waiting for $name"
return 1
}
# ============================================================================
# Container Status
# ============================================================================
# Get container status
get_container_status() {
local container_name="$1"
docker inspect --format='{{.State.Status}}' "$container_name" 2>/dev/null || echo "not_found"
}
# Check if container is running
is_container_running() {
local container_name="$1"
local status
status=$(get_container_status "$container_name")
[[ "$status" == "running" ]]
}
# List all Mosaic containers
list_mosaic_containers() {
docker ps -a --filter "name=mosaic-" --format "{{.Names}}\t{{.Status}}"
}
# Get container logs
get_container_logs() {
local container_name="$1"
local lines="${2:-100}"
docker logs --tail "$lines" "$container_name" 2>&1
}
# Tail container logs
tail_container_logs() {
local container_name="$1"
docker logs -f "$container_name"
}
# ============================================================================
# Database Operations
# ============================================================================
# Wait for PostgreSQL to be ready
wait_for_postgres() {
local container_name="${1:-mosaic-postgres}"
local user="${2:-mosaic}"
local database="${3:-mosaic}"
local timeout="${4:-60}"
echo -e "${INFO}i${NC} Waiting for PostgreSQL to be ready..."
local elapsed=0
while [[ $elapsed -lt $timeout ]]; do
if docker exec "$container_name" pg_isready -U "$user" -d "$database" &>/dev/null; then
echo -e "${SUCCESS}${NC} PostgreSQL is ready"
return 0
fi
sleep 2
((elapsed += 2))
done
echo -e "${ERROR}${NC} Timeout waiting for PostgreSQL"
return 1
}
# Run database migrations
run_database_migrations() {
local api_container="${1:-mosaic-api}"
echo -e "${WARN}${NC} Running database migrations..."
if ! docker exec "$api_container" npx prisma migrate deploy &>/dev/null; then
echo -e "${WARN}${NC} Could not run migrations via API container"
echo -e "${INFO}i${NC} Migrations will run automatically when API starts"
return 0
fi
echo -e "${SUCCESS}${NC} Database migrations complete"
}
# ============================================================================
# Service URLs
# ============================================================================
# Get the URL for a service
get_service_url() {
local service="$1"
local port="${2:-}"
local host="localhost"
# Check if we're in WSL and need to use Windows host
if is_wsl; then
host=$(cat /etc/resolv.conf 2>/dev/null | grep nameserver | awk '{print $2}' | head -1)
fi
if [[ -n "$port" ]]; then
echo "http://${host}:${port}"
else
echo "http://${host}"
fi
}
# Get all service URLs
get_all_service_urls() {
local env_file="${1:-.env}"
declare -A urls=()
if [[ -f "$env_file" ]]; then
# shellcheck source=/dev/null
source "$env_file"
fi
urls[web]="http://localhost:${WEB_PORT:-3000}"
urls[api]="http://localhost:${API_PORT:-3001}"
urls[postgres]="localhost:${POSTGRES_PORT:-5432}"
urls[valkey]="localhost:${VALKEY_PORT:-6379}"
if [[ "${OIDC_ENABLED:-false}" == "true" ]]; then
urls[authentik]="http://localhost:${AUTHENTIK_PORT_HTTP:-9000}"
fi
if [[ "${OLLAMA_MODE:-disabled}" != "disabled" ]]; then
urls[ollama]="http://localhost:${OLLAMA_PORT:-11434}"
fi
for service in "${!urls[@]}"; do
echo "$service: ${urls[$service]}"
done
}
# ============================================================================
# Docker Cleanup
# ============================================================================
# Remove unused Docker resources
docker_cleanup() {
echo -e "${WARN}${NC} Cleaning up unused Docker resources..."
# Remove dangling images
docker image prune -f
# Remove unused networks
docker network prune -f
echo -e "${SUCCESS}${NC} Docker cleanup complete"
}
# Remove all Mosaic containers and volumes
docker_remove_all() {
local compose_file="${1:-docker-compose.yml}"
local env_file="${2:-.env}"
echo -e "${WARN}${NC} Removing all Mosaic containers and volumes..."
if [[ -f "$env_file" ]]; then
docker_compose -f "$compose_file" --env-file "$env_file" down -v --remove-orphans
else
docker_compose -f "$compose_file" down -v --remove-orphans
fi
echo -e "${SUCCESS}${NC} All containers and volumes removed"
}
# ============================================================================
# Docker Info
# ============================================================================
# Print Docker system info
print_docker_info() {
echo -e "${BOLD}Docker Information:${NC}"
echo ""
echo -e " Docker Version:"
docker --version 2>/dev/null | sed 's/^/ /'
echo ""
echo -e " Docker Compose:"
docker_compose version 2>/dev/null | sed 's/^/ /'
echo ""
echo -e " Docker Storage:"
docker system df 2>/dev/null | sed 's/^/ /'
echo ""
echo -e " Running Containers:"
docker ps --format " {{.Names}}\t{{.Status}}" 2>/dev/null | head -10
}
# ============================================================================
# Volume Management
# ============================================================================
# List all Mosaic volumes
list_mosaic_volumes() {
docker volume ls --filter "name=mosaic" --format "{{.Name}}"
}
# Backup a Docker volume
backup_volume() {
local volume_name="$1"
local backup_file="${2:-${volume_name}-backup-$(date +%Y%m%d-%H%M%S).tar.gz}"
echo -e "${WARN}${NC} Backing up volume ${INFO}$volume_name${NC}..."
docker run --rm \
-v "$volume_name":/source:ro \
-v "$(pwd)":/backup \
alpine:latest \
tar czf "/backup/$backup_file" -C /source .
echo -e "${SUCCESS}${NC} Backup created: $backup_file"
}
# Restore a Docker volume
restore_volume() {
local volume_name="$1"
local backup_file="$2"
echo -e "${WARN}${NC} Restoring volume ${INFO}$volume_name${NC} from $backup_file..."
# Create volume if it doesn't exist
docker volume create "$volume_name" &>/dev/null || true
docker run --rm \
-v "$volume_name":/target \
-v "$(pwd)":/backup \
alpine:latest \
tar xzf "/backup/$backup_file" -C /target
echo -e "${SUCCESS}${NC} Volume restored"
}
# ============================================================================
# Network Management
# ============================================================================
# Create a Docker network if it doesn't exist
ensure_network() {
local network_name="$1"
if ! docker network inspect "$network_name" &>/dev/null; then
echo -e "${WARN}${NC} Creating network ${INFO}$network_name${NC}..."
docker network create "$network_name"
echo -e "${SUCCESS}${NC} Network created"
fi
}
# Check if a network exists
network_exists() {
local network_name="$1"
docker network inspect "$network_name" &>/dev/null
}
# ============================================================================
# Build Operations
# ============================================================================
# Build Docker images
docker_build() {
local compose_file="${1:-docker-compose.yml}"
local env_file="${2:-.env}"
local parallel="${3:-true}"
echo -e "${WARN}${NC} Building Docker images..."
local args=("-f" "$compose_file")
if [[ -f "$env_file" ]]; then
args+=("--env-file" "$env_file")
fi
args+=("build")
if [[ "$parallel" == "true" ]]; then
args+=("--parallel")
fi
docker_compose "${args[@]}"
}
# Check if buildx is available
check_buildx() {
docker buildx version &>/dev/null
}
# Set up buildx builder
setup_buildx() {
if ! check_buildx; then
echo -e "${WARN}${NC} buildx not available"
return 1
fi
# Create or use existing builder
if ! docker buildx inspect mosaic-builder &>/dev/null; then
echo -e "${WARN}${NC} Creating buildx builder..."
docker buildx create --name mosaic-builder --use
else
docker buildx use mosaic-builder
fi
}

665
scripts/lib/platform.sh Normal file
View File

@@ -0,0 +1,665 @@
#!/bin/bash
# Platform detection functions for Mosaic Stack installer
# Provides OS, package manager, architecture, and environment detection
# ============================================================================
# Colors (if terminal supports them)
# ============================================================================
if [[ -t 1 ]]; then
BOLD='\033[1m'
ACCENT='\033[38;2;128;90;213m'
SUCCESS='\033[38;2;47;191;113m'
WARN='\033[38;2;255;176;32m'
ERROR='\033[38;2;226;61;45m'
INFO='\033[38;2;100;149;237m'
MUTED='\033[38;2;139;127;119m'
NC='\033[0m'
else
BOLD=''
ACCENT=''
SUCCESS=''
WARN=''
ERROR=''
INFO=''
MUTED=''
NC=''
fi
# ============================================================================
# OS Detection
# ============================================================================
# Detect operating system type
# Returns: macos, debian, arch, fedora, linux, unknown
detect_os() {
case "$OSTYPE" in
darwin*)
echo "macos"
return 0
;;
linux-gnu*)
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
source /etc/os-release
case "$ID" in
ubuntu|debian|linuxmint|pop|elementary)
echo "debian"
return 0
;;
arch|manjaro|endeavouros|garuda|arcolinux)
echo "arch"
return 0
;;
fedora|rhel|centos|rocky|almalinux|ol)
echo "fedora"
return 0
;;
*)
echo "linux"
return 0
;;
esac
else
echo "linux"
return 0
fi
;;
*)
echo "unknown"
return 1
;;
esac
}
# Detect if running under WSL (Windows Subsystem for Linux)
# Returns WSL_DISTRO_NAME if in WSL, empty string otherwise
detect_wsl() {
if [[ -n "${WSL_DISTRO_NAME:-}" ]]; then
echo "$WSL_DISTRO_NAME"
return 0
fi
# Check for WSL in /proc/version
if [[ -f /proc/version ]]; then
if grep -qi "microsoft\|wsl" /proc/version 2>/dev/null; then
# Try to get distro name from os-release
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
source /etc/os-release
echo "${NAME:-WSL}"
return 0
fi
echo "WSL"
return 0
fi
fi
return 1
}
# Check if running in WSL
is_wsl() {
[[ -n "${WSL_DISTRO_NAME:-}" ]] && return 0
[[ -f /proc/version ]] && grep -qi "microsoft\|wsl" /proc/version 2>/dev/null
}
# Get human-readable OS name
get_os_name() {
local os="$1"
case "$os" in
macos)
echo "macOS"
;;
debian)
echo "Debian/Ubuntu"
;;
arch)
echo "Arch Linux"
;;
fedora)
echo "Fedora/RHEL"
;;
linux)
echo "Linux"
;;
*)
echo "Unknown"
;;
esac
}
# ============================================================================
# Package Manager Detection
# ============================================================================
# Detect the system's package manager
# Returns: brew, apt, pacman, dnf, yum, unknown
detect_package_manager() {
local os="$1"
# First check for Homebrew (available on macOS and Linux)
if command -v brew &>/dev/null; then
echo "brew"
return 0
fi
# Fall back to OS-specific package managers
case "$os" in
macos)
# macOS without Homebrew
echo "none"
return 1
;;
debian)
if command -v apt-get &>/dev/null; then
echo "apt"
return 0
fi
;;
arch)
if command -v pacman &>/dev/null; then
echo "pacman"
return 0
fi
;;
fedora)
if command -v dnf &>/dev/null; then
echo "dnf"
return 0
elif command -v yum &>/dev/null; then
echo "yum"
return 0
fi
;;
esac
echo "unknown"
return 1
}
# ============================================================================
# Architecture Detection
# ============================================================================
# Get system architecture
# Returns: x86_64, aarch64, armv7l, armv6l, unknown
get_arch() {
local arch
arch=$(uname -m)
case "$arch" in
x86_64|amd64)
echo "x86_64"
;;
aarch64|arm64)
echo "aarch64"
;;
armv7l|armhf)
echo "armv7l"
;;
armv6l)
echo "armv6l"
;;
*)
echo "unknown"
;;
esac
}
# Check if running on Apple Silicon
is_apple_silicon() {
[[ "$(detect_os)" == "macos" ]] && [[ "$(get_arch)" == "aarch64" ]]
}
# ============================================================================
# Init System Detection
# ============================================================================
# Detect the init system
# Returns: systemd, openrc, launchd, sysvinit, unknown
detect_init_system() {
local os
os=$(detect_os)
case "$os" in
macos)
echo "launchd"
return 0
;;
esac
# Check for systemd
if command -v systemctl &>/dev/null && pidof systemd &>/dev/null; then
echo "systemd"
return 0
fi
# Check for OpenRC
if command -v rc-status &>/dev/null; then
echo "openrc"
return 0
fi
# Check for SysVinit
if command -v service &>/dev/null; then
echo "sysvinit"
return 0
fi
echo "unknown"
return 1
}
# ============================================================================
# Privilege Helpers
# ============================================================================
# Check if running as root
is_root() {
[[ "$(id -u)" -eq 0 ]]
}
# Run command with sudo only if not already root
maybe_sudo() {
if is_root; then
# Skip -E flag when root (env is already preserved)
if [[ "${1:-}" == "-E" ]]; then
shift
fi
"$@"
else
sudo "$@"
fi
}
# Ensure sudo is available (Linux only)
require_sudo() {
local os
os=$(detect_os)
if [[ "$os" == "macos" ]]; then
return 0
fi
if is_root; then
return 0
fi
if command -v sudo &>/dev/null; then
return 0
fi
echo -e "${ERROR}Error: sudo is required for system installs on Linux${NC}"
echo "Install sudo or re-run as root."
return 1
}
# Validate sudo credentials (cache them early)
validate_sudo() {
if is_root; then
return 0
fi
if command -v sudo &>/dev/null; then
sudo -v 2>/dev/null
return $?
fi
return 1
}
# ============================================================================
# TTY and Interactive Detection
# ============================================================================
# Check if running in an interactive terminal
is_interactive() {
[[ -t 0 && -t 1 ]]
}
# Check if we can prompt the user
is_promptable() {
[[ -r /dev/tty && -w /dev/tty ]]
}
# Read input from TTY (for prompts when stdin is piped)
read_from_tty() {
local prompt="$1"
local var_name="$2"
if is_promptable; then
echo -e "$prompt" > /dev/tty
read -r "$var_name" < /dev/tty
else
return 1
fi
}
# ============================================================================
# Shell Detection
# ============================================================================
# Get current shell name
get_shell() {
basename "${SHELL:-/bin/sh}"
}
# Get shell configuration file
get_shell_rc() {
local shell
shell=$(get_shell)
case "$shell" in
zsh)
echo "$HOME/.zshrc"
;;
bash)
echo "$HOME/.bashrc"
;;
fish)
echo "$HOME/.config/fish/config.fish"
;;
*)
echo "$HOME/.profile"
;;
esac
}
# ============================================================================
# Network and Connectivity
# ============================================================================
# Check if we have internet connectivity
has_internet() {
local timeout="${1:-5}"
# Try to reach common endpoints
if command -v curl &>/dev/null; then
curl -s --max-time "$timeout" https://api.github.com &>/dev/null
return $?
elif command -v wget &>/dev/null; then
wget -q --timeout="$timeout" --spider https://api.github.com
return $?
fi
# Fall back to ping
ping -c 1 -W "$timeout" 8.8.8.8 &>/dev/null 2>&1
}
# Get local IP address
get_local_ip() {
local ip
# Try hostname command first (macOS)
if command -v hostname &>/dev/null; then
ip=$(hostname -I 2>/dev/null | cut -d' ' -f1)
if [[ -n "$ip" ]]; then
echo "$ip"
return 0
fi
fi
# Try ip command (Linux)
if command -v ip &>/dev/null; then
ip=$(ip route get 1 2>/dev/null | awk '{print $7; exit}')
if [[ -n "$ip" ]]; then
echo "$ip"
return 0
fi
fi
# Try ifconfig
if command -v ifconfig &>/dev/null; then
ip=$(ifconfig 2>/dev/null | grep -Eo 'inet (addr:)?([0-9]*\.){3}[0-9]*' | grep -Eo '([0-9]*\.){3}[0-9]*' | grep -v '127.0.0.1' | head -1)
if [[ -n "$ip" ]]; then
echo "$ip"
return 0
fi
fi
return 1
}
# ============================================================================
# System Resources
# ============================================================================
# Get total RAM in MB
get_total_ram() {
local os
os=$(detect_os)
case "$os" in
macos)
sysctl -n hw.memsize 2>/dev/null | awk '{print int($1/1024/1024)}'
;;
*)
if [[ -f /proc/meminfo ]]; then
awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo
else
echo "0"
fi
;;
esac
}
# Get available disk space in GB for a path
get_available_disk() {
local path="${1:-.}"
if command -v df &>/dev/null; then
df -BG "$path" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G'
else
echo "0"
fi
}
# Check if system meets minimum requirements
check_minimum_requirements() {
local min_ram="${1:-2048}" # MB
local min_disk="${2:-10}" # GB
local ram disk
ram=$(get_total_ram)
disk=$(get_available_disk "$HOME")
local errors=()
if [[ "$ram" -lt "$min_ram" ]]; then
errors+=("RAM: ${ram}MB (minimum: ${min_ram}MB)")
fi
if [[ "$disk" -lt "$min_disk" ]]; then
errors+=("Disk: ${disk}GB (minimum: ${min_disk}GB)")
fi
if [[ ${#errors[@]} -gt 0 ]]; then
echo "System does not meet minimum requirements:"
printf ' - %s\n' "${errors[@]}"
return 1
fi
return 0
}
# ============================================================================
# Downloader Detection
# ============================================================================
DOWNLOADER=""
detect_downloader() {
if command -v curl &>/dev/null; then
DOWNLOADER="curl"
return 0
fi
if command -v wget &>/dev/null; then
DOWNLOADER="wget"
return 0
fi
echo -e "${ERROR}Error: Missing downloader (curl or wget required)${NC}"
return 1
}
# Download a file securely
download_file() {
local url="$1"
local output="$2"
if [[ -z "$DOWNLOADER" ]]; then
detect_downloader || return 1
fi
if [[ "$DOWNLOADER" == "curl" ]]; then
curl -fsSL --proto '=https' --tlsv1.2 --retry 3 --retry-delay 1 --retry-connrefused -o "$output" "$url"
return $?
fi
wget -q --https-only --secure-protocol=TLSv1_2 --tries=3 --timeout=20 -O "$output" "$url"
return $?
}
# Download and execute a script
run_remote_script() {
local url="$1"
local tmp
tmp=$(mktemp)
if ! download_file "$url" "$tmp"; then
rm -f "$tmp"
return 1
fi
/bin/bash "$tmp"
local ret=$?
rm -f "$tmp"
return $ret
}
# ============================================================================
# PATH Management
# ============================================================================
# Store original PATH for later comparison
ORIGINAL_PATH="${PATH:-}"
# Check if a directory is in PATH
path_has_dir() {
local path="$1"
local dir="${2%/}"
[[ -z "$dir" ]] && return 1
case ":${path}:" in
*":${dir}:"*) return 0 ;;
*) return 1 ;;
esac
}
# Add directory to PATH in shell config
add_to_path() {
local dir="$1"
local rc="${2:-$(get_shell_rc)}"
if [[ ! -f "$rc" ]]; then
return 1
fi
# shellcheck disable=SC2016
local path_line="export PATH=\"${dir}:\$PATH\""
if ! grep -qF "$dir" "$rc" 2>/dev/null; then
echo "" >> "$rc"
echo "# Added by Mosaic Stack installer" >> "$rc"
echo "$path_line" >> "$rc"
return 0
fi
return 1
}
# Warn about missing PATH entries
warn_path_missing() {
local dir="$1"
local label="$2"
if [[ -z "$dir" ]]; then
return 0
fi
if path_has_dir "$ORIGINAL_PATH" "$dir"; then
return 0
fi
echo ""
echo -e "${WARN}${NC} PATH warning: missing ${label}: ${INFO}${dir}${NC}"
echo -e "This can make commands show as \"not found\" in new terminals."
echo -e "Fix by adding to your shell config:"
echo -e " export PATH=\"${dir}:\$PATH\""
}
# ============================================================================
# Temp File Management
# ============================================================================
TMPFILES=()
# Create a tracked temp file
create_temp_file() {
local f
f=$(mktemp)
TMPFILES+=("$f")
echo "$f"
}
# Create a tracked temp directory
create_temp_dir() {
local d
d=$(mktemp -d)
TMPFILES+=("$d")
echo "$d"
}
# Cleanup all temp files
cleanup_temp_files() {
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
done
}
# Set up cleanup trap
setup_cleanup_trap() {
trap cleanup_temp_files EXIT
}
# ============================================================================
# Platform Summary
# ============================================================================
# Print a summary of the detected platform
print_platform_summary() {
local os pkg arch init wsl
os=$(detect_os)
pkg=$(detect_package_manager "$os")
arch=$(get_arch)
init=$(detect_init_system)
wsl=$(detect_wsl)
echo -e "${BOLD}Platform Detection:${NC}"
echo -e " OS: ${INFO}$(get_os_name "$os")${NC}"
echo -e " Architecture: ${INFO}$arch${NC}"
echo -e " Package Mgr: ${INFO}$pkg${NC}"
echo -e " Init System: ${INFO}$init${NC}"
if [[ -n "$wsl" ]]; then
echo -e " WSL: ${INFO}$wsl${NC}"
fi
echo -e " Shell: ${INFO}$(get_shell)${NC}"
echo -e " RAM: ${INFO}$(get_total_ram)MB${NC}"
echo -e " Disk: ${INFO}$(get_available_disk)GB available${NC}"
}

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
}