Files
git.stella-ops.org/scripts/setup.sh

542 lines
16 KiB
Bash

#!/usr/bin/env bash
# Automated developer environment setup for Stella Ops (Linux/macOS).
#
# Usage:
# ./scripts/setup.sh [--skip-build] [--infra-only] [--images-only] [--skip-images]
set -euo pipefail
# ─── Parse flags ────────────────────────────────────────────────────────────
SKIP_BUILD=false
INFRA_ONLY=false
IMAGES_ONLY=false
SKIP_IMAGES=false
for arg in "$@"; do
case "$arg" in
--skip-build) SKIP_BUILD=true ;;
--infra-only) INFRA_ONLY=true ;;
--images-only) IMAGES_ONLY=true ;;
--skip-images) SKIP_IMAGES=true ;;
-h|--help)
echo "Usage: $0 [--skip-build] [--infra-only] [--images-only] [--skip-images]"
exit 0
;;
*) echo "Unknown flag: $arg" >&2; exit 1 ;;
esac
done
ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ -z "$ROOT" ]]; then
echo "ERROR: Not inside a git repository." >&2
exit 1
fi
COMPOSE_DIR="${ROOT}/devops/compose"
# ─── Helpers ────────────────────────────────────────────────────────────────
step() { printf '\n\033[1;36m>> %s\033[0m\n' "$1"; }
ok() { printf ' \033[0;32m[OK]\033[0m %s\n' "$1"; }
warn() { printf ' \033[0;33m[WARN]\033[0m %s\n' "$1"; }
fail() { printf ' \033[0;31m[FAIL]\033[0m %s\n' "$1"; }
has_cmd() { command -v "$1" &>/dev/null; }
get_running_container_by_service() {
local service="$1"
docker ps --filter "label=com.docker.compose.service=${service}" --format "{{.Names}}" 2>/dev/null | head -n1
}
service_http_probe_url() {
local service="$1"
local container_port="$2"
local path="${3:-/}"
local container mapping host host_port
container=$(get_running_container_by_service "$service")
[[ -z "$container" ]] && return 1
mapping=$(docker port "$container" "${container_port}/tcp" 2>/dev/null | head -n1)
[[ -z "$mapping" ]] && return 1
host="${mapping%:*}"
host_port="${mapping##*:}"
if [[ "$host" == "0.0.0.0" || "$host" == "::" ]]; then
host="127.0.0.1"
fi
[[ "$path" != /* ]] && path="/$path"
printf 'http://%s:%s%s' "$host" "$host_port" "$path"
}
# ─── 1. Check prerequisites ────────────────────────────────────────────────
check_prerequisites() {
step 'Checking prerequisites'
local all_good=true
# dotnet
if has_cmd dotnet; then
local v; v=$(dotnet --version 2>/dev/null)
if [[ "$v" =~ ^10\. ]]; then
ok "dotnet $v"
else
fail "dotnet $v found, but 10.x is required"
all_good=false
fi
else
fail 'dotnet SDK not found. Install .NET 10 SDK.'
all_good=false
fi
# node
if has_cmd node; then
local v; v=$(node --version 2>/dev/null | sed 's/^v//')
local major; major=$(echo "$v" | cut -d. -f1)
if (( major >= 20 )); then
ok "node $v"
else
fail "node $v found, but 20+ is required"
all_good=false
fi
else
fail 'node not found. Install Node.js 20+.'
all_good=false
fi
# npm
if has_cmd npm; then
local v; v=$(npm --version 2>/dev/null)
local major; major=$(echo "$v" | cut -d. -f1)
if (( major >= 10 )); then
ok "npm $v"
else
fail "npm $v found, but 10+ is required"
all_good=false
fi
else
fail 'npm not found.'
all_good=false
fi
# docker
if has_cmd docker; then
ok "docker: $(docker --version 2>/dev/null)"
else
fail 'docker not found. Install Docker.'
all_good=false
fi
# docker compose
if docker compose version &>/dev/null; then
ok 'docker compose available'
else
fail 'docker compose not available. Install Compose V2.'
all_good=false
fi
# git
if has_cmd git; then
ok "$(git --version 2>/dev/null)"
else
fail 'git not found.'
all_good=false
fi
if [[ "$all_good" != "true" ]]; then
echo 'ERROR: Prerequisites not met. Install missing tools and re-run.' >&2
exit 1
fi
}
# ─── 2. Check and install hosts file ─────────────────────────────────────
check_hosts() {
step 'Checking hosts file for stella-ops.local entries'
local hosts_source="${ROOT}/devops/compose/hosts.stellaops.local"
if grep -q 'stella-ops\.local' /etc/hosts 2>/dev/null; then
ok 'stella-ops.local entries found in /etc/hosts'
return
fi
warn 'stella-ops.local entries NOT found in /etc/hosts.'
if [[ ! -f "$hosts_source" ]]; then
warn "Hosts source file not found at $hosts_source"
echo ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2'
echo ' to /etc/hosts (use sudo).'
return
fi
echo ''
echo ' Stella Ops needs ~50 hosts file entries for local development.'
echo " Source: devops/compose/hosts.stellaops.local"
echo ''
printf ' Add entries to /etc/hosts now? (Y/n) '
read -r answer
if [[ -z "$answer" || "$answer" =~ ^[Yy] ]]; then
if [[ "$(id -u)" -eq 0 ]]; then
printf '\n' >> /etc/hosts
cat "$hosts_source" >> /etc/hosts
ok 'Hosts entries added successfully'
else
echo ''
echo ' Adding hosts entries requires sudo...'
if sudo sh -c "printf '\n' >> /etc/hosts && cat '$hosts_source' >> /etc/hosts"; then
ok 'Hosts entries added successfully'
else
warn 'Failed to add hosts entries. Add them manually:'
echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'"
fi
fi
else
warn 'Skipped. Add them manually before accessing the platform:'
echo " sudo sh -c 'cat $hosts_source >> /etc/hosts'"
fi
}
# ─── 3. Ensure .env ────────────────────────────────────────────────────────
ensure_env() {
step 'Ensuring .env file exists'
local env_file="${COMPOSE_DIR}/.env"
local env_example="${COMPOSE_DIR}/env/stellaops.env.example"
if [[ -f "$env_file" ]]; then
ok ".env already exists at $env_file"
elif [[ -f "$env_example" ]]; then
cp "$env_example" "$env_file"
ok "Copied $env_example -> $env_file"
warn 'For production, change POSTGRES_PASSWORD in .env.'
else
fail "Neither .env nor env/stellaops.env.example found in $COMPOSE_DIR"
exit 1
fi
}
get_compose_env_value() {
local key="$1"
local env_file="${COMPOSE_DIR}/.env"
[[ -f "$env_file" ]] || return 1
awk -F= -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "$env_file"
}
get_frontdoor_network_name() {
if [[ -n "${FRONTDOOR_NETWORK:-}" ]]; then
printf '%s\n' "$FRONTDOOR_NETWORK"
return
fi
local configured
configured="$(get_compose_env_value FRONTDOOR_NETWORK || true)"
if [[ -n "$configured" ]]; then
printf '%s\n' "$configured"
return
fi
printf '%s\n' 'stellaops_frontdoor'
}
ensure_frontdoor_network() {
local network_name
network_name="$(get_frontdoor_network_name)"
if docker network inspect "$network_name" >/dev/null 2>&1; then
ok "Frontdoor network available ($network_name)"
return
fi
warn "Frontdoor network missing ($network_name); creating it now."
docker network create "$network_name" >/dev/null
ok "Created frontdoor network ($network_name)"
}
# ─── 4. Start infrastructure ───────────────────────────────────────────────
start_infra() {
step 'Starting infrastructure containers (docker-compose.dev.yml)'
cd "$COMPOSE_DIR"
docker compose -f docker-compose.dev.yml up -d
echo ' Waiting for containers to become healthy...'
local max_wait=120
local elapsed=0
while (( elapsed < max_wait )); do
local all_healthy=true
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local health; health=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true)
if [[ -n "$health" && "$health" != "healthy" ]]; then
all_healthy=false
fi
done < <(docker compose -f docker-compose.dev.yml ps --format json 2>/dev/null)
if [[ "$all_healthy" == "true" && $elapsed -gt 5 ]]; then
ok 'All infrastructure containers healthy'
cd "$ROOT"
return
fi
sleep 5
elapsed=$((elapsed + 5))
done
warn "Timed out waiting for healthy status after ${max_wait}s."
cd "$ROOT"
}
# ─── 5. Build .NET solutions ───────────────────────────────────────────────
build_solutions() {
step 'Building all .NET solutions'
local script="${ROOT}/scripts/build-all-solutions.sh"
if [[ -x "$script" ]]; then
"$script" --stop-repo-host-processes
ok '.NET solutions built successfully'
elif [[ -f "$script" ]]; then
bash "$script" --stop-repo-host-processes
ok '.NET solutions built successfully'
else
warn "Build script not found at $script. Skipping .NET build."
fi
}
# ─── 6. Build Docker images ────────────────────────────────────────────────
build_images() {
local publish_no_restore="${1:-false}"
step 'Building Docker images'
local script="${ROOT}/devops/docker/build-all.sh"
if [[ -x "$script" ]]; then
PUBLISH_NO_RESTORE="$publish_no_restore" "$script"
ok 'Docker images built successfully'
elif [[ -f "$script" ]]; then
PUBLISH_NO_RESTORE="$publish_no_restore" bash "$script"
ok 'Docker images built successfully'
else
warn "Build script not found at $script. Skipping image build."
fi
}
# ─── 7. Start full platform ────────────────────────────────────────────────
start_platform() {
step 'Starting full Stella Ops platform'
ensure_frontdoor_network
cd "$COMPOSE_DIR"
docker compose -f docker-compose.stella-ops.yml up -d
ok 'Platform services started'
cd "$ROOT"
}
http_status() {
local url="$1"
local attempts="${2:-6}"
local delay_seconds="${3:-2}"
local status=""
for (( attempt=1; attempt<=attempts; attempt++ )); do
status=$(curl -sk -o /dev/null --connect-timeout 5 -w '%{http_code}' "$url" 2>/dev/null || true)
if [[ -n "$status" && "$status" != "000" ]]; then
printf '%s' "$status"
return 0
fi
if (( attempt < attempts )); then
sleep "$delay_seconds"
fi
done
return 0
}
frontdoor_bootstrap_ready() {
step 'Waiting for frontdoor bootstrap readiness'
local probes=(
"Frontdoor readiness|https://stella-ops.local/health/ready|200"
"Frontdoor welcome page|https://stella-ops.local/welcome|200"
"Frontdoor environment settings|https://stella-ops.local/envsettings.json|200"
"Authority discovery|https://stella-ops.local/.well-known/openid-configuration|200"
"Authority authorize bootstrap|https://stella-ops.local/connect/authorize?client_id=stella-ops-ui&redirect_uri=https%3A%2F%2Fstella-ops.local%2Fauth%2Fcallback&response_type=code&scope=openid%20profile%20email&state=setup-smoke&nonce=setup-smoke&code_challenge=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&code_challenge_method=S256|200,302,303"
)
local entry name url allowed status matched
for entry in "${probes[@]}"; do
IFS='|' read -r name url allowed <<<"$entry"
status="$(http_status "$url" 24 5)"
matched=false
IFS=',' read -ra allowed_codes <<<"$allowed"
for code in "${allowed_codes[@]}"; do
if [[ "$status" == "$code" ]]; then
matched=true
break
fi
done
if [[ "$matched" == "true" ]]; then
ok "$name (HTTP $status)"
continue
fi
fail "$name did not reach an expected status ($allowed)"
return 1
done
ok 'Frontdoor bootstrap path is ready for first-user sign-in'
}
# ─── 8. Smoke test ─────────────────────────────────────────────────────────
smoke_test() {
step 'Running smoke tests'
local has_blocking_failures=false
# Infrastructure checks
if docker exec stellaops-dev-postgres pg_isready -U stellaops &>/dev/null; then
ok 'PostgreSQL'
else
warn 'PostgreSQL not responding'
has_blocking_failures=true
fi
local pong; pong=$(docker exec stellaops-dev-valkey valkey-cli ping 2>/dev/null || true)
if [[ "$pong" == "PONG" ]]; then
ok 'Valkey'
else
warn 'Valkey not responding'
has_blocking_failures=true
fi
local rustfs_url rustfs_status
rustfs_url=$(service_http_probe_url rustfs 8333 / || true)
rustfs_status=$(http_status "$rustfs_url")
if [[ "$rustfs_status" == "200" || "$rustfs_status" == "403" ]]; then
ok "RustFS S3 endpoint (HTTP $rustfs_status)"
else
warn 'RustFS S3 endpoint did not respond with an expected status (wanted 200/403)'
has_blocking_failures=true
fi
local registry_url registry_status
registry_url=$(service_http_probe_url registry 5000 /v2/ || true)
registry_status=$(http_status "$registry_url")
if [[ "$registry_status" == "200" || "$registry_status" == "401" ]]; then
ok "Zot registry endpoint (HTTP $registry_status)"
else
warn 'Zot registry endpoint did not respond with an expected status (wanted 200/401)'
has_blocking_failures=true
fi
if [[ "$INFRA_ONLY" != "true" ]]; then
if ! frontdoor_bootstrap_ready; then
has_blocking_failures=true
fi
fi
# Platform container health summary
step 'Container health summary'
cd "$COMPOSE_DIR"
local total=0
local healthy=0
local unhealthy_names=""
for cf in docker-compose.dev.yml docker-compose.stella-ops.yml; do
[[ ! -f "$cf" ]] && continue
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local name; name=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Name',''))" 2>/dev/null || true)
local h; h=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true)
total=$((total + 1))
if [[ -z "$h" || "$h" == "healthy" ]]; then
healthy=$((healthy + 1))
else
unhealthy_names="${unhealthy_names} Unhealthy: ${name}\n"
fi
done < <(docker compose -f "$cf" ps --format json 2>/dev/null)
done
if (( total > 0 )); then
if (( healthy == total )); then
ok "$healthy/$total containers healthy"
else
warn "$healthy/$total containers healthy"
[[ -n "$unhealthy_names" ]] && printf " \033[0;33m%b\033[0m" "$unhealthy_names"
fi
fi
# Platform endpoint check
if curl -sk --connect-timeout 5 -o /dev/null -w '' https://stella-ops.local 2>/dev/null; then
ok 'Platform accessible at https://stella-ops.local'
elif bash -c "echo >/dev/tcp/stella-ops.local/443" 2>/dev/null; then
ok 'Platform listening on https://stella-ops.local (TLS handshake pending)'
else
warn 'Platform not yet accessible at https://stella-ops.local (may still be starting)'
has_blocking_failures=true
fi
cd "$ROOT"
if [[ "$has_blocking_failures" == "true" ]]; then
return 1
fi
}
# ─── Main ───────────────────────────────────────────────────────────────────
echo '============================================='
echo ' Stella Ops Developer Environment Setup'
echo '============================================='
check_prerequisites
check_hosts
if [[ "$IMAGES_ONLY" == "true" ]]; then
build_images false
echo ''
echo 'Done (images only).'
exit 0
fi
ensure_env
start_infra
if [[ "$INFRA_ONLY" == "true" ]]; then
if ! smoke_test; then
fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1
fi
echo ''
echo 'Done (infra only). Infrastructure is running.'
exit 0
fi
if [[ "$SKIP_BUILD" != "true" ]]; then
build_solutions
fi
if [[ "$SKIP_IMAGES" != "true" ]]; then
if [[ "$SKIP_BUILD" == "true" ]]; then
build_images false
else
build_images true
fi
fi
start_platform
if ! smoke_test; then
fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1
fi
echo ''
echo '============================================='
echo ' Setup complete!'
echo ' Platform: https://stella-ops.local'
echo ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md'
echo '============================================='