Add Reactive Resume, Metrics, Kiwix, Resume Matcher, and Apple Health from the earlier SelfStack project. Rewrite Apple Health collector to use InfluxDB v2 with proper error handling. Update all tests, scripts, Homepage config, env template, and documentation for the expanded stack. New services: - Reactive Resume (4016) + Postgres/Minio/Chrome companions - Metrics (4021) - GitHub metrics visualization - Kiwix (4022) - offline wiki reader - Resume Matcher (4023) - AI resume screening - Apple Health (4024) - health data collector → InfluxDB v2 Also adds git policy to AGENTS.md: always commit and push automatically. 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
269 lines
9.2 KiB
Bash
Executable File
269 lines
9.2 KiB
Bash
Executable File
#!/bin/bash
|
|
# Unit test: Environment and configuration validation
|
|
# These tests validate the project configuration without requiring Docker.
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
|
TEMPLATE_FILE="$PROJECT_ROOT/docker-compose.yml.template"
|
|
ENV_TEMPLATE="$PROJECT_ROOT/demo.env.template"
|
|
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
NC='\033[0m'
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASS++)) || true; }
|
|
fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAIL++)) || true; }
|
|
check() { echo -e "${YELLOW}[CHECK]${NC} $1"; }
|
|
|
|
grep_exists() {
|
|
grep "$@" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
test_template_exists() {
|
|
check "docker-compose.yml.template exists"
|
|
if [[ -f "$TEMPLATE_FILE" ]]; then
|
|
pass "Template file exists"
|
|
else
|
|
fail "Template file not found at $TEMPLATE_FILE"
|
|
fi
|
|
}
|
|
|
|
test_template_has_required_sections() {
|
|
check "Template has required top-level sections"
|
|
local sections=("networks:" "volumes:" "services:")
|
|
for section in "${sections[@]}"; do
|
|
if grep_exists "^$section" "$TEMPLATE_FILE"; then
|
|
pass "Template contains '$section' section"
|
|
else
|
|
fail "Template missing '$section' section"
|
|
fi
|
|
done
|
|
}
|
|
|
|
test_template_has_all_services() {
|
|
check "Template defines all 24 services"
|
|
local services=(
|
|
"docker-socket-proxy:" "homepage:" "pihole:" "dockhand:"
|
|
"influxdb:" "grafana:" "drawio:" "kroki:" "atomictracker:"
|
|
"archivebox:" "ta-redis:" "ta-elasticsearch:" "tubearchivist:"
|
|
"wakapi:" "mailhog:" "atuin:"
|
|
"reactiveresume-postgres:" "reactiveresume-minio:" "reactiveresume-chrome:" "reactiveresume-app:" "metrics:" "kiwix:" "resumematcher:" "applehealth:"
|
|
)
|
|
local found=0
|
|
for svc in "${services[@]}"; do
|
|
if grep_exists " ${svc}" "$TEMPLATE_FILE"; then
|
|
((found++)) || true
|
|
else
|
|
fail "Service not found in template: $svc"
|
|
fi
|
|
done
|
|
if [[ $found -eq ${#services[@]} ]]; then
|
|
pass "All ${#services[@]} services defined in template"
|
|
fi
|
|
}
|
|
|
|
test_all_services_have_healthchecks() {
|
|
check "All exposed services have healthcheck blocks"
|
|
local exposed_services=("homepage" "pihole" "dockhand" "influxdb" "grafana" "drawio" "kroki" "atomictracker" "archivebox" "tubearchivist" "wakapi" "mailhog" "atuin" "reactiveresume-app" "metrics" "kiwix" "resumematcher" "applehealth")
|
|
local missing=()
|
|
for svc in "${exposed_services[@]}"; do
|
|
local svc_block
|
|
svc_block=$(sed -n "/^ ${svc}:/,/^[^ ]/p" "$TEMPLATE_FILE" || true)
|
|
if echo "$svc_block" | grep_exists "healthcheck:"; then
|
|
:
|
|
else
|
|
missing+=("$svc")
|
|
fi
|
|
done
|
|
if [[ ${#missing[@]} -eq 0 ]]; then
|
|
pass "All exposed services have health checks"
|
|
else
|
|
fail "Services missing health checks: ${missing[*]}"
|
|
fi
|
|
}
|
|
|
|
test_all_services_have_restart_policy() {
|
|
check "All services have restart policy"
|
|
local restart_count
|
|
restart_count=$(grep -c "restart:" "$TEMPLATE_FILE" || true)
|
|
if [[ $restart_count -ge 24 ]]; then
|
|
pass "$restart_count services have restart policies"
|
|
else
|
|
fail "Only $restart_count services have restart policies (expected >= 24)"
|
|
fi
|
|
}
|
|
|
|
test_all_services_have_labels() {
|
|
check "All user-facing services have Homepage labels"
|
|
local label_services=("homepage" "pihole" "dockhand" "influxdb" "grafana" "drawio" "kroki" "atomictracker" "archivebox" "tubearchivist" "wakapi" "mailhog" "atuin" "reactiveresume-app" "metrics" "kiwix" "resumematcher" "applehealth")
|
|
local missing=()
|
|
for svc in "${label_services[@]}"; do
|
|
local svc_block
|
|
svc_block=$(sed -n "/^ ${svc}:/,/^[^ ]/p" "$TEMPLATE_FILE" || true)
|
|
if echo "$svc_block" | grep_exists "homepage.group:"; then
|
|
:
|
|
else
|
|
missing+=("$svc")
|
|
fi
|
|
done
|
|
if [[ ${#missing[@]} -eq 0 ]]; then
|
|
pass "All user-facing services have Homepage discovery labels"
|
|
else
|
|
fail "Services missing labels: ${missing[*]}"
|
|
fi
|
|
}
|
|
|
|
test_dockhand_uses_proxy() {
|
|
check "Dockhand connects through docker-socket-proxy"
|
|
local dockhand_block
|
|
dockhand_block=$(sed -n "/^ dockhand:/,/^[^ ]/p" "$TEMPLATE_FILE" || true)
|
|
if echo "$dockhand_block" | grep_exists "DOCKER_HOST=tcp://docker-socket-proxy:2375"; then
|
|
pass "Dockhand routes through socket proxy"
|
|
else
|
|
fail "Dockhand not configured to use socket proxy (security issue)"
|
|
fi
|
|
}
|
|
|
|
test_no_direct_socket_mounts_except_proxy() {
|
|
check "No direct Docker socket mounts except on socket-proxy"
|
|
local socket_lines
|
|
socket_lines=$(grep -n '/var/run/docker\.sock' "$TEMPLATE_FILE" || true)
|
|
local bad_mounts=0
|
|
while IFS= read -r line; do
|
|
[[ -z "$line" ]] && continue
|
|
local line_num
|
|
line_num=$(echo "$line" | cut -d: -f1)
|
|
local context
|
|
context=$(head -n "$line_num" "$TEMPLATE_FILE" | grep "^ [a-z]" | tail -1 || true)
|
|
if [[ "$context" != *"docker-socket-proxy"* ]]; then
|
|
((bad_mounts++)) || true
|
|
fail "Direct socket mount found outside proxy at line $line_num"
|
|
fi
|
|
done <<< "$socket_lines"
|
|
if [[ $bad_mounts -eq 0 ]]; then
|
|
pass "Only docker-socket-proxy mounts the Docker socket"
|
|
fi
|
|
}
|
|
|
|
test_env_template_completeness() {
|
|
check "demo.env.template has all required variables"
|
|
local required_vars=(
|
|
"COMPOSE_PROJECT_NAME" "COMPOSE_NETWORK_NAME"
|
|
"DEMO_UID" "DEMO_GID" "DEMO_DOCKER_GID"
|
|
"HOMEPAGE_PORT" "PIHOLE_PORT" "DOCKHAND_PORT"
|
|
"INFLUXDB_PORT" "GRAFANA_PORT" "DRAWIO_PORT" "KROKI_PORT"
|
|
"ATOMIC_TRACKER_PORT" "ARCHIVEBOX_PORT" "TUBE_ARCHIVIST_PORT"
|
|
"WAKAPI_PORT" "MAILHOG_PORT" "MAILHOG_SMTP_PORT" "ATUIN_PORT"
|
|
"NETWORK_SUBNET" "NETWORK_GATEWAY"
|
|
"TA_USERNAME" "TA_PASSWORD" "ELASTIC_PASSWORD"
|
|
"GF_SECURITY_ADMIN_USER" "GF_SECURITY_ADMIN_PASSWORD"
|
|
"PIHOLE_WEBPASSWORD"
|
|
"REACTIVE_RESUME_PORT" "RESUME_MINIO_PORT" "METRICS_PORT" "KIWIX_PORT" "RESUME_MATCHER_PORT" "APPLEHEALTH_PORT" "RESUME_POSTGRES_PASSWORD"
|
|
)
|
|
for var in "${required_vars[@]}"; do
|
|
if grep_exists "^${var}=" "$ENV_TEMPLATE"; then
|
|
pass "Env template has $var"
|
|
else
|
|
fail "Env template missing $var"
|
|
fi
|
|
done
|
|
}
|
|
|
|
test_env_template_port_range() {
|
|
check "All ports in env template are in 4000-4099 range"
|
|
local ports_out_of_range=()
|
|
while IFS='=' read -r var val; do
|
|
if [[ "$var" == *"_PORT" && "$val" =~ ^[0-9]+$ ]]; then
|
|
if [[ "$val" -lt 4000 || "$val" -gt 4099 ]]; then
|
|
ports_out_of_range+=("$var=$val")
|
|
fi
|
|
fi
|
|
done < "$ENV_TEMPLATE"
|
|
if [[ ${#ports_out_of_range[@]} -eq 0 ]]; then
|
|
pass "All ports within 4000-4099 range"
|
|
else
|
|
fail "Ports outside range: ${ports_out_of_range[*]}"
|
|
fi
|
|
}
|
|
|
|
test_homepage_configs_exist() {
|
|
check "Homepage configuration files exist"
|
|
local configs=("services.yaml" "widgets.yaml" "settings.yaml" "bookmarks.yaml" "docker.yaml")
|
|
for cfg in "${configs[@]}"; do
|
|
if [[ -f "$PROJECT_ROOT/config/homepage/$cfg" ]]; then
|
|
pass "Homepage config exists: $cfg"
|
|
else
|
|
fail "Homepage config missing: $cfg"
|
|
fi
|
|
done
|
|
}
|
|
|
|
test_grafana_configs_exist() {
|
|
check "Grafana configuration files exist"
|
|
local configs=("datasources.yml" "dashboards.yml" "dashboards/docker-overview.json")
|
|
for cfg in "${configs[@]}"; do
|
|
if [[ -f "$PROJECT_ROOT/config/grafana/$cfg" ]]; then
|
|
pass "Grafana config exists: $cfg"
|
|
else
|
|
fail "Grafana config missing: $cfg"
|
|
fi
|
|
done
|
|
}
|
|
|
|
test_scripts_exist() {
|
|
check "Deployment scripts exist"
|
|
local scripts=("scripts/demo-stack.sh" "scripts/demo-test.sh" "scripts/validate-all.sh")
|
|
for script in "${scripts[@]}"; do
|
|
if [[ -f "$PROJECT_ROOT/$script" ]]; then
|
|
pass "Script exists: $script"
|
|
else
|
|
fail "Script missing: $script"
|
|
fi
|
|
done
|
|
}
|
|
|
|
test_scripts_use_strict_mode() {
|
|
check "All scripts use strict mode (set -euo pipefail)"
|
|
local found_scripts
|
|
found_scripts=("$PROJECT_ROOT/scripts/"*.sh)
|
|
for script in "${found_scripts[@]}"; do
|
|
if head -5 "$script" | grep_exists "set -euo pipefail"; then
|
|
pass "$(basename "$script") uses strict mode"
|
|
else
|
|
fail "$(basename "$script") missing strict mode"
|
|
fi
|
|
done
|
|
}
|
|
|
|
echo "======================================"
|
|
echo "Unit Tests: Configuration Validation"
|
|
echo "======================================"
|
|
echo ""
|
|
|
|
test_template_exists
|
|
test_template_has_required_sections
|
|
test_template_has_all_services
|
|
test_all_services_have_healthchecks
|
|
test_all_services_have_restart_policy
|
|
test_all_services_have_labels
|
|
test_dockhand_uses_proxy
|
|
test_no_direct_socket_mounts_except_proxy
|
|
test_env_template_completeness
|
|
test_env_template_port_range
|
|
test_homepage_configs_exist
|
|
test_grafana_configs_exist
|
|
test_scripts_exist
|
|
test_scripts_use_strict_mode
|
|
|
|
echo ""
|
|
echo "======================================"
|
|
echo "RESULTS: $PASS passed, $FAIL failed"
|
|
echo "======================================"
|
|
[[ $FAIL -eq 0 ]]
|