Add MIX_ENV=test mix deps.compile after NIF build. The _build/test tree is ephemeral (cleared when nix devShell derivation changes), which causes 'Mox is not available' errors. This ensures test deps like Mox are ready before the user ever runs mix test.
374 lines
12 KiB
Nix
374 lines
12 KiB
Nix
{
|
||
lib,
|
||
stdenv,
|
||
mkShell,
|
||
writeShellScriptBin,
|
||
# Elixir/Erlang
|
||
elixir,
|
||
# Databases and caches
|
||
postgresql_17,
|
||
redis,
|
||
# SNMP tools
|
||
net-snmp,
|
||
# Build tools for C NIF
|
||
gcc,
|
||
gnumake,
|
||
pkg-config,
|
||
# Development tools
|
||
git-lfs,
|
||
inotify-tools,
|
||
# LSPs and formatters
|
||
elixir-ls,
|
||
nixfmt,
|
||
shellcheck,
|
||
# Pre-commit hooks
|
||
pre-commit-hooks,
|
||
}:
|
||
|
||
let
|
||
# PostgreSQL with TimescaleDB and PostGIS extensions
|
||
pg = postgresql_17.withPackages (ps: [
|
||
ps.timescaledb
|
||
ps.postgis
|
||
]);
|
||
|
||
# PostgreSQL data directory (local to project)
|
||
pgDataDir = ".nix-postgres";
|
||
pgPort = "5432";
|
||
pgHost = "localhost";
|
||
|
||
# Redis data directory (local to project)
|
||
redisDataDir = ".nix-redis";
|
||
redisPort = "6379";
|
||
|
||
# Service management flag
|
||
servicesFlag = ".nix-services-started";
|
||
|
||
# Version file to detect when PG config changes (extensions added/removed, version bump)
|
||
pgVersionFile = ".nix-postgres-version";
|
||
|
||
# Script to start PostgreSQL and Redis
|
||
startServices = writeShellScriptBin "start-services" ''
|
||
set -e
|
||
|
||
echo "🚀 Starting development services..."
|
||
|
||
# Check if PG version/extensions changed and reinit if needed.
|
||
# Must run BEFORE the servicesFlag guard so already-running clusters
|
||
# get reinitialized when extensions or versions change.
|
||
pg_needs_reinit=false
|
||
if [ -d "${pgDataDir}" ] && [ -f "${pgVersionFile}" ]; then
|
||
if [ "$(cat ${pgVersionFile})" != "${pg}" ]; then
|
||
echo "⚠️ PostgreSQL configuration changed (extensions or version updated)"
|
||
pg_needs_reinit=true
|
||
fi
|
||
fi
|
||
if [ -d "${pgDataDir}" ] && [ ! -f "${pgVersionFile}" ]; then
|
||
pg_needs_reinit=true
|
||
fi
|
||
|
||
if $pg_needs_reinit; then
|
||
echo "🗑️ Stopping old PostgreSQL and removing cluster..."
|
||
${pg}/bin/pg_ctl -D ${pgDataDir} stop -m fast 2>/dev/null || true
|
||
rm -rf ${pgDataDir}
|
||
rm -f ${servicesFlag}
|
||
fi
|
||
|
||
# Check if already started (with correct version)
|
||
if [ -f "${servicesFlag}" ]; then
|
||
echo "✓ Services already running (remove ${servicesFlag} to force restart)"
|
||
exit 0
|
||
fi
|
||
|
||
# Initialize PostgreSQL if needed
|
||
if [ ! -d "${pgDataDir}" ]; then
|
||
echo "📦 Initializing PostgreSQL database..."
|
||
${pg}/bin/initdb -D ${pgDataDir} -U $USER --encoding=UTF8 --locale=en_US.UTF-8
|
||
|
||
# Configure PostgreSQL
|
||
cat >> ${pgDataDir}/postgresql.conf << EOF
|
||
# Development configuration
|
||
max_connections = 100
|
||
shared_buffers = 128MB
|
||
fsync = off # Faster for development (DO NOT use in production)
|
||
synchronous_commit = off
|
||
full_page_writes = off
|
||
# TimescaleDB
|
||
shared_preload_libraries = 'timescaledb'
|
||
timescaledb.telemetry_level = off
|
||
EOF
|
||
|
||
# Record current PG version for future change detection
|
||
echo -n "${pg}" > ${pgVersionFile}
|
||
fi
|
||
|
||
# Check if PostgreSQL is already running on this port
|
||
if ${pg}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then
|
||
echo "✓ PostgreSQL already running on port ${pgPort}"
|
||
else
|
||
# Start PostgreSQL
|
||
echo "🐘 Starting PostgreSQL on port ${pgPort}..."
|
||
${pg}/bin/pg_ctl -D ${pgDataDir} -l ${pgDataDir}/logfile -o "-p ${pgPort}" start
|
||
|
||
# Wait for PostgreSQL to be ready
|
||
for i in {1..30}; do
|
||
if ${pg}/bin/pg_isready -h ${pgHost} -p ${pgPort} > /dev/null 2>&1; then
|
||
break
|
||
fi
|
||
if [ $i -eq 30 ]; then
|
||
echo "❌ PostgreSQL failed to start within 30 seconds"
|
||
echo " Check ${pgDataDir}/logfile for details"
|
||
exit 1
|
||
fi
|
||
sleep 1
|
||
done
|
||
echo "✓ PostgreSQL started successfully"
|
||
fi
|
||
|
||
# Create postgres role if it doesn't exist (needed by some tools)
|
||
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \
|
||
"SELECT 1 FROM pg_roles WHERE rolname='postgres'" | grep -q 1 || \
|
||
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -c \
|
||
"CREATE ROLE postgres WITH SUPERUSER LOGIN;"
|
||
|
||
# Create development and test databases
|
||
for db in towerops_dev towerops_test; do
|
||
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d postgres -tc \
|
||
"SELECT 1 FROM pg_database WHERE datname='$db'" | grep -q 1 || \
|
||
${pg}/bin/createdb -h ${pgHost} -p ${pgPort} -U $USER $db
|
||
done
|
||
|
||
# Enable TimescaleDB and PostGIS extensions for dev and test databases
|
||
for db in towerops_dev towerops_test; do
|
||
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d $db -c \
|
||
"CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;" 2>/dev/null || \
|
||
echo "⚠️ TimescaleDB not available for $db (optional)"
|
||
${pg}/bin/psql -h ${pgHost} -p ${pgPort} -U $USER -d $db -c \
|
||
"CREATE EXTENSION IF NOT EXISTS postgis CASCADE;" 2>/dev/null || \
|
||
echo "⚠️ PostGIS not available for $db (optional)"
|
||
done
|
||
|
||
echo "✓ PostgreSQL started successfully"
|
||
|
||
# Check if Redis is already running on this port
|
||
if ${redis}/bin/redis-cli -p ${redisPort} ping > /dev/null 2>&1; then
|
||
echo "✓ Redis already running on port ${redisPort}"
|
||
else
|
||
# Start Redis
|
||
echo "📮 Starting Redis on port ${redisPort}..."
|
||
mkdir -p ${redisDataDir}
|
||
${redis}/bin/redis-server --daemonize yes \
|
||
--port ${redisPort} \
|
||
--dir ${redisDataDir} \
|
||
--save "" \
|
||
--appendonly no
|
||
|
||
# Wait for Redis to be ready
|
||
for i in {1..10}; do
|
||
if ${redis}/bin/redis-cli -p ${redisPort} ping > /dev/null 2>&1; then
|
||
break
|
||
fi
|
||
if [ $i -eq 10 ]; then
|
||
echo "❌ Redis failed to start within 10 seconds"
|
||
exit 1
|
||
fi
|
||
sleep 1
|
||
done
|
||
echo "✓ Redis started successfully"
|
||
fi
|
||
|
||
# Mark services as started
|
||
touch ${servicesFlag}
|
||
|
||
echo ""
|
||
echo "✨ Development environment ready!"
|
||
echo ""
|
||
echo "PostgreSQL: ${pgHost}:${pgPort} (databases: towerops_dev, towerops_test)"
|
||
echo "Redis: ${pgHost}:${redisPort}"
|
||
echo ""
|
||
'';
|
||
|
||
# Script to stop PostgreSQL and Redis
|
||
stopServices = writeShellScriptBin "stop-services" ''
|
||
set -e
|
||
|
||
echo "🛑 Stopping development services..."
|
||
|
||
# Stop PostgreSQL
|
||
if [ -d "${pgDataDir}" ]; then
|
||
${pg}/bin/pg_ctl -D ${pgDataDir} stop -m fast 2>/dev/null || true
|
||
echo "✓ PostgreSQL stopped"
|
||
fi
|
||
|
||
# Stop Redis
|
||
${redis}/bin/redis-cli -p ${redisPort} shutdown 2>/dev/null || true
|
||
echo "✓ Redis stopped"
|
||
|
||
# Remove services flag
|
||
rm -f ${servicesFlag}
|
||
|
||
echo "✓ Services stopped successfully"
|
||
'';
|
||
|
||
# Pre-commit hooks configuration
|
||
pre-commit-check = pre-commit-hooks.run {
|
||
src = ../.;
|
||
hooks = {
|
||
# Elixir formatting
|
||
mix-format = {
|
||
enable = true;
|
||
name = "mix format";
|
||
entry = "mix format --check-formatted";
|
||
files = "\\.(ex|exs)$";
|
||
pass_filenames = false;
|
||
};
|
||
|
||
# Credo linting
|
||
credo = {
|
||
enable = true;
|
||
name = "credo";
|
||
entry = "mix credo --strict";
|
||
files = "\\.(ex|exs)$";
|
||
pass_filenames = false;
|
||
};
|
||
|
||
# Nix formatting
|
||
nixfmt = {
|
||
enable = true;
|
||
entry = "${nixfmt}/bin/nixfmt";
|
||
};
|
||
|
||
# Shellcheck for shell scripts
|
||
shellcheck.enable = true;
|
||
};
|
||
};
|
||
|
||
in
|
||
mkShell {
|
||
name = "towerops-dev";
|
||
|
||
# Development tools
|
||
buildInputs = [
|
||
# Elixir/Erlang
|
||
elixir
|
||
|
||
# Databases (PostgreSQL with TimescaleDB)
|
||
pg
|
||
redis
|
||
|
||
# SNMP tools
|
||
net-snmp
|
||
|
||
# C NIF build tools
|
||
gcc
|
||
gnumake
|
||
pkg-config
|
||
|
||
# Development tools
|
||
git-lfs
|
||
|
||
# LSPs and formatters
|
||
elixir-ls
|
||
nixfmt
|
||
shellcheck
|
||
|
||
# Service management scripts
|
||
startServices
|
||
stopServices
|
||
]
|
||
++ lib.optionals stdenv.isLinux [
|
||
# Linux-only tools
|
||
inotify-tools # For Mix file watching
|
||
];
|
||
|
||
# Environment variables
|
||
shellHook = ''
|
||
# Color codes
|
||
GREEN='\033[0;32m'
|
||
BLUE='\033[0;34m'
|
||
NC='\033[0m' # No Color
|
||
|
||
# Set Mix and Hex home to local directories (avoid polluting $HOME)
|
||
export MIX_HOME="$PWD/.nix-mix"
|
||
export HEX_HOME="$PWD/.nix-hex"
|
||
mkdir -p "$MIX_HOME" "$HEX_HOME"
|
||
|
||
# PostgreSQL connection
|
||
export PGHOST="${pgHost}"
|
||
export PGPORT="${pgPort}"
|
||
export PGUSER="$USER"
|
||
export DATABASE_URL="ecto://$USER@${pgHost}:${pgPort}/towerops_dev"
|
||
|
||
# Redis connection
|
||
export REDIS_URL="redis://${pgHost}:${redisPort}/0"
|
||
|
||
# Development secrets (DO NOT use in production)
|
||
export SECRET_KEY_BASE="dev_secret_key_base_at_least_64_bytes_long_for_phoenix_to_accept_it_safely"
|
||
export CLOAK_KEY="nQWmgQYhoCNXA3PAxwriKxLyPHAOWH9VgpLkBOXrowM="
|
||
export RELEASE_COOKIE="dev_release_cookie_for_distributed_erlang"
|
||
|
||
# Development environment
|
||
export MIX_ENV=dev
|
||
export PHX_HOST=localhost
|
||
export PORT=4000
|
||
|
||
# Ensure Erlang can find NIF libraries
|
||
export ERL_AFLAGS="-kernel shell_history enabled"
|
||
|
||
# Skip heavy setup when running inside direnv (DIRENV_DIR is set).
|
||
# direnv evaluates the shellHook to load env vars only; running mix,
|
||
# starting services, or installing pre-commit hooks would block the
|
||
# prompt and cause nix profile cleanup hangs on macOS.
|
||
if [ -z "$DIRENV_DIR" ]; then
|
||
# Running in nix develop (interactive shell)
|
||
|
||
# Install Hex and Rebar3 if not already installed
|
||
mix local.hex --force --if-missing > /dev/null 2>&1
|
||
mix local.rebar --force --if-missing > /dev/null 2>&1
|
||
|
||
# Compile C NIF if not already built (uses Nix-provided gcc/make/net-snmp)
|
||
if [ ! -f "priv/towerops_nif.so" ]; then
|
||
echo "🔧 Building C NIF..."
|
||
make -C c_src
|
||
fi
|
||
|
||
# Pre-compile test dependencies so mix test works immediately.
|
||
# The _build/test tree is ephemeral (cleared when nix devShell changes).
|
||
MIX_ENV=test mix deps.compile 2>/dev/null || true
|
||
|
||
# Auto-start services on first shell entry.
|
||
# Run in a detached subshell to prevent nix develop from waiting
|
||
# on daemonized PostgreSQL/Redis child processes.
|
||
if [ ! -f "${servicesFlag}" ]; then
|
||
( ${startServices}/bin/start-services </dev/null & )
|
||
fi
|
||
|
||
# Install pre-commit hooks
|
||
${pre-commit-check.shellHook}
|
||
|
||
# Welcome message
|
||
echo ""
|
||
echo -e "''${BLUE}╔═══════════════════════════════════════════════════════════╗''${NC}"
|
||
echo -e "''${BLUE}║ ║''${NC}"
|
||
echo -e "''${BLUE}║''${NC} ''${GREEN}Towerops Development Environment''${NC} ''${BLUE}║''${NC}"
|
||
echo -e "''${BLUE}║ ║''${NC}"
|
||
echo -e "''${BLUE}╚═══════════════════════════════════════════════════════════╝''${NC}"
|
||
echo ""
|
||
echo -e "''${GREEN}Available commands:''${NC}"
|
||
echo " mix phx.server - Start Phoenix development server"
|
||
echo " mix test - Run test suite"
|
||
echo " mix precommit - Run pre-commit checks (format, credo, test)"
|
||
echo " start-services - Start PostgreSQL and Redis"
|
||
echo " stop-services - Stop PostgreSQL and Redis"
|
||
echo " mix ecto.reset - Reset database (drop, create, migrate, seed)"
|
||
echo ""
|
||
echo -e "''${GREEN}Services:''${NC}"
|
||
echo " PostgreSQL: ${pgHost}:${pgPort}"
|
||
echo " Redis: ${pgHost}:${redisPort}"
|
||
echo ""
|
||
echo -e "''${GREEN}Documentation:''${NC}"
|
||
echo " See CLAUDE.md and docs/nix.md for more information"
|
||
echo ""
|
||
fi
|
||
'';
|
||
}
|