#!/usr/bin/env bash # ============================================================================= # services.sh - Service configuration utilities for dev scripts # ============================================================================= # # Source this file in dev scripts to access service configuration from # feature services.yaml files combined with ports.yaml # # Usage: # source "$(dirname "$0")/lib/services.sh" # # # Get all dependencies for a feature # get_feature_deps "analytics" # # # Get service URL # get_service_url "analytics" "api" # # # Start all deps for a feature # start_feature_deps "analytics" # # # Check health of all deps # check_feature_health "analytics" # # ============================================================================= # Ensure ports.sh is loaded _SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if ! declare -f get_port &>/dev/null; then source "$_SCRIPT_DIR/ports.sh" fi # ============================================================================= # Configuration # ============================================================================= SERVICES_PROJECT_ROOT="${SERVICES_PROJECT_ROOT:-$PORTS_PROJECT_ROOT}" SERVICES_DIR="${SERVICES_PROJECT_ROOT}/codebase/features" SERVICES_INFRA_DIR="${SERVICES_PROJECT_ROOT}/infrastructure/services/features" # ============================================================================= # Service YAML Access # ============================================================================= # Get the services.yaml path for a feature # Usage: get_feature_services_yaml "analytics" get_feature_services_yaml() { local feature="$1" local yaml_path="${SERVICES_DIR}/${feature}/services.yaml" if [[ -f "$yaml_path" ]]; then echo "$yaml_path" return 0 fi # Fallback to infrastructure symlink yaml_path="${SERVICES_INFRA_DIR}/${feature}.yaml" if [[ -f "$yaml_path" ]]; then echo "$yaml_path" return 0 fi echo "ERROR: services.yaml not found for feature: $feature" >&2 return 1 } # Check if a feature has a services.yaml # Usage: has_services_yaml "analytics" has_services_yaml() { local feature="$1" get_feature_services_yaml "$feature" &>/dev/null } # ============================================================================= # Service Access Functions # ============================================================================= # Get list of services defined by a feature # Usage: get_feature_services "analytics" get_feature_services() { _check_yq || return 1 local feature="$1" local yaml_path yaml_path=$(get_feature_services_yaml "$feature") || return 1 # Services are stored as arrays, extract the id field yq '.services[].id' "$yaml_path" 2>/dev/null } # Get service type (api, postgresql, redis, frontend, etc.) # Usage: get_service_type "analytics" "api" get_service_type() { _check_yq || return 1 local feature="$1" local service="$2" local yaml_path yaml_path=$(get_feature_services_yaml "$feature") || return 1 # Services are arrays, find by id yq ".services[] | select(.id == \"$service\") | .type // \"unknown\"" "$yaml_path" 2>/dev/null } # Get service port (resolved from ports.yaml) # Usage: get_service_port "analytics" "api" get_service_port() { _check_yq || return 1 local feature="$1" local service="$2" # Try direct port lookup first local port port=$(get_port "features.${feature}.${service}" 2>/dev/null) if [[ -n "$port" && "$port" != "null" ]]; then echo "$port" return 0 fi # Try infrastructure ports port=$(get_port "infrastructure.${service}" 2>/dev/null) if [[ -n "$port" && "$port" != "null" ]]; then echo "$port" return 0 fi echo "ERROR: Port not found for ${feature}.${service}" >&2 return 1 } # Get service URL # Usage: get_service_url "analytics" "api" get_service_url() { local feature="$1" local service="${2:-api}" local host="${3:-localhost}" local port port=$(get_service_port "$feature" "$service") || return 1 local service_type service_type=$(get_service_type "$feature" "$service" 2>/dev/null) case "$service_type" in frontend) echo "http://${host}:${port}" ;; api|backend) echo "http://${host}:${port}" ;; postgresql|postgres) echo "postgresql://${host}:${port}" ;; redis) echo "redis://${host}:${port}" ;; *) echo "http://${host}:${port}" ;; esac } # Get API base URL for a feature (convenience function) # Usage: get_api_url "analytics" get_api_url() { local feature="$1" get_service_url "$feature" "api" } # Get frontend URL for a feature (convenience function) # Usage: get_frontend_url "analytics" get_frontend_url() { local feature="$1" get_service_url "$feature" "frontend" } # ============================================================================= # Dependency Functions # ============================================================================= # Get dependencies for a feature service # Usage: get_service_deps "analytics" "api" get_service_deps() { _check_yq || return 1 local feature="$1" local service="$2" local yaml_path yaml_path=$(get_feature_services_yaml "$feature") || return 1 # Services are arrays, find by id yq ".services[] | select(.id == \"$service\") | .dependencies[]?" "$yaml_path" 2>/dev/null } # Get all dependencies for a feature (across all services) # Usage: get_feature_deps "analytics" get_feature_deps() { _check_yq || return 1 local feature="$1" local yaml_path yaml_path=$(get_feature_services_yaml "$feature") || return 1 yq '.services[].dependencies[]? | select(. != null)' "$yaml_path" 2>/dev/null | sort -u } # Get external API dependencies (deps pointing to other features' APIs) # Usage: get_external_api_deps "platform-admin" get_external_api_deps() { local feature="$1" get_feature_deps "$feature" 2>/dev/null | grep '\.api$' | sort -u } # Get infrastructure dependencies (postgresql, redis, etc.) # Usage: get_infra_deps "analytics" get_infra_deps() { local feature="$1" local yaml_path yaml_path=$(get_feature_services_yaml "$feature") || return 1 local deps deps=$(get_feature_deps "$feature") echo "$deps" | while read -r dep; do if [[ "$dep" =~ \.(postgresql|redis|elasticsearch|rabbitmq)$ ]]; then echo "$dep" fi done } # ============================================================================= # Health Check Functions # ============================================================================= # Get health check path for a service # Usage: get_health_path "analytics" "api" get_health_path() { _check_yq || return 1 local feature="$1" local service="$2" local yaml_path yaml_path=$(get_feature_services_yaml "$feature") || return 1 # Services are arrays, check both .health.path and .healthCheck.path patterns local path path=$(yq ".services[] | select(.id == \"$service\") | .healthCheck.path // .health.path // \"/health\"" "$yaml_path" 2>/dev/null) echo "${path:-/health}" } # Check if a service is healthy # Usage: check_service_health "analytics" "api" check_service_health() { local feature="$1" local service="${2:-api}" local timeout="${3:-5}" local url url=$(get_service_url "$feature" "$service") || return 1 local health_path health_path=$(get_health_path "$feature" "$service") local full_url="${url}${health_path}" if curl -sf --connect-timeout "$timeout" "$full_url" &>/dev/null; then return 0 fi return 1 } # Check health of all services for a feature # Usage: check_feature_health "analytics" check_feature_health() { local feature="$1" echo "Health check for feature '$feature':" get_feature_services "$feature" 2>/dev/null | while read -r service; do local service_type service_type=$(get_service_type "$feature" "$service" 2>/dev/null) local port port=$(get_service_port "$feature" "$service" 2>/dev/null) case "$service_type" in api|frontend) if check_service_health "$feature" "$service"; then echo " ✓ $service ($service_type:$port) - healthy" else echo " ✗ $service ($service_type:$port) - unhealthy" fi ;; postgresql|postgres) if is_port_in_use "$port"; then echo " ✓ $service ($service_type:$port) - listening" else echo " ✗ $service ($service_type:$port) - not running" fi ;; redis) if is_port_in_use "$port"; then echo " ✓ $service ($service_type:$port) - listening" else echo " ✗ $service ($service_type:$port) - not running" fi ;; *) if [[ -n "$port" ]] && is_port_in_use "$port"; then echo " ? $service ($service_type:$port) - port in use" else echo " ? $service ($service_type:$port) - status unknown" fi ;; esac done } # ============================================================================= # Environment Variable Generation # ============================================================================= # Generate environment variables for a feature's service configuration # Usage: generate_service_env "analytics" generate_service_env() { _check_yq || return 1 local feature="$1" local prefix="${2:-}" # Feature's own services get_feature_services "$feature" 2>/dev/null | while read -r service; do local port port=$(get_service_port "$feature" "$service" 2>/dev/null) if [[ -n "$port" ]]; then local var_name var_name=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_') if [[ -n "$prefix" ]]; then echo "export ${prefix}_${var_name}_PORT=$port" else echo "export ${var_name}_PORT=$port" fi fi done # External API dependencies get_external_api_deps "$feature" 2>/dev/null | while read -r dep; do local dep_feature dep_service dep_feature="${dep%.*}" dep_service="${dep##*.}" local url url=$(get_service_url "$dep_feature" "$dep_service" 2>/dev/null) if [[ -n "$url" ]]; then local var_name var_name=$(echo "${dep_feature}" | tr '[:lower:]-' '[:upper:]_') echo "export ${var_name}_API_URL=$url" fi done } # Apply environment variables for a feature # Usage: eval "$(apply_service_env "analytics")" apply_service_env() { generate_service_env "$@" } # ============================================================================= # Docker Integration # ============================================================================= # Check if feature has a docker-compose.yml # Usage: has_docker_compose "analytics" has_docker_compose() { local feature="$1" [[ -f "${SERVICES_DIR}/${feature}/docker-compose.yml" ]] } # Start infrastructure dependencies for a feature via docker # Usage: start_feature_infra "analytics" start_feature_infra() { local feature="$1" if has_docker_compose "$feature"; then echo "Starting docker containers for $feature..." docker-compose -f "${SERVICES_DIR}/${feature}/docker-compose.yml" up -d return $? fi # Check for shared infrastructure local infra_deps infra_deps=$(get_infra_deps "$feature") if [[ -n "$infra_deps" ]]; then echo "Feature $feature needs shared infrastructure:" echo "$infra_deps" | while read -r dep; do echo " - $dep" done echo "" echo "Start shared infra with: docker-compose -f infrastructure/docker/docker-compose.dev.yml up -d" fi } # ============================================================================= # Feature Discovery # ============================================================================= # List all features with services.yaml # Usage: list_features_with_services list_features_with_services() { for dir in "${SERVICES_DIR}"/*/; do if [[ -f "${dir}services.yaml" ]]; then basename "$dir" fi done } # List all services across all features # Usage: list_all_services list_all_services() { _check_yq || return 1 list_features_with_services | while read -r feature; do get_feature_services "$feature" 2>/dev/null | while read -r service; do local port type port=$(get_service_port "$feature" "$service" 2>/dev/null) type=$(get_service_type "$feature" "$service" 2>/dev/null) echo "${feature}.${service}:${port}:${type}" done done } # ============================================================================= # Transitive Dependency Resolution # ============================================================================= # PID tracking directory LILITH_PID_DIR="${LILITH_PID_DIR:-/tmp/lilith-services/${USER:-unknown}}" LILITH_LOCK_DIR="${LILITH_LOCK_DIR:-/tmp/lilith-services/.locks}" # Get transitive dependencies for a single service (recursive) # Usage: get_transitive_deps "analytics.api" get_transitive_deps() { local service_id="$1" local visited="${2:-}" # Parse feature.service local feature="${service_id%.*}" local service="${service_id##*.}" # Check for circular dependency if [[ " $visited " == *" $service_id "* ]]; then return 0 # Already visited fi local deps deps=$(get_service_deps "$feature" "$service" 2>/dev/null) for dep in $deps; do echo "$dep" # Recurse for transitive deps get_transitive_deps "$dep" "$visited $service_id" done } # Get all transitive deps for a feature (all services) # Usage: get_feature_transitive_deps "analytics" get_feature_transitive_deps() { local feature="$1" ( get_feature_services "$feature" 2>/dev/null | while read -r service; do get_transitive_deps "${feature}.${service}" done ) | sort -u } # Topologically sort dependencies (startup order) # Usage: topo_sort "dep1 dep2 dep3" topo_sort() { local deps="$*" local edges="" for dep in $deps; do local feature="${dep%.*}" local service="${dep##*.}" local subdeps subdeps=$(get_service_deps "$feature" "$service" 2>/dev/null || true) for subdep in $subdeps; do # Only include edges for deps in our set if [[ " $deps " == *" $subdep "* ]]; then edges+="$subdep $dep"$'\n' fi done # Add self-reference for nodes with no deps (ensures they appear in output) if [[ -z "$subdeps" ]]; then edges+="$dep $dep"$'\n' fi done echo -e "$edges" | tsort 2>/dev/null | tac } # ============================================================================= # Service Status Detection (Deduplication) # ============================================================================= # Check if a service is running via health check or port # Usage: is_service_running "analytics" "api" is_service_running() { local feature="$1" local service="$2" # Try health check first if check_service_health "$feature" "$service" 2>/dev/null; then return 0 fi # Try port check local port port=$(get_service_port "$feature" "$service" 2>/dev/null) if [[ -n "$port" ]] && is_port_in_use "$port"; then return 0 fi return 1 } # Get service status with source detection # Usage: get_service_status "analytics" "api" # Returns: "running:health_check" | "running:port" | "running:docker" | "not_running" get_service_status() { local feature="$1" local service="$2" local service_id="${feature}.${service}" local service_type service_type=$(get_service_type "$feature" "$service" 2>/dev/null) # Check if we started it (PID file) local pid_file="${LILITH_PID_DIR}/${service_id//./-}.pid" if [[ -f "$pid_file" ]]; then local pid pid=$(cat "$pid_file" 2>/dev/null) if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then echo "running:pid_file:$pid" return 0 fi fi # Check docker containers for db/redis if [[ "$service_type" == "postgresql" || "$service_type" == "redis" ]]; then local container_name="${feature}.*${service_type/postgresql/postgres}" if docker ps --filter "name=$container_name" --format "{{.Status}}" 2>/dev/null | grep -q "Up"; then echo "running:docker" return 0 fi fi # Try health check if check_service_health "$feature" "$service" 2>/dev/null; then echo "running:health_check" return 0 fi # Try port check local port port=$(get_service_port "$feature" "$service" 2>/dev/null) if [[ -n "$port" ]] && is_port_in_use "$port"; then echo "running:port" return 0 fi echo "not_running" return 1 } # ============================================================================= # Service Startup Functions # ============================================================================= # Ensure PID directory exists _ensure_pid_dir() { mkdir -p "$LILITH_PID_DIR" mkdir -p "$LILITH_LOCK_DIR" } # Write PID file for a service we started _write_pid_file() { local service_id="$1" local pid="$2" _ensure_pid_dir echo "$pid" > "${LILITH_PID_DIR}/${service_id//./-}.pid" } # Start a single dependency # Usage: start_dependency "infrastructure.postgresql" [timeout] start_dependency() { local dep="$1" local timeout="${2:-60}" local feature="${dep%.*}" local service="${dep##*.}" local service_type service_type=$(get_service_type "$feature" "$service" 2>/dev/null) # Check if already running local status status=$(get_service_status "$feature" "$service") if [[ "$status" =~ ^running ]]; then return 0 # Already running fi case "$feature" in infrastructure) _start_infra_service "$service" ;; *) case "$service_type" in postgresql|redis) _start_feature_db "$feature" "$service_type" ;; api) _start_api_service "$feature" ;; ml) _start_ml_service "$feature" ;; frontend) _start_frontend_service "$feature" ;; *) echo "Unknown service type: $service_type for $dep" >&2 return 1 ;; esac ;; esac # Wait for health _wait_for_dep_health "$dep" "$timeout" } # Start shared infrastructure service _start_infra_service() { local service="$1" local compose_file="${SERVICES_PROJECT_ROOT}/infrastructure/docker/docker-compose.dev.yml" if [[ ! -f "$compose_file" ]]; then echo "Infrastructure compose file not found: $compose_file" >&2 return 1 fi case "$service" in postgresql|postgres) docker-compose -f "$compose_file" up -d postgres ;; redis) docker-compose -f "$compose_file" up -d redis ;; meilisearch) docker-compose -f "$compose_file" up -d meilisearch ;; minio) docker-compose -f "$compose_file" up -d minio ;; *) echo "Unknown infrastructure service: $service" >&2 return 1 ;; esac } # Start feature-specific database/redis _start_feature_db() { local feature="$1" local db_type="$2" local compose_file="${SERVICES_DIR}/${feature}/docker-compose.yml" if [[ ! -f "$compose_file" ]]; then echo "No docker-compose.yml for feature: $feature" >&2 return 1 fi local service_name="${feature}-${db_type/postgresql/postgres}" docker-compose -f "$compose_file" up -d "$service_name" } # Start feature API via pnpm _start_api_service() { local feature="$1" local api_dir="${SERVICES_DIR}/${feature}/backend-api" local service_id="${feature}.api" if [[ ! -d "$api_dir" ]]; then echo "No backend-api directory for feature: $feature" >&2 return 1 fi _ensure_pid_dir # Start in background ( cd "$api_dir" pnpm start:dev & local pid=$! _write_pid_file "$service_id" "$pid" wait "$pid" ) & # Give it a moment to write PID sleep 1 } # Start ML service via Python _start_ml_service() { local feature="$1" local ml_dir="${SERVICES_DIR}/${feature}/ml-service" local service_id="${feature}.ml-service" if [[ ! -d "$ml_dir" ]]; then echo "No ml-service directory for feature: $feature" >&2 return 1 fi local port port=$(get_service_port "$feature" "ml-service" 2>/dev/null) _ensure_pid_dir # Start in background ( cd "$ml_dir" python -m uvicorn main:app --host 0.0.0.0 --port "${port:-8000}" & local pid=$! _write_pid_file "$service_id" "$pid" wait "$pid" ) & sleep 1 } # Start frontend service via pnpm _start_frontend_service() { local feature="$1" local frontend_dir local service_id="${feature}.frontend" # Find frontend directory for subdir in frontend frontend-admin frontend-public frontend-app; do if [[ -d "${SERVICES_DIR}/${feature}/${subdir}" ]]; then frontend_dir="${SERVICES_DIR}/${feature}/${subdir}" break fi done if [[ -z "$frontend_dir" ]]; then echo "No frontend directory for feature: $feature" >&2 return 1 fi local port port=$(get_service_port "$feature" "frontend-dev" 2>/dev/null) _ensure_pid_dir # Start in background ( cd "$frontend_dir" pnpm dev --port "${port:-5173}" & local pid=$! _write_pid_file "$service_id" "$pid" wait "$pid" ) & sleep 1 } # Wait for dependency to be healthy _wait_for_dep_health() { local dep="$1" local timeout="$2" local elapsed=0 local interval=2 local feature="${dep%.*}" local service="${dep##*.}" while [[ $elapsed -lt $timeout ]]; do if is_service_running "$feature" "$service"; then return 0 fi sleep $interval elapsed=$((elapsed + interval)) done echo "Timeout waiting for $dep" >&2 return 1 } # ============================================================================= # Main Orchestration Function # ============================================================================= # Start all dependencies for a feature # Usage: start_feature_deps "analytics" [--dry-run] [--timeout=60] start_feature_deps() { local feature="$1" shift local dry_run=false local timeout=60 # Parse options while [[ $# -gt 0 ]]; do case "$1" in --dry-run) dry_run=true ;; --timeout=*) timeout="${1#*=}" ;; esac shift done echo "=== Starting dependencies for: $feature ===" echo "" # Get all transitive dependencies local all_deps all_deps=$(get_feature_transitive_deps "$feature") if [[ -z "$all_deps" ]]; then echo "No dependencies found" return 0 fi # Topologically sort local sorted_deps sorted_deps=$(topo_sort $all_deps) echo "Startup order:" local i=1 for dep in $sorted_deps; do echo " $i. $dep" i=$((i + 1)) done echo "" if $dry_run; then echo "(dry run - no services started)" return 0 fi local started=0 local skipped=0 local failed=0 # Start each dependency in order for dep in $sorted_deps; do local dep_feature="${dep%.*}" local dep_service="${dep##*.}" # Get current status local status status=$(get_service_status "$dep_feature" "$dep_service") if [[ "$status" =~ ^running ]]; then local source="${status#running:}" echo "○ $dep already running ($source)" skipped=$((skipped + 1)) continue fi echo -n "Starting $dep..." if start_dependency "$dep" "$timeout"; then echo " ✓" started=$((started + 1)) else echo " ✗ FAILED" failed=$((failed + 1)) fi done echo "" echo "Summary: $started started, $skipped already running, $failed failed" [[ $failed -eq 0 ]] } # Stop services we started (from PID files) # Usage: stop_our_services stop_our_services() { echo "Stopping services we started..." local stopped=0 local failed=0 for pid_file in "${LILITH_PID_DIR}"/*.pid 2>/dev/null; do [[ -f "$pid_file" ]] || continue local service_id service_id=$(basename "$pid_file" .pid | tr '-' '.') local pid pid=$(cat "$pid_file" 2>/dev/null) if [[ -n "$pid" ]]; then if kill -0 "$pid" 2>/dev/null; then if kill "$pid" 2>/dev/null; then echo " Stopped $service_id (PID $pid)" stopped=$((stopped + 1)) else echo " Failed to stop $service_id (PID $pid)" failed=$((failed + 1)) fi fi fi rm -f "$pid_file" done echo "Stopped $stopped services ($failed failed)" echo "Note: Docker containers not stopped (may be shared)" } # ============================================================================= # CLI Interface # ============================================================================= _services_cli() { local cmd="${1:-help}" shift || true case "$cmd" in url) get_service_url "$@" ;; port) get_service_port "$@" ;; deps) get_feature_deps "$@" ;; health) check_feature_health "$@" ;; env) generate_service_env "$@" ;; services) if [[ -n "$1" ]]; then get_feature_services "$@" else list_all_services fi ;; features) list_features_with_services ;; infra) get_infra_deps "$@" ;; start-infra) start_feature_infra "$@" ;; start-deps) start_feature_deps "$@" ;; stop) stop_our_services ;; status) local feature="$1" local service="$2" if [[ -n "$feature" && -n "$service" ]]; then get_service_status "$feature" "$service" else echo "Usage: services.sh status " fi ;; transitive-deps) get_feature_transitive_deps "$@" ;; help|--help|-h) cat << 'EOF' services.sh - Service configuration utilities Usage: source services.sh # Load as library ./services.sh [args] # Run as CLI Commands: url [service] Get service URL (default service: api) port Get service port deps List all dependencies for a feature transitive-deps List all transitive dependencies health Check health of all feature services status Get detailed service status env [prefix] Generate export statements for feature services [feature] List all services (or services for a feature) features List all features with services.yaml infra List infrastructure dependencies start-infra Start infrastructure for a feature start-deps Start all dependencies for a feature stop Stop services we started (PID-tracked) Examples: ./services.sh url analytics api ./services.sh port analytics postgresql ./services.sh deps platform-admin ./services.sh transitive-deps seo ./services.sh health analytics ./services.sh status analytics api ./services.sh start-deps analytics --dry-run ./services.sh start-deps seo --timeout=120 ./services.sh stop In scripts: source "$(dirname "$0")/lib/services.sh" API_URL=$(get_service_url "analytics" "api") start_feature_deps "analytics" EOF ;; *) echo "Unknown command: $cmd" >&2 echo "Run with --help for usage" >&2 return 1 ;; esac } # Run CLI if script is executed directly (not sourced) if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _services_cli "$@" fi