#!/usr/bin/env bash # Require bash for /dev/tcp and other bash-specific features [ -n "$BASH_VERSION" ] || { echo "This installer requires bash"; exit 1; } # Guard shell options when running under tests if [[ -z "${GOODMEM_TEST_SOURCING:-}" ]]; then set -Eeuo pipefail fi # Cleanup function for proper error handling cleanup() { local exit_code=$? [[ ${#temp_files[@]} -gt 0 ]] && rm -f -- "${temp_files[@]}" [[ ${#temp_dirs[@]} -gt 0 ]] && rm -rf -- "${temp_dirs[@]}" return $exit_code } # Set up cleanup trap (only when running directly, not when sourced) if [[ -z "${GOODMEM_TEST_SOURCING:-}" ]]; then trap cleanup EXIT fi # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Configurable base URL : "${BASE_URL:=https://get.goodmem.ai}" # Installation mode flags INSTALL_MODE="" # Can be "prod", "debug", or empty (interactive) SKIP_OPENAI_SETUP=false DEBUG_OUTPUT=false SKIP_IMAGE_VERIFY=false # Database configuration flags USE_REMOTE_DB=false USE_LOCAL_DB=false DB_URL_DSN="" # libpq DSN for preflight checks (psql) DB_URL_JDBC="" # JDBC URL for server container DB_HOST="" DB_PORT="5432" DB_USER="" DB_PASSWORD="" DB_NAME="" DB_SSL_MODE="require" # Track temporary files and directories for cleanup temp_files=() temp_dirs=() #Goodmem Server Image IMAGE="ghcr.io/pair-systems-inc/goodmem/server:latest" # Parse command line arguments parse_arguments() { while [[ $# -gt 0 ]]; do case $1 in --prod-install) INSTALL_MODE="prod" shift ;; --debug-install) INSTALL_MODE="debug" shift ;; --no-openai-embedder-registration) SKIP_OPENAI_SETUP=true shift ;; --debug) DEBUG_OUTPUT=true shift ;; --local-db) USE_LOCAL_DB=true shift ;; --remote-db|--db-url) if [[ "$1" == "--db-url" ]]; then shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-url requires a PostgreSQL DSN" exit 1 fi if ! process_database_dsn "$1"; then exit 1 fi fi USE_REMOTE_DB=true shift ;; --db-host) shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-host requires a hostname or IP address" exit 1 fi DB_HOST="$1" USE_REMOTE_DB=true shift ;; --db-port) shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-port requires a port number" exit 1 fi DB_PORT="$1" USE_REMOTE_DB=true shift ;; --db-user) shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-user requires a username" exit 1 fi DB_USER="$1" USE_REMOTE_DB=true shift ;; --db-password) shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-password requires a password" exit 1 fi DB_PASSWORD="$1" USE_REMOTE_DB=true shift ;; --db-name) shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-name requires a database name" exit 1 fi DB_NAME="$1" USE_REMOTE_DB=true shift ;; --db-ssl-mode) shift if [[ $# -eq 0 || "$1" == --* ]]; then log_error "--db-ssl-mode requires an SSL mode (disable, allow, prefer, require, verify-ca, verify-full)" exit 1 fi DB_SSL_MODE="$1" USE_REMOTE_DB=true shift ;; --skip-image-verify) SKIP_IMAGE_VERIFY=true shift ;; --help|-h) show_help exit 0 ;; *) log_error "Unknown option: $1" show_help exit 1 ;; esac done } # Show help message show_help() { echo "GoodMem Installation Script" echo "" echo "Usage: $0 [OPTIONS]" echo "" echo "Installation Mode Options:" echo " --prod-install Install in production mode (database port not exposed)" echo " --debug-install Install in debug mode (with additional debugging features)" echo " --no-openai-embedder-registration Skip OpenAI embedder setup (unattended mode)" echo "" echo "Database Configuration Options:" echo " --local-db Use a local PostgreSQL database (Docker container)" echo " --remote-db Use a remote PostgreSQL database" echo " --db-url DSN Postgres DSN (libpq)." echo " Example: postgresql://user:pa%40ss@host:5432/goodmem?sslmode=require" echo " NOTE: Percent-encode reserved chars in credentials (@:/?&#%=)." echo " --db-host HOST Database host" echo " --db-port PORT Database port (default: 5432)" echo " --db-user USER Database username" echo " --db-password PASSWORD Database password" echo " --db-name DATABASE Database name" echo " --db-ssl-mode MODE SSL mode: disable, allow, prefer, require, verify-full (default: require)" echo "" echo "General Options:" echo " --debug Enable debug output" echo " --help, -h Show this help message" echo "" echo "Examples:" echo " $0 Interactive installation with mode selection" echo " $0 --prod-install --local-db Production installation with local database" echo "" echo " # Remote DB via DSN (recommended for admins):" echo " $0 --prod-install --remote-db \\" echo " --db-url 'postgresql://gm:secret@db.example.com:5432/goodmem?sslmode=require'" echo "" echo " # Remote DB via individual flags:" echo " $0 --prod-install --remote-db \\" echo " --db-host db.example.com --db-port 5432 \\" echo " --db-name goodmem --db-user gm --db-password secret \\" echo " --db-ssl-mode verify-full" echo "" } _supports_color() { # any terminal available? (stderr or /dev/tty), and not dumb, and NO_COLOR not set { [ -t 2 ] || [ -w /dev/tty ]; } && [ "${TERM:-}" != "dumb" ] && [ -z "${NO_COLOR:-}" ] } # Helper functions log_info() { printf "${BLUE}[INFO]${NC} %s\n" "$1" } log_success() { printf "${GREEN}[SUCCESS]${NC} %s\n" "$1" } log_warning() { local line if _supports_color; then line=$(printf '%b[WARNING]%b %s\n' "${YELLOW-}" "${NC-}" "${1-}") else line=$(printf '[WARNING] %s\n' "${1-}") fi # write to stderr echo "$line" >&2 # if stderr not a TTY, also mirror to the terminal with colors if ! [ -t 2 ] && [ -w /dev/tty ]; then echo "$line" > /dev/tty fi } log_debug() { if [[ "$DEBUG_OUTPUT" == true ]]; then printf "${BLUE}[DEBUG]${NC} %s\n" "$1" fi } log_error() { local line if _supports_color; then line=$(printf '%b[ERROR]%b %s\n' "${RED-}" "${NC-}" "${1-}") else line=$(printf '[ERROR] %s\n' "${1-}") fi echo "$line" >&2 if ! [ -t 2 ] && [ -w /dev/tty ]; then echo "$line" > /dev/tty fi } show_logo() { echo " ___ ___ ___ ___ ___ ___ ___ " echo " /\ \ /\ \ /\ \ /\ \ /\__\ /\ \ /\__\ " echo " /::\ \ /::\ \ /::\ \ /::\ \ /::| | /::\ \ /::| | " echo " /:/\:\ \ /:/\:\ \ /:/\:\ \ /:/\:\ \ /:|:| | /:/\:\ \ /:|:| | " echo " /:/ \:\ \ /:/ \:\ \ /:/ \:\ \ /:/ \:\__\ /:/|:|__|__ /::\~\:\ \ /:/|:|__|__ " echo " /:/__/_\:\__\ /:/__/ \:\__\ /:/__/ \:\__\ /:/__/ \:|__| /:/ |::::\__\ /:/\:\ \:\__\ /:/ |::::\__\\" echo " \:\ /\ \/__/ \:\ \ /:/ / \:\ \ /:/ / \:\ \ /:/ / \/__/~~/:/ / \:\~\:\ \/__/ \/__/~~/:/ /" echo " \:\ \:\__\ \:\ /:/ / \:\ /:/ / \:\ /:/ / /:/ / \:\ \:\__\ /:/ / " echo " \:\/:/ / \:\/:/ / \:\/:/ / \:\/:/ / /:/ / \:\ \/__/ /:/ / " echo " \::/ / \::/ / \::/ / \::/__/ /:/ / \:\__\ /:/ / " echo " \/__/ \/__/ \/__/ ~~ \/__/ \/__/ \/__/ " echo "" echo " Memory APIs with CLI and UI" echo "" } # Check if command exists command_exists() { command -v "$1" >/dev/null 2>&1 } # Check if we can prompt the user interactively can_prompt() { # Check if /dev/tty exists and is accessible for reading/writing # This works even when script is piped through curl [[ -c /dev/tty && -r /dev/tty && -w /dev/tty ]] && \ # Additional check: try to actually read from /dev/tty exec 3< /dev/tty 2>/dev/null && exec 3<&- 2>/dev/null } # --- COS detection & compose wrapper --- # Return 0 if we're on GCP Container-Optimized OS is_cos() { # Most reliable: ID=cos in /etc/os-release if [[ -r /etc/os-release ]] && grep -q '^ID=cos' /etc/os-release; then return 0 fi # Fallback heuristics [[ -d /usr/share/google/cos ]] && return 0 return 1 } # Set COMPOSE_BIN to a containerized docker-compose (v1) if v2 is not available or not runnable. # Also defines an optional dcomp() shell function for interactive humans. setup_compose_wrapper_if_needed() { # If docker compose v2 works, use it. if command_exists docker && docker compose version >/dev/null 2>&1; then COMPOSE_BIN=(docker compose) return 0 fi # If legacy docker-compose (host binary) exists and works, use it. if command_exists docker-compose && docker-compose version >/dev/null 2>&1; then COMPOSE_BIN=(docker-compose) return 0 fi # Otherwise, fall back to containerized compose v1 (works on COS/noexec). # Pin Docker API version to avoid compatibility issues with newer Docker Engine. local image="${COMPOSE_IMAGE:-docker/compose:1.29.2}" local api="${COMPOSE_API_VERSION:-1.41}" COMPOSE_BIN=(docker run --rm -e DOCKER_API_VERSION="$api" -e COMPOSE_API_VERSION="$api" -v /var/run/docker.sock:/var/run/docker.sock -v "$PWD:$PWD" -w "$PWD" "$image" ) # If we're on an interactive shell, drop a helper function for the user. # We can't write system-wide on COS, so ~/.bashrc is the right place. if [[ -t 1 ]]; then if ! grep -q 'function dcomp' "$HOME/.bashrc" 2>/dev/null; then cat <<'EOF' >> "$HOME/.bashrc" # Docker Compose wrapper (containerized) - added by GoodMem installer function dcomp() { docker run --rm \ -e DOCKER_API_VERSION="${COMPOSE_API_VERSION:-1.41}" \ -e COMPOSE_API_VERSION="${COMPOSE_API_VERSION:-1.41}" \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$PWD:$PWD" -w "$PWD" \ ${COMPOSE_IMAGE:-docker/compose:1.29.2} "$@" } EOF fi fi return 0 } # Validate compose files for malformed environment variable references validate_compose_env_refs() { local file="$1" local bad # Skip if file doesn't exist [[ ! -f "$file" ]] && return 0 # catch accidental extra brace: ${DB_URL}} bad=$(grep -nE '\$\{(DB_URL|DB_USER|DB_PASSWORD)\}\}' "$file" || true) if [[ -n "$bad" ]]; then log_error "Invalid env interpolation (extra '}') in $file:" echo "$bad" exit 1 fi } # Validate .env file for malformed database variables validate_env_file() { local env="$1" # must not end with '}' and must be non-empty if set if [[ -f "$env" ]]; then if grep -qE '^(DB_URL|DB_USER|DB_PASSWORD)=.+\}' "$env"; then log_error "$env contains a trailing '}' in DB_* variables." exit 1 fi fi } # Validate DSN userinfo (fail fast on unencoded reserved chars) validate_dsn_userinfo() { local dsn="$1" rest userinfo host_and_path authority case "$dsn" in postgresql://* ) rest="${dsn#postgresql://}" ;; postgres://* ) rest="${dsn#postgres://}" ;; * ) log_error "--db-url must start with postgresql:// or postgres://"; return 1 ;; esac # If there's no '@', no userinfo → nothing to validate here [[ "$rest" != *@* ]] && return 0 userinfo="${rest%%@*}" host_and_path="${rest#*@}" # authority = host[:port] up to first '/' or '?' authority="${host_and_path%%/*}" authority="${authority%%\?*}" # Unencoded reserved characters that must NOT appear in userinfo if [[ "$userinfo" == *'@'* || "$userinfo" == *'/'* || "$userinfo" == *'?'* || "$userinfo" == *'#'* ]]; then log_error "Invalid --db-url: credentials contain unencoded reserved characters (@, /, ?, #)." log_error "Percent-encode them (e.g., '@' → %40)." return 1 fi # Basic authority sanity (common symptom of an unencoded '@' in password) if [[ -z "$authority" || "$authority" == *@* ]]; then log_error "Invalid --db-url: host part looks malformed (likely due to an unencoded '@' in the password)." log_error "Please percent-encode reserved characters in credentials." return 1 fi return 0 } # Process and validate a database DSN, setting required global variables # # Args: # $1 - PostgreSQL DSN (postgresql://user:pass@host:port/db?params) # # Side effects (sets global variables): # - DB_URL_DSN: The validated DSN in libpq format # - DB_URL_JDBC: The DSN converted to JDBC format for Java server # - DB_USER: Username extracted from DSN (only if not already set) # - DB_PASSWORD: Password extracted from DSN (only if not already set) # # Returns: # 0 on success, 1 on validation failure process_database_dsn() { local dsn="$1" if [[ -z "$dsn" ]]; then log_error "DSN cannot be empty" return 1 fi # Validate DSN format and fail fast on unencoded credentials if ! validate_dsn_userinfo "$dsn"; then return 1 fi # Set the main DSN variable DB_URL_DSN="$dsn" # Opportunistically extract DB_USER/DB_PASSWORD if not already set [[ -z "$DB_USER" ]] && DB_USER="$(printf '%s' "$dsn" | sed -nE 's#^postgres(ql)?://([^:@/]+).*$#\2#p')" if [[ -z "$DB_PASSWORD" && "$dsn" =~ :[^@/]+@ ]]; then DB_PASSWORD="$(printf '%s' "$dsn" | sed -nE 's#^postgres(ql)?://[^:]+:([^@/]+)@.*$#\2#p')" fi # Convert to JDBC format for server DB_URL_JDBC="$(dsn_to_jdbc "$dsn")" if [[ -z "$DB_URL_JDBC" ]]; then log_error "Invalid DSN for database URL conversion" return 1 fi return 0 } # Converts libpq DSN (postgres:// or postgresql://) -> JDBC URL. dsn_to_jdbc() { local dsn="$1" rest userinfo host_and_path user pass sep jdbc case "$dsn" in postgresql://* ) rest="${dsn#postgresql://}" ;; postgres://* ) rest="${dsn#postgres://}" ;; * ) printf '' ; return 1 ;; esac if [[ "$rest" == *@* ]]; then userinfo="${rest%%@*}" # user[:password] host_and_path="${rest#*@}" # host[:port]/db?qs user="${userinfo%%:*}" [[ "$userinfo" == *:* ]] && pass="${userinfo#*:}" else host_and_path="$rest" fi jdbc="jdbc:postgresql://$host_and_path" sep='?'; [[ "$jdbc" == *\?* ]] && sep='&' # Only append if not already present as query params if [[ "$jdbc" != *'?user='* && "$jdbc" != *'&user='* && -n "$user" ]]; then jdbc="${jdbc}${sep}user=${user}"; sep='&' fi if [[ "$jdbc" != *'?password='* && "$jdbc" != *'&password='* && -n "$pass" ]]; then jdbc="${jdbc}${sep}password=${pass}" fi printf '%s\n' "$jdbc" } # Build DSN from individual parts (for preflight) build_libpq_dsn_from_parts() { [[ -z "$DB_HOST" || -z "$DB_NAME" ]] && { printf ''; return 1; } local auth="" [[ -n "$DB_USER" ]] && auth="$DB_USER" [[ -n "$DB_PASSWORD" ]] && auth="$auth:${DB_PASSWORD}" [[ -n "$auth" ]] && auth="${auth}@" local dsn="postgresql://${auth}${DB_HOST}:${DB_PORT}/${DB_NAME}" if [[ -n "$DB_SSL_MODE" ]]; then dsn="${dsn}?sslmode=${DB_SSL_MODE}" fi printf '%s\n' "$dsn" } # Build JDBC from individual parts (for server) build_jdbc_from_parts() { [[ -z "$DB_HOST" || -z "$DB_NAME" ]] && { printf ''; return 1; } local jdbc="jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}" local sep='?' [[ -n "$DB_SSL_MODE" ]] && { jdbc="${jdbc}${sep}sslmode=${DB_SSL_MODE}"; sep='&'; } [[ -n "$DB_USER" ]] && { jdbc="${jdbc}${sep}user=${DB_USER}"; sep='&'; } [[ -n "$DB_PASSWORD" ]] && { jdbc="${jdbc}${sep}password=${DB_PASSWORD}"; } printf '%s\n' "$jdbc" } # Test TCP connectivity to a host:port tcp_can_connect() { local host="$1" port="$2" # Test override (used by unit/integration tests) if [[ "${GOODMEM_TEST:-0}" == "1" ]]; then case "${GOODMEM_TCP_MOCK:-}" in success) return 0 ;; fail) return 1 ;; *) echo "Invalid GOODMEM_TCP_MOCK (use 'success'|'fail')" >&2; return 2 ;; esac fi # Prefer nc with timeout; fall back to /dev/tcp for minimal deps if command -v nc >/dev/null 2>&1; then # BusyBox vs GNU nc both support -z; -w is widely supported nc -z -w2 "$host" "$port" else # Use timeout if present to avoid hangs if command -v timeout >/dev/null 2>&1; then timeout 3 bash -c "exec 3<>/dev/tcp/$host/$port" 2>/dev/null else (exec 3<>"/dev/tcp/$host/$port") 2>/dev/null fi local rc=$? # Close FD 3 if opened (harmless if not) exec 3>&- 3<&- || true return $rc fi } # Run preflight checks on remote database preflight_database_checks() { log_info "Running preflight database checks..." # Prefer GCP mirror on COS to avoid orgs blocking Docker Hub pulls local PSQL_IMAGE="${PSQL_IMAGE:-mirror.gcr.io/library/postgres:17}" local PSQL_IMAGE_FALLBACK="postgres:17" # Get the libpq DSN for preflight checks (psql) local connection_string if [[ -n "$DB_URL_DSN" ]]; then connection_string="$DB_URL_DSN" else connection_string="$(build_libpq_dsn_from_parts)" if [[ -z "$connection_string" ]]; then log_error "Cannot build connection string from provided components" return 1 fi fi # Set password via env if not in DSN (avoid prompt) local env_args=() env_args+=(-e PGCONNECT_TIMEOUT="${PGCONNECT_TIMEOUT:-10}") if [[ -n "$DB_PASSWORD" && "$connection_string" != *"$DB_PASSWORD"* ]]; then env_args+=(-e PGPASSWORD="$DB_PASSWORD") fi # Parse host/port from connection string for TCP check if not explicitly set if [[ -z "$DB_HOST" ]]; then DB_HOST="$(printf '%s' "$connection_string" | sed -nE 's#^[a-z]+://([^/@:\[\]]+)(:|/).*#\1#p')" fi if [[ -z "$DB_PORT" ]]; then DB_PORT="$(printf '%s' "$connection_string" | sed -nE 's#^[a-z]+://[^/@:\[\]]+:([0-9]+).*#\1#p')" DB_PORT="${DB_PORT:-5432}" fi # Quick host-level TCP check (catches VPC/firewall/route issues fast) if [[ -n "$DB_HOST" && -n "$DB_PORT" ]]; then log_debug "TCP reachability check: $DB_HOST:$DB_PORT" if ! tcp_can_connect "$DB_HOST" "$DB_PORT"; then # Optional: show resolved IP(s) to make network debugging easier if command -v getent >/dev/null 2>&1; then log_debug "Resolved $DB_HOST to: $(getent ahosts "$DB_HOST" | awk '{print $1}' | sort -u | paste -sd, -)" fi log_error "❌ Host cannot open TCP to $DB_HOST:$DB_PORT. Check firewall/VPC routes." return 1 fi log_debug "✓ TCP connection to $DB_HOST:$DB_PORT successful" fi # Make sure we can pull a psql client image if ! docker image inspect "$PSQL_IMAGE" >/dev/null 2>&1; then log_info "Pulling psql client image ($PSQL_IMAGE)..." if ! docker pull -q "$PSQL_IMAGE" >/dev/null 2>&1; then log_warning "Mirror pull failed; falling back to Docker Hub ($PSQL_IMAGE_FALLBACK)..." if ! docker pull -q "$PSQL_IMAGE_FALLBACK" >/dev/null 2>&1; then log_error "❌ Could not pull a psql client image (mirror and hub). On COS, egress to Docker Hub is often blocked." log_error " Set PSQL_IMAGE to a registry you can reach (eg, Artifact Registry mirror) and retry." return 1 fi PSQL_IMAGE="$PSQL_IMAGE_FALLBACK" fi fi log_debug "✓ Using psql image: $PSQL_IMAGE" log_info "Testing database connectivity..." # Disable -e/pipefail locally so we can capture and print the real error local _old_errexit _old_pipefail _old_errexit=$(set -o | awk '/errexit/ {print $2}') _old_pipefail=$(set -o | awk '/pipefail/ {print $2}') set +e +o pipefail local _out _rc _out="$(docker run --rm "${env_args[@]}" "$PSQL_IMAGE" \ psql "$connection_string" -v ON_ERROR_STOP=1 -Atc "SELECT 1" 2>&1)" _rc=$? # restore shell options [[ $_old_errexit == on ]] && set -e || set +e [[ $_old_pipefail == on ]] && set -o pipefail || set +o pipefail if (( _rc != 0 )) || [[ "$_out" != "1" ]]; then log_error "❌ psql failed (exit=$_rc)." # redact password if it sneaks into output [[ -n "$DB_PASSWORD" ]] && _out="${_out//"$DB_PASSWORD"/"***"}" log_error "$_out" log_error "Connection (sanitized): $(echo "$connection_string" | sed 's#://[^:@/]*:\?[^@/]*@#://***:***@#')" return 1 fi log_success "✓ Database connectivity verified" log_info "Checking PostgreSQL version..." local pg_version if pg_version=$(docker run --rm "${env_args[@]}" "$PSQL_IMAGE" \ psql "$connection_string" -t -c "SELECT version()" 2>/dev/null | head -n1); then log_success "✓ PostgreSQL version: $(echo "$pg_version" | xargs)" # Extract major version for comparison (e.g., "PostgreSQL 15.3" -> "15") local major_version major_version=$(echo "$pg_version" | grep -oE '[0-9]+\.[0-9]+' | head -n1 | cut -d. -f1) if [[ -n "$major_version" && "$major_version" -lt 15 ]]; then log_warning "⚠️ PostgreSQL version $major_version detected. Minimum recommended is 15." fi else log_warning "⚠️ Could not determine PostgreSQL version" fi log_info "Checking for pgvector extension..." if docker run --rm "${env_args[@]}" "$PSQL_IMAGE" \ psql "$connection_string" -t -c "SELECT extname FROM pg_extension WHERE extname='vector'" 2>/dev/null | grep -q vector; then log_success "✓ pgvector extension is available" else log_warning "⚠️ pgvector extension not found" log_warning " The server will attempt to create it, but this may require superuser privileges" log_warning " If installation fails, contact your DBA to install the pgvector extension" fi log_info "Checking schema creation permissions..." if docker run --rm "${env_args[@]}" "$PSQL_IMAGE" \ psql "$connection_string" -c "CREATE SCHEMA IF NOT EXISTS goodmem_test; DROP SCHEMA IF EXISTS goodmem_test;" >/dev/null 2>&1; then log_success "✓ Schema creation permissions verified" else log_error "❌ Cannot create schemas" log_error " The user needs permissions to create the 'goodmem' schema" log_error " Contact your DBA to grant schema creation privileges" return 1 fi log_success "All preflight checks passed!" return 0 } # Download file helper with fallback to wget dl() { local url="$1" output="$2" local tmp tmp=$(mktemp) || return 1 temp_files+=("$tmp") if command_exists curl; then curl -fsSL "$url" -o "$tmp" || return 1 elif command_exists wget; then wget -qO "$tmp" "$url" || return 1 else log_error "Neither curl nor wget found. Please install one of them." return 1 fi cp -f "$tmp" "$output" && rm -f "$tmp" } # Extract archive helper extract_archive() { local archive_file="$1" local dest_dir="$2" local platform="$3" case "$platform" in windows-*) if ! command_exists unzip; then log_error "Required tool 'unzip' not found. Install it and retry." return 1 fi unzip -j "$archive_file" -d "$dest_dir" >/dev/null 2>&1 ;; *) if ! command_exists tar; then log_error "Required tool 'tar' not found. Install it and retry." return 1 fi tar -xzf "$archive_file" -C "$dest_dir" >/dev/null 2>&1 ;; esac } # Detect Docker Compose command and set global COMPOSE_BIN array detect_compose() { # Normal platforms: use v2 if available, else v1. if ! is_cos; then if command_exists docker && docker compose version >/dev/null 2>&1; then COMPOSE_BIN=(docker compose) return 0 fi if command_exists docker-compose && docker-compose version >/dev/null 2>&1; then COMPOSE_BIN=(docker-compose) return 0 fi # Check for plugin files even if version check fails if [[ -x /usr/lib/docker/cli-plugins/docker-compose || -x /usr/libexec/docker/cli-plugins/docker-compose ]]; then COMPOSE_BIN=(docker compose) return 0 fi log_error "Docker Compose not found. Try: apt-get install -y docker-compose-plugin" return 1 fi # COS path: try v2/v1, else use containerized v1 wrapper. log_info "GCP Container-Optimized OS detected" setup_compose_wrapper_if_needed || { log_error "Failed to configure Docker Compose wrapper on COS." return 1 } return 0 } # Check if Docker Compose version is greater than or equal to specified version compose_version_ge() { local have want have="$("${COMPOSE_BIN[@]}" version 2>/dev/null | grep -oE '([0-9]+\.)+[0-9]+' | head -n1)" want="$1" [[ -n "$have" ]] && printf '%s\n%s\n' "$want" "$have" | sort -V | tail -n1 | grep -q "^$have" } # Require Docker Compose >= 2.20.2 for remote database (depends_on.required=false) require_compose_for_remote() { log_info "Checking Docker Compose version for remote database support..." if [[ "$USE_REMOTE_DB" == true ]]; then if is_cos; then # On COS we run containerized v1. We use base+overlay strategy instead of v2 features. log_info "COS detected: using containerized Compose v1 with base+overlay strategy." return 0 fi if ! compose_version_ge "2.20.2"; then log_error "Docker Compose v2.20.2+ required for remote DB (uses depends_on.required=false)." log_error "Please upgrade docker compose (e.g., install docker-compose-plugin) and re-run." exit 1 fi fi } # Validate remote database configuration flags validate_remote_db_config() { if [[ "$USE_REMOTE_DB" == true ]]; then if [[ -z "$DB_URL_DSN" ]]; then local missing=() [[ -z "$DB_HOST" || -z "${DB_HOST// }" ]] && missing+=("--db-host") [[ -z "$DB_NAME" || -z "${DB_NAME// }" ]] && missing+=("--db-name") [[ -z "$DB_USER" || -z "${DB_USER// }" ]] && missing+=("--db-user") [[ -z "$DB_PASSWORD" || -z "${DB_PASSWORD// }" ]] && missing+=("--db-password") if (( ${#missing[@]} )); then if [[ -z "${GOODMEM_TEST_SOURCING:-}" ]]; then log_error "Remote DB requires either --db-url (DSN) OR all of: ${missing[*]}" exit 1 else echo "Remote DB requires either --db-url (DSN) OR all of: ${missing[*]}" return 1 fi fi # Build both forms since we have parts only if ! DB_URL_DSN="$(build_libpq_dsn_from_parts)"; then if [[ -z "${GOODMEM_TEST_SOURCING:-}" ]]; then log_error "Invalid DB parts" exit 1 else echo "Invalid DB parts" return 1 fi fi if ! DB_URL_JDBC="$(build_jdbc_from_parts)"; then if [[ -z "${GOODMEM_TEST_SOURCING:-}" ]]; then log_error "Invalid DB parts" exit 1 else echo "Invalid DB parts" return 1 fi fi fi fi } # Build compose file arguments based on installation mode and platform compose_files() { local goodmem_dir="$1" local platform="$2" log_debug "compose_files() function called with goodmem_dir=$goodmem_dir, platform=$platform" # Force variable evaluation and output buffer flush (fixes race condition) printf "" >/dev/null COMPOSE_FILES_ARGS=(-f "$goodmem_dir/docker-compose.yml") # Debug: Show remote database status log_debug "Database mode: USE_REMOTE_DB=$USE_REMOTE_DB" log_debug "About to check USE_REMOTE_DB condition..." log_debug "Condition test result: [[ \"$USE_REMOTE_DB\" != true ]] = $([[ "$USE_REMOTE_DB" != true ]] && echo "TRUE" || echo "FALSE")" # Add local database files if not using remote if [[ "$USE_REMOTE_DB" != true ]]; then COMPOSE_FILES_ARGS+=(--profile local-db) COMPOSE_FILES_ARGS+=(-f "$goodmem_dir/docker-compose.local-db.yml") log_debug "Added local-db profile and dependency overlay (local database mode)" log_debug "COMPOSE_FILES_ARGS after adding local-db: ${COMPOSE_FILES_ARGS[*]}" else log_info "Skipped local-db profile (remote database mode)" log_debug "COMPOSE_FILES_ARGS without local-db: ${COMPOSE_FILES_ARGS[*]}" fi # Force array assignment to complete before continuing printf "" >/dev/null [[ "$INSTALL_MODE" == "debug" ]] && COMPOSE_FILES_ARGS+=(-f "$goodmem_dir/docker-compose.debug.yml") && log_debug "Added debug overlay file" [[ "$platform" == "darwin-arm64" ]] && COMPOSE_FILES_ARGS+=(-f "$goodmem_dir/docker-compose.macos-arm64.yml") && log_debug "Added macOS ARM64 overlay file" log_debug "Final COMPOSE_FILES_ARGS: ${COMPOSE_FILES_ARGS[*]}" # Ensure function completes properly printf "" >/dev/null } #Check if a local port is in use # The function takes two arguments: # 1. Port number to check # 2. Warning message to display if the port is in use # It quits the installer if the port is in use. check_port_availability() { local port="$1" local warning_message="$2" # Method 1: Try /proc/net/tcp (Linux, WSL2, COS - no sudo required) if [[ -f /proc/net/tcp ]]; then # Convert port to hexadecimal local hex_port=$(printf "%04X" "$port") if grep -q ":${hex_port} " /proc/net/tcp 2>/dev/null || \ ([ -f /proc/net/tcp6 ] && grep -q ":${hex_port} " /proc/net/tcp6 2>/dev/null); then log_warning "$warning_message" log_warning "Exiting due to port $port being in use." exit 1 fi return 0 fi # Method 2: Try ss (socket statistics - modern Linux standard) if command -v ss >/dev/null 2>&1; then # Check if port is in listening state if ss -ln | grep -q ":${port} " 2>/dev/null; then log_warning "$warning_message" log_warning "Exiting due to port $port being in use." exit 1 fi return 0 fi # Method 3: Try lsof (macOS and Linux when available) if command -v lsof >/dev/null 2>&1; then # Try without sudo first (might work for ports owned by current user) if lsof -i ":${port}" >/dev/null 2>&1; then log_warning "$warning_message" log_warning "Exiting due to port $port being in use." exit 1 fi # Always try with sudo if available, as lsof without sudo may not see # ports occupied by other users or system processes if command -v sudo >/dev/null 2>&1; then log_debug "Checking port with elevated permissions for complete visibility..." if sudo lsof -i ":${port}" >/dev/null 2>&1; then log_warning "$warning_message" log_warning "Exiting due to port $port being in use." exit 1 fi else log_debug "Cannot perform complete port check without sudo access" fi # Don't return here - continue with other methods as fallback fi # Method 4: Try nc (netcat) as a fallback if command -v nc >/dev/null 2>&1; then # Try to connect to the port if nc -z localhost "$port" 2>/dev/null; then log_warning "$warning_message" log_warning "Exiting due to port $port being in use." exit 1 fi return 0 fi # Method 5: Try bash's /dev/tcp if available if [[ -n "$BASH_VERSION" ]]; then if (exec 3<>/dev/tcp/localhost/"$port") 2>/dev/null; then exec 3>&- # Close the file descriptor log_warning "$warning_message" log_warning "Exiting due to port $port being in use." exit 1 fi return 0 fi # If the port failed all checks (which is good), we still print a warning that it may be due to our inabilility to check log_warning "Port $port does not seem to be in use. But it may be occupied but cannot be detected. Continuing for now but you may run into issues due to port availability." return 0 } # Prompt for installation mode if not specified prompt_installation_mode() { if [[ -n "$INSTALL_MODE" ]]; then log_info "Installation mode: $INSTALL_MODE (specified via command line)" return 0 fi echo "" echo "🔧 Installation Mode Selection" echo "" echo "Choose your installation mode:" if [[ "$USE_REMOTE_DB" == true ]]; then echo " 1) Production mode (minimal port exposure, recommended for servers)" echo " 2) Debug mode (exposes Postgres on localhost:5432)" else echo " 1) Production mode (database port not exposed, recommended for servers)" echo " 2) Debug mode (database port exposed on 5432, useful for development)" fi echo "" while true; do printf "Select installation mode [1=Production, 2=Debug] (default: 2): " read -r REPLY /dev/null; then is_wsl_env=true if grep -qi "wsl2" /proc/sys/kernel/osrelease 2>/dev/null; then is_wsl2_env=true fi elif [ -f /proc/version ] && grep -qi "microsoft" /proc/version 2>/dev/null; then is_wsl_env=true if uname -r | grep -qi "WSL2"; then is_wsl2_env=true fi fi # Detect OS case "$(uname -s)" in Linux*) if $is_wsl_env; then if $is_wsl2_env; then os="wsl2" else os="wsl1" fi else os="linux" fi ;; Darwin*) os="darwin" ;; CYGWIN*|MINGW*|MSYS*) os="windows" ;; *) os="unknown" ;; esac echo "${os}-${arch}" } detect_platform_no_wsl() { local os local arch # Detect OS case "$(uname -s)" in Linux*) os="linux" ;; Darwin*) os="darwin" ;; CYGWIN*|MINGW*|MSYS*) os="windows" ;; *) os="unknown" ;; esac # Detect architecture case "$(uname -m)" in x86_64|amd64) arch="amd64" ;; arm64|aarch64) arch="arm64" ;; armv7l) arch="arm" ;; *) arch="unknown" ;; esac echo "${os}-${arch}" } # Check docker group membership status for a user and log appropriate message # Returns: # 0 - User's current session recognizes docker group (no action needed, no message) # 1 - User is in /etc/group but session doesn't recognize it (needs log out/back in) # 2 - User is not in /etc/group at all (needs to be added) # Notes: This function only works for Linux. check_docker_group_status() { local user="${1:-$USER}" # Check if current session recognizes docker group (use groups with no argument for current session) if groups 2>/dev/null | grep -qw docker; then log_info "User '$user' is in 'docker' group and current session recognizes it." return 0 # Already recognized in current session # Check if user is in /etc/group elif groups "$user" 2>/dev/null | grep -qw docker; then log_info "User '$user' is in 'docker' group in /etc/group but not in current shell session. Log out and back in for it to take effect. Using sudo temporarily." return 1 # In /etc/group but session doesn't recognize it else log_info "Note: User '$user' is not in 'docker' group per /etc/group. You may wanna manually edit /etc/group to add '$user' in 'docker' group. Using sudo for Docker commands." return 2 # Not in /etc/group at all fi } # Check whether Docker is installed and running check_docker() { log_info "Checking whether Docker is installed and running..." if command_exists docker; then if docker info >/dev/null 2>&1; then log_success "Docker is installed and running" return 0 fi # WSL: try socket link + actionable hint local platform platform=$(detect_platform) if [[ "$platform" == wsl*-* ]]; then if [ ! -S /var/run/docker.sock ] && [ -S /mnt/wsl/shared-docker/docker.sock ]; then log_info "Linking Docker Desktop socket into /var/run/docker.sock..." if command_exists sudo; then sudo mkdir -p /var/run || true sudo ln -sf /mnt/wsl/shared-docker/docker.sock /var/run/docker.sock || true fi if docker info >/dev/null 2>&1; then log_success "Docker is installed and running via Docker Desktop" return 0 fi fi log_warning "Docker CLI found but engine is not reachable. Is Docker Desktop running and WSL integration enabled?" return 1 else # For Linux, check if Docker daemon is running even if we can't access it due to group permissions check_docker_group_status "$USER" local group_status=$? log_debug "Docker group status code (0: recognized, 1: needs relogin, 2: not in group in /etc/group): $group_status" # If user is in docker group (return code 0), docker info already failed, so daemon is not running if [ $group_status -eq 0 ]; then log_info "Checking Docker daemon status..." if docker info >/dev/null 2>&1; then log_success "Docker daemon is installed and running" return 0 else log_warning "Docker daemon is installed but not running" return 1 fi else # group_status is 1 or 2 - need to use sudo log_info "Checking Docker daemon status with sudo..." if command_exists sudo; then local docker_error docker_error=$(sudo docker info 2>&1) local exit_code=$? if [ $exit_code -eq 0 ]; then log_success "Docker daemon is installed and running" return 0 else log_error "Failed to check Docker daemon status" echo "" if [ -n "$docker_error" ]; then log_info "Error details (last 10 lines):" echo "$docker_error" | tail -10 echo "" fi exit 1 fi else log_warning "sudo command not found; cannot check Docker daemon status with elevated permissions" return 1 fi fi fi else log_warning "Docker is not installed" return 1 fi } # --- WSL-specific helpers ---------------------------------------------------- # Add the current user to the docker group if the group exists ensure_user_in_docker_group() { if getent group docker >/dev/null 2>&1; then if ! id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then log_info "Adding $USER to 'docker' group..." if command_exists sudo; then sudo usermod -aG docker "$USER" || true log_warning "You may need to close and reopen your WSL terminal for group changes to take effect." fi fi fi } # Install only the Docker CLI inside Ubuntu on WSL. The engine is provided by Docker Desktop for Windows. install_docker_cli_on_ubuntu_wsl() { if ! command_exists apt-get; then log_error "This WSL distribution does not appear to be Debian/Ubuntu based. Please install the docker CLI manually." return 1 fi log_info "Installing Docker CLI (docker-ce-cli) and compose plugin inside WSL Ubuntu..." if ! command_exists curl; then log_info "Installing curl..." sudo apt-get update -y && sudo apt-get install -y curl fi # Install prerequisites sudo apt-get update -y sudo apt-get install -y ca-certificates curl gnupg lsb-release # Setup Docker's official GPG key (idempotent) sudo install -m 0755 -d /etc/apt/keyrings if [ ! -f /etc/apt/keyrings/docker.gpg ]; then curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg fi # Setup repository (idempotent) local codename codename=$(. /etc/os-release && echo "$VERSION_CODENAME") echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${codename} stable" | \ sudo tee /etc/apt/sources.list.d/docker.list >/dev/null sudo apt-get update -y sudo apt-get install -y docker-ce-cli docker-compose-plugin ensure_user_in_docker_group } # Give precise instructions for installing and enabling Docker Desktop on Windows with WSL integration advise_docker_desktop_setup() { cat <<'EOT' Docker Desktop for Windows is required on WSL 2: 1) In Windows 11, install Docker Desktop: https://www.docker.com/products/docker-desktop/ 2) Open Docker Desktop → Settings → Resources → WSL Integration. - Enable "Enable integration with my default WSL distro" - Ensure your current distro is toggled ON. 3) Start Docker Desktop. Inside WSL, verify the socket exists: /var/run/docker.sock 4) If the socket doesn't exist but /mnt/wsl/shared-docker/docker.sock does, this installer will link it. Then re-run this installer. EOT } # Install Docker install_docker() { local platform platform=$(detect_platform) log_info "Installing Docker for platform: $platform" case "$platform" in wsl*-*) log_info "Detected Windows Subsystem for Linux." if [[ "$platform" == "wsl1-"* ]]; then log_error "WSL 1 is not supported for Docker. Please upgrade this distro to WSL 2." advise_docker_desktop_setup return 1 fi # Install the Docker CLI and compose plugin inside WSL if ! command_exists docker; then install_docker_cli_on_ubuntu_wsl || true fi # Ensure the Docker Desktop socket is reachable if [ -S /var/run/docker.sock ]; then log_info "Found Docker Desktop socket at /var/run/docker.sock" elif [ -S /mnt/wsl/shared-docker/docker.sock ]; then log_info "Found Docker Desktop socket at /mnt/wsl/shared-docker/docker.sock; creating link..." if command_exists sudo; then sudo mkdir -p /var/run || true sudo ln -sf /mnt/wsl/shared-docker/docker.sock /var/run/docker.sock || true else ln -sf /mnt/wsl/shared-docker/docker.sock /var/run/docker.sock || true fi else log_warning "Docker Desktop for Windows does not appear to be running or WSL integration is disabled." advise_docker_desktop_setup return 1 fi ensure_user_in_docker_group # Validate connectivity if docker info >/dev/null 2>&1; then log_success "Docker Desktop (engine) + docker-ce-cli (WSL) are ready." return 0 else log_warning "Docker CLI is installed, but the engine is still unreachable." advise_docker_desktop_setup return 1 fi ;; linux-*) log_info "Installing Docker using the official installation script..." if command_exists curl; then curl -fsSL https://get.docker.com | sh else local temp_install_script temp_install_script=$(mktemp) temp_files+=("$temp_install_script") dl "https://get.docker.com" "$temp_install_script" sh "$temp_install_script" fi # Start and enable Docker daemon # NOTE: On most systems (Ubuntu/Debian), the docker-ce package post-install script # automatically starts and enables the daemon. However, we explicitly start it here # as a safety measure in case post-install hooks fail or on non-standard systems. log_info "Starting Docker daemon..." if command_exists systemctl; then sudo systemctl start docker sudo systemctl enable docker elif command_exists service; then sudo service docker start fi # Add current user to docker group if ! id -nG "$USER" 2>/dev/null | tr ' ' '\n' | grep -qx docker; then log_info "Adding user '$USER' to docker group..." sudo usermod -aG docker "$USER" log_warning "User '$USER' added to docker group. You may need to log out and back in for the change to take effect" fi ;; darwin-*) # Check if Docker Desktop is already installed if command_exists docker || [ -d "/Applications/Docker.app" ]; then log_info "Docker Desktop is installed. Checking if it's running..." # If Docker is not running, try to start it if ! docker info >/dev/null 2>&1; then log_info "Starting Docker Desktop..." if command_exists open; then # Start Docker Desktop - try multiple ways to find and start it if [ -d "/Applications/Docker.app" ]; then log_info "Starting Docker Desktop from /Applications/Docker.app..." # Use nohup to start Docker Desktop in background without GUI focus nohup open "/Applications/Docker.app" >/dev/null 2>&1 & sleep 3 elif command_exists open; then log_info "Starting Docker Desktop via application name..." nohup open -Ra Docker >/dev/null 2>&1 & sleep 3 else log_error "Cannot find Docker Desktop application to start" log_error "Please start Docker Desktop manually: open -a Docker" exit 1 fi # Wait for Docker to start with proper timeout and retry logic log_info "Waiting for Docker Desktop to start (this may take several minutes)..." local max_wait=600 # 10 minutes local wait_time=0 local check_interval=15 while [ $wait_time -lt $max_wait ]; do if docker info >/dev/null 2>&1; then log_success "Docker Desktop started successfully" return 0 fi log_info "Still waiting for Docker Desktop... (${wait_time}s/${max_wait}s)" sleep $check_interval wait_time=$((wait_time + check_interval)) done log_error "Docker Desktop failed to start within ${max_wait} seconds" log_error "Please start Docker Desktop manually and re-run this installer" exit 1 else log_error "Cannot start Docker Desktop automatically (missing 'open' command)" log_error "Please start Docker Desktop manually and re-run this installer" exit 1 fi else log_success "Docker Desktop is already running" return 0 fi else log_info "Installing Docker Desktop..." # Check if we're in a CI environment (GitHub Actions, etc.) if [[ -n "${CI:-}" || -n "${GITHUB_ACTIONS:-}" ]]; then log_info "CI environment detected - using direct Docker Desktop installation" # Download Docker Desktop DMG local docker_dmg="/tmp/Docker.dmg" log_info "Downloading Docker Desktop..." if command_exists curl; then curl -fsSL "https://desktop.docker.com/mac/main/amd64/Docker.dmg" -o "$docker_dmg" else dl "https://desktop.docker.com/mac/main/amd64/Docker.dmg" "$docker_dmg" fi # Install Docker Desktop using the official command-line method log_info "Installing Docker Desktop with privileged configuration..." sudo hdiutil attach "$docker_dmg" # Use --user flag to avoid GUI permission prompts in CI local current_user="$(whoami)" sudo "/Volumes/Docker/Docker.app/Contents/MacOS/install" --accept-license --user="$current_user" sudo hdiutil detach "/Volumes/Docker" rm -f "$docker_dmg" log_success "Docker Desktop installed with CI-friendly configuration" # Start Docker Desktop log_info "Starting Docker Desktop..." # Docker should start automatically after installation with --user flag else # Interactive installation via Homebrew for local development log_info "Installing Docker Desktop via Homebrew..." if command_exists brew; then brew install --cask docker-desktop log_info "Docker Desktop installed. Starting it..." # Wait a moment for the app to be fully registered sleep 3 # Pre-configure Docker Desktop to avoid GUI prompts log_info "Configuring Docker Desktop for headless operation..." # Create Docker Desktop settings directory if it doesn't exist mkdir -p "$HOME/Library/Group Containers/group.com.docker" # Create a minimal settings file to skip setup wizard cat > "$HOME/Library/Group Containers/group.com.docker/settings.json" << 'EOF' { "analyticsEnabled": false, "autoStart": false, "checkForUpdates": false, "displayedOnboarding": true, "exposeDockerAPIOnTCP2375": false, "openUIOnStartupDisabled": true, "showSystemContainers": false, "skipUpdateToWSLPrompt": true, "terminalType": "integrated" } EOF # Start Docker Desktop - try multiple ways after installation if [ -d "/Applications/Docker.app" ]; then log_info "Starting Docker Desktop from /Applications/Docker.app..." # Use nohup to start Docker Desktop in background without GUI focus nohup open "/Applications/Docker.app" >/dev/null 2>&1 & # Give it a moment to start the background process sleep 5 else log_error "Docker Desktop was installed but cannot find /Applications/Docker.app" log_error "Please start Docker Desktop manually and re-run this installer" exit 1 fi else log_error "Homebrew not found. Please install Docker Desktop manually:" log_error "https://docker.com/products/docker-desktop" exit 1 fi fi # Same waiting logic for both CI and interactive installations log_info "Waiting for Docker Desktop to start after installation..." local max_wait=600 local wait_time=0 local check_interval=15 while [ $wait_time -lt $max_wait ]; do if docker info >/dev/null 2>&1; then log_success "Docker Desktop started successfully after installation" return 0 fi log_info "Still waiting for Docker Desktop... (${wait_time}s/${max_wait}s)" sleep $check_interval wait_time=$((wait_time + check_interval)) done log_error "Docker Desktop failed to start within ${max_wait} seconds after installation" log_error "Please start Docker Desktop manually and re-run this installer" exit 1 fi ;; windows-*) log_error "Please install Docker Desktop for Windows from: https://docker.com/products/docker-desktop" log_error "Then run this installer again" exit 1 ;; *) log_error "Unsupported platform: $platform" log_error "Please install Docker manually and run this installer again" exit 1 ;; esac } install_cosign() { local platform local cosign_url platform=$(detect_platform_no_wsl) cosign_url="https://github.com/sigstore/cosign/releases/latest/download/cosign-${platform}" if command_exists cosign; then COSIGN_BIN="cosign" log_info "cosign is already installed" return 0 fi log_info "Installing Cosign for image signature verification..." if ! curl -O -L "${cosign_url}"; then log_info "Failed to download cosign" log_debug "${cosign_url}" exit 1 fi COSIGN_BIN=$(pwd)/cosign-"$platform" if { mv "$COSIGN_BIN" /usr/local/bin/cosign; } || { command -v sudo && sudo mv "$COSIGN_BIN" /usr/local/bin/cosign; } then COSIGN_BIN=/usr/local/bin/cosign fi if { ! chmod +x "$COSIGN_BIN"; } && { command -v sudo && ! sudo chmod +x "$COSIGN_BIN"; } then log_info "Failed to make cosign executable, try with sudo or disable image verification (not recommended)" exit 1 fi } fetch_cosign_args(){ COSIGN_VERIFY_ARGS=( "--certificate-identity-regexp" "https://github.com/PAIR-Systems-Inc/goodmem/.github/workflows/*" "--certificate-oidc-issuer" "https://token.actions.githubusercontent.com" "--timeout" "60s" "$IMAGE" ) } # Setup environment variables for Docker Compose based on database configuration setup_environment_variables() { local goodmem_dir="$1" if [[ "$USE_REMOTE_DB" == true ]]; then log_debug "Configuring environment for remote database..." # Get the JDBC URL for the server container local db_url_value if [[ -n "$DB_URL_JDBC" ]]; then db_url_value="$DB_URL_JDBC" else db_url_value="$(build_jdbc_from_parts)" if [[ -z "$db_url_value" ]]; then log_error "Cannot build JDBC URL from provided components" return 1 fi fi cat > "$goodmem_dir/.env" < "$goodmem_dir/.env" <> ~/.bashrc export PATH="/var/lib/toolbox:$PATH" fi fi # On WSL, ensure ~/.local/bin is in PATH (WSL may not source ~/.profile) if [[ "$platform" == wsl*-* ]] && [[ "$cli_dir" == "$HOME/.local/bin" ]]; then if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then log_info "Adding ~/.local/bin to PATH for current session" export PATH="$HOME/.local/bin:$PATH" fi fi # Check if we need sudo for installation local use_sudo="" if [ ! -w "$cli_dir" ]; then log_info "Installation directory requires elevated privileges" use_sudo="sudo" fi # Determine archive format based on platform # Map WSL to appropriate CLI binary (WSL uses Linux binaries) local cli_platform="$platform" case "$platform" in wsl1-*|wsl2-*) cli_platform="${platform#wsl?-}" # wsl2-amd64 -> amd64 cli_platform="linux-${cli_platform}" # -> linux-amd64 ;; esac local archive_file case "$cli_platform" in windows-*) archive_file="goodmem-$cli_platform.zip" ;; *) archive_file="goodmem-$cli_platform.tar.gz" ;; esac # Download CLI archive log_debug "Downloading CLI archive ($archive_file)..." local temp_archive temp_archive=$(mktemp) temp_files+=("$temp_archive") if ! dl "$base_url/tag/latest/$archive_file" "$temp_archive"; then log_error "Failed to download CLI archive for platform: $platform" return 1 fi # Extract and install binary log_debug "Extracting and installing CLI binary..." local temp_dir temp_dir=$(mktemp -d) temp_dirs+=("$temp_dir") if ! extract_archive "$temp_archive" "$temp_dir" "$platform"; then log_error "Failed to extract CLI archive" return 1 fi # Find the binary in the extracted files # First try executable bit search; if that yields nothing, fall back to name match local extracted_binary extracted_binary="$(find "$temp_dir" -type f -name 'goodmem*' -perm -111 | head -n1)" # Fallback if archive didn't preserve exec bits if [ -z "$extracted_binary" ]; then extracted_binary="$(find "$temp_dir" -type f -name 'goodmem*' | head -n1)" fi if [ -z "$extracted_binary" ]; then log_error "Could not find goodmem binary in archive" return 1 fi # Install binary log_debug "Installing CLI binary to $cli_dir/$binary_name..." if ! $use_sudo install -m 755 "$extracted_binary" "$cli_dir/$binary_name"; then log_error "Failed to install CLI binary" return 1 fi # Verify installation if command_exists "$binary_name"; then log_success "CLI installed successfully" log_info "CLI version: $($binary_name version 2>/dev/null || echo 'unknown')" else log_warning "CLI installed but not found in PATH. You may need to restart your shell or add $cli_dir to your PATH" fi return 0 } # Start GoodMem services start_goodmem() { local goodmem_dir="${GOODMEM_DIR:-$HOME/.goodmem}" local platform log_debug "start_goodmem() function called" log_debug "In start_goodmem(), USE_REMOTE_DB=$USE_REMOTE_DB" platform=$(detect_platform) # Run preflight checks for remote database if [[ "$USE_REMOTE_DB" == true ]]; then log_info "Running preflight checks for remote database..." if ! preflight_database_checks; then log_error "Preflight database checks failed. Please fix the issues and try again." return 1 fi echo "" fi log_info "Starting GoodMem services..." # Set Docker platform for Apple Silicon as belt-and-suspenders if [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; then export DOCKER_DEFAULT_PLATFORM="${DOCKER_DEFAULT_PLATFORM:-linux/amd64}" fi # Change to goodmem directory and start services if ! cd "$goodmem_dir"; then log_error "Failed to change to directory: $goodmem_dir" return 1 fi log_debug "Changed to GoodMem directory: $goodmem_dir" # Detect compose command if ! detect_compose; then log_error "Failed to detect Docker Compose command" return 1 fi # Check if we need sudo for docker compose (e.g., when fresh installation where group membership hasn't taken effect) log_info "Checking if sudo is needed for Docker Compose..." if ! docker info >/dev/null 2>&1; then log_debug "Docker info failed without sudo, trying with sudo..." # Try with sudo if available if command_exists sudo; then log_debug "Attempting to verify Docker daemon with sudo..." if sudo docker info >/dev/null 2>&1; then log_info "Docker compose needs sudo. Prepending sudo to docker compose commands." COMPOSE_BIN=(sudo "${COMPOSE_BIN[@]}") else log_warning "Docker daemon check with sudo failed. Continuing without sudo for docker commands." log_warning "This may cause issues if Docker requires elevated privileges." fi fi else log_info "We do not need sudo for docker compose" fi # Check compose version requirements for remote database require_compose_for_remote # Build compose file arguments log_debug "About to call compose_files() function..." compose_files "$goodmem_dir" "$platform" log_debug "compose_files() function completed" # Verify required compose files exist if [[ ! -f "$goodmem_dir/docker-compose.yml" ]]; then log_error "Required docker-compose.yml file not found in $goodmem_dir" return 1 fi # Debug output to show exactly what command will be executed log_debug "Using Docker Compose command: ${COMPOSE_BIN[*]}" log_debug "Using compose files: ${COMPOSE_FILES_ARGS[*]}" if [ "$SKIP_IMAGE_VERIFY" == "false" ]; then install_cosign log_debug "Using Cosign Verify command: ${COSIGN_BIN}" fetch_cosign_args fi log_info "Pulling Docker images..." log_debug "About to run: ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} pull" # Run docker pull with progress and ensure both stdout and stderr are visible if ! "${COMPOSE_BIN[@]}" "${COMPOSE_FILES_ARGS[@]}" pull 2>&1; then echo "" log_error "Failed to pull Docker images (see error messages above)" echo "" log_info "This often happens due to Docker registry authentication issues." log_info "Try these solutions:" echo "" echo "1. Clear Docker authentication (recommended):" echo " docker logout ghcr.io" echo "" echo "2. If the issue persists, try manually pulling the images:" echo " docker pull pgvector/pgvector:pg17" echo " docker pull ghcr.io/pair-systems-inc/goodmem/server:latest" echo "" log_info "After trying the above, run this installer again." exit 1 fi if [ "$SKIP_IMAGE_VERIFY" == "false" ]; then log_info "Verifying goodmem docker image..." args_string=$(printf '%s ' "${COSIGN_VERIFY_ARGS[@]}") log_debug "About to run: ${COSIGN_BIN} verify $args_string" if "${COSIGN_BIN}" verify "$args_string" > /dev/null; then log_info "Image verified" elif "${COSIGN_BIN}" verify --offline "${COSIGN_VERIFY_ARGS[@]}" > /dev/null; then log_info "Image Verified" else log_error "Warning: Cosign verify offline command failed. The image could not be verified." fi fi # Validate compose configuration before starting services log_debug "Validating Docker Compose configuration..." if ! "${COMPOSE_BIN[@]}" "${COMPOSE_FILES_ARGS[@]}" config >/dev/null; then log_error "Compose config validation failed. See errors above." exit 1 fi log_info "Starting services (this may take a few minutes)..." log_debug "Docker Compose arguments: ${COMPOSE_FILES_ARGS[*]}" if "${COMPOSE_BIN[@]}" up --help 2>/dev/null | grep -q -- ' --wait'; then log_debug "About to run: ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} up -d --wait --wait-timeout 180" "${COMPOSE_BIN[@]}" "${COMPOSE_FILES_ARGS[@]}" up -d --wait --wait-timeout 180 log_success "GoodMem services started and ready" else log_warning "'--wait' not supported by your Docker Compose; starting without wait" log_debug "About to run: ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} up -d" "${COMPOSE_BIN[@]}" "${COMPOSE_FILES_ARGS[@]}" up -d log_success "GoodMem services started" fi } # Setup default OpenAI embedder setup_default_embedder() { # Check if OpenAI setup should be skipped if [[ "$SKIP_OPENAI_SETUP" == true ]]; then log_info "Skipping OpenAI embedder setup (--no-openai-embedder-registration specified)" return 0 fi log_info "Setting up default embedder..." # Check if CLI is available if ! command_exists goodmem; then log_warning "GoodMem CLI not found in PATH. Skipping embedder setup." return 1 fi # Check if embedders already exist local embedder_list if embedder_list=$(goodmem embedder list 2>/dev/null); then if ! echo "$embedder_list" | grep -q "No embedders found"; then log_info "Embedders already exist. Skipping default embedder setup." echo "$embedder_list" return 0 fi fi echo "" echo "Default Embedder Setup" echo "" echo "We can help you set up OpenAI's text-embedding-3-small model (1536 dimensions)." echo "This model is compatible with GoodMem's vector storage limits." echo "" echo "Note: text-embedding-3-large (3072 dimensions) exceeds GoodMem's 1536-dimension limit." echo "For other providers or models, use: goodmem embedder create" echo "" # Ask if user wants to set up OpenAI embedder printf "Would you like to set up OpenAI text-embedding-3-small? [y/N] (default: N): " read -r REPLY &1); then log_success "OpenAI embedder created successfully!" echo "$embedder_output" # Extract embedder UUID for potential space creation local embedder_uuid embedder_uuid=$(echo "$embedder_output" | grep "ID:" | awk '{print $2}') if [[ -n "$embedder_uuid" ]]; then # Ask if user wants to create a default space echo "" printf "Would you like to create a default space called 'My Memories'? [y/N] (default: N): " read -r REPLY /dev/null; then log_success "Default space 'My Memories' created successfully!" else log_warning "Failed to create default space. You can create one manually with: goodmem space create" fi fi fi return 0 else log_error "Failed to create OpenAI embedder:" echo "$embedder_output" log_warning "You can create embedders manually with: goodmem embedder create" return 1 fi } # Wait for server to be ready with retry loop (updated version for testing) wait_for_server_ready() { # Check if parameters are provided (not just empty) if [[ $# -eq 0 ]]; then # No parameters, use defaults local url="http://localhost:8080/health" local timeout_seconds="120" elif [[ $# -eq 1 ]]; then # One parameter provided, check if it's empty if [[ -z "$1" ]]; then log_error "Usage: wait_for_server_ready " return 1 fi local url="$1" local timeout_seconds="120" elif [[ $# -eq 2 ]]; then # Two parameters provided, check if either is empty if [[ -z "$1" || -z "$2" ]]; then log_error "Usage: wait_for_server_ready " return 1 fi local url="$1" local timeout_seconds="$2" else log_error "Usage: wait_for_server_ready " return 1 fi local count=0 local max_attempts=$((timeout_seconds / 2)) while (( count < max_attempts )); do if curl -fsS "$url" >/dev/null 2>&1; then echo "Server is ready" return 0 fi echo "Waiting for server to be ready (attempt $((count + 1))/$max_attempts)..." sleep 2 ((count++)) done log_error "Server did not become ready within $timeout_seconds seconds" return 1 } # Test helper functions for bats compatibility # Check Docker Compose version (test-friendly version) check_docker_compose_version() { # Determine which compose command to use local compose_cmd if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then compose_cmd="docker compose" elif command -v docker-compose >/dev/null 2>&1; then compose_cmd="docker-compose" else echo "Failed to get Docker Compose version" return 1 fi # Get the version local have have=$($compose_cmd version 2>/dev/null | grep -oE '([0-9]+\.)+[0-9]+' | head -n1) if [[ -z "$have" ]]; then echo "Failed to get Docker Compose version" return 1 fi # Check if version is >= 2.20.2 if printf '%s\n%s\n' "2.20.2" "$have" | sort -V | tail -n1 | grep -q "^$have"; then return 0 else echo "Docker Compose version 2.20.2 or newer is required" echo "Found: v$have" return 1 fi } # Build compose arguments for the given mode build_compose_args() { if [[ "$USE_REMOTE_DB" == true ]]; then echo "-f docker-compose.yml -f docker-compose.remote-db.yml" else echo "-f docker-compose.yml" fi } # Build database URL from parts build_db_url() { local host="${DB_HOST}" local port="${DB_PORT:-5432}" local dbname="${DB_NAME}" # Handle IPv6 addresses if [[ "$host" =~ : ]] && [[ ! "$host" =~ ^\[.*\]$ ]]; then host="[$host]" fi echo "jdbc:postgresql://${host}:${port}/${dbname}" } # Build database URL with SSL parameters build_db_url_with_ssl_params() { local base_url base_url=$(build_db_url) local ssl_mode="${DB_SSL_MODE:-require}" echo "${base_url}?sslmode=${ssl_mode}" } # Validate remote database flags (wrapper for existing function) validate_remote_db_flags() { if [[ -z "$DB_HOST" ]]; then echo "Error: DB_HOST is required for remote database" return 1 fi if [[ -z "$DB_USER" ]]; then echo "Error: DB_USER is required for remote database" return 1 fi if [[ -z "$DB_PASSWORD" ]]; then echo "Error: DB_PASSWORD is required for remote database" return 1 fi if [[ -z "$DB_NAME" ]]; then echo "Error: DB_NAME is required for remote database" return 1 fi # Check for empty or whitespace-only values if [[ -z "${DB_HOST// }" ]]; then echo "Error: DB_HOST cannot be empty or whitespace" return 1 fi return 0 } # Write environment file write_env_file() { local env_file="$1" if [[ -z "$env_file" ]]; then echo "Error: Environment file path is required" return 1 fi # Create parent directory if needed local dir dir=$(dirname "$env_file") mkdir -p "$dir" if [[ "$USE_REMOTE_DB" == true ]]; then # Validate required variables for remote mode if [[ -z "$DB_URL" || -z "$DB_USER" || -z "$DB_PASSWORD" ]]; then echo "Error: DB_URL, DB_USER, and DB_PASSWORD are required for remote database" return 1 fi # Write remote database configuration cat > "$env_file" < "$env_file" </dev/null 2>&1; then echo "Error: psql command not found. Please install PostgreSQL client tools." return 1 fi echo "Testing database connection..." if ! psql "$DB_URL_DSN" -c "SELECT 1" >/dev/null 2>&1; then echo "Database connection failed" return 1 fi echo "Checking required extensions..." if ! psql "$DB_URL_DSN" -c "SELECT 1 FROM pg_extension WHERE extname = 'uuid-ossp'" >/dev/null 2>&1; then echo "Required database extension not available: uuid-ossp" return 1 fi echo "Checking database permissions..." if ! psql "$DB_URL_DSN" -c "CREATE SCHEMA IF NOT EXISTS test_permissions; DROP SCHEMA test_permissions" >/dev/null 2>&1; then echo "Insufficient database permissions: cannot create schemas" return 1 fi echo "Database connection successful" return 0 } # Initialize GoodMem system initialize_goodmem_system() { log_info "Initializing GoodMem system..." # Check if CLI is available if ! command_exists goodmem; then log_warning "GoodMem CLI not found in PATH. Skipping system initialization." log_warning "You can initialize the system manually by running: goodmem init" return 1 fi # Wait for server readiness if ! wait_for_server_ready; then log_error "Server readiness check failed" return 1 fi # Run goodmem init and capture output local init_output if init_output=$(goodmem init 2>&1); then log_success "System initialized successfully!" # Extract and display the important information echo "" echo "🔑 System Initialization Complete!" echo "" echo "$init_output" echo "" log_warning "IMPORTANT: Save the root API key securely. It will not be shown again." echo "" return 0 else log_error "Failed to initialize GoodMem system:" echo "$init_output" log_warning "You can initialize the system manually by running: goodmem init" return 1 fi } # Verify the installation verify_installation() { local goodmem_dir="${GOODMEM_DIR:-$HOME/.goodmem}" local platform detect_compose || return 1 platform=$(detect_platform) compose_files "$goodmem_dir" "$platform" log_debug "Verifying installation..." # Test server endpoint log_debug "Testing server connectivity..." if command_exists curl; then if curl -sSf http://localhost:8080/health >/dev/null 2>&1 || \ curl -sSI http://localhost:8080 >/dev/null 2>&1; then log_success "Server is responding on port 8080" else log_warning "Server may still be starting. Check with: ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} logs server" fi fi log_success "GoodMem installation complete!" echo "" echo "🎉 GoodMem is now running!" echo "" echo "Access points:" echo " • REST API: http://localhost:8080" echo " • gRPC API: localhost:9090" if [[ "$INSTALL_MODE" == "debug" ]]; then echo " • Database: localhost:5432 (debug mode)" echo " • Background Job Runner: internal (no dashboard)" else echo " • Database: Internal only (production mode)" fi echo "" echo "Management commands:" echo " • View logs: cd $goodmem_dir && ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} logs -f" echo " • Stop: cd $goodmem_dir && ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} down" echo " • Restart: cd $goodmem_dir && ${COMPOSE_BIN[*]} ${COMPOSE_FILES_ARGS[*]} restart" echo "" } # Main installation function main() { # Parse command line arguments first parse_arguments "$@" # Validate mutually exclusive flags if [[ "$USE_LOCAL_DB" == true && "$USE_REMOTE_DB" == true ]]; then log_error "Cannot use both --local-db and --remote-db options together" exit 1 fi show_logo log_info "Starting GoodMem installation..." log_info "Platform: $(detect_platform)" # Debug: Show remote database configuration status log_debug "USE_REMOTE_DB=$USE_REMOTE_DB" log_debug "DB_HOST=${DB_HOST:-}" log_debug "DB_USER=${DB_USER:-}" log_debug "DB_NAME=${DB_NAME:-}" # Check whether ports that Goodmem services use are available # ports to check: 8080 (server) and 9090 (gRPC) # Ports 5432 and 8001 are only used in debug mode. so we don't check them here. for port in 8080 9090; do check_port_availability "$port" "Port $port is used by another application. Please free it and try again." done # Prompt for installation mode if not specified via command line if [[ -z "$INSTALL_MODE" ]]; then if can_prompt; then prompt_installation_mode else INSTALL_MODE="prod" log_info "Non-interactive environment detected; defaulting to production mode" fi fi # Force remote database on Container-Optimized OS if [[ "$USE_REMOTE_DB" != true ]] && is_cos; then log_warning "Container-Optimized OS detected: Local PostgreSQL database is not supported" log_warning "COS has strict security policies that prevent proper database container operation" echo "" log_info "Switching to remote PostgreSQL database configuration..." USE_REMOTE_DB=true # Check if database credentials were provided via command line if [[ -z "$DB_URL_DSN" && (-z "$DB_HOST" || -z "$DB_USER" || -z "$DB_PASSWORD" || -z "$DB_NAME") ]]; then if can_prompt; then # Prompt for database details since none were provided prompt_remote_database_details else # Non-interactive environment without credentials echo "" echo "Remote PostgreSQL database credentials are required on COS." echo "Please re-run with database connection details:" echo " --db-url 'postgresql://user:pass@host:5432/dbname'" echo "or individual flags: --db-host, --db-user, --db-password, --db-name" exit 1 fi fi echo "" fi # Prompt for database configuration if not specified via command line if [[ "$USE_REMOTE_DB" != true ]]; then if can_prompt; then prompt_database_configuration else log_info "Non-interactive environment detected; defaulting to local database" fi fi # Validate remote database configuration validate_remote_db_config # Check and install Docker if needed if ! check_docker; then # Distinguish between "not installed" and "not running" if command_exists docker; then # Docker is installed but not running - try to start it log_info "Docker is installed but daemon is not running. Attempting to start Docker daemon..." if command_exists sudo && command_exists systemctl; then if sudo systemctl start docker 2>/dev/null; then log_info "Docker daemon start command sent. Waiting a moment for it to initialize..." sleep 3 # Check again if check_docker; then log_success "Docker daemon started successfully" else log_error "Docker daemon failed to start" log_error "Please start Docker manually: sudo systemctl start docker" exit 1 fi else log_error "Failed to start Docker daemon" log_error "Please start Docker manually: sudo systemctl start docker" exit 1 fi else log_error "Cannot start Docker daemon automatically (sudo or systemctl not available)" log_error "Please start Docker manually and run this installer again" exit 1 fi else # Docker is not installed - install it log_info "Docker is not installed. Installing Docker..." install_docker # Verify Docker installation if ! check_docker; then log_error "Docker installation failed or Docker daemon is not running" log_error "Please install Docker manually and ensure it's running" exit 1 fi fi fi log_success "Docker is ready!" # Download GoodMem files download_goodmem_files # Install CLI if ! install_cli; then log_warning "CLI installation failed, but continuing with server installation" log_warning "You can install the CLI manually from: https://github.com/PAIR-Systems-Inc/goodmem/releases" fi # Start GoodMem services log_debug "About to call start_goodmem() from main..." start_goodmem log_debug "start_goodmem() call completed" # Verify installation verify_installation # Initialize GoodMem system and setup embedder if successful if initialize_goodmem_system; then setup_default_embedder fi # Show next steps at the very end echo "" echo "📚 Next Steps:" echo " • Getting Started Guide: https://docs.goodmem.ai/docs/get-started/nextsteps/" echo " • API Reference: https://docs.goodmem.ai/docs/reference/api-reference/" echo "" } # Run main function only when executed directly (not when sourced for testing) if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then main "$@" fi