diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust index 73cc58a1..76f7afd9 100644 --- a/docker/Dockerfile.rust +++ b/docker/Dockerfile.rust @@ -50,7 +50,15 @@ ENV RUST_LOG=info # Override at runtime: docker run -e CSI_SOURCE=esp32 ... ENV CSI_SOURCE=auto -ENTRYPOINT ["/bin/sh", "-c"] -# Shell-form CMD allows $CSI_SOURCE to be substituted at container start. -# The ENV default above (CSI_SOURCE=auto) applies when the variable is unset. -CMD ["/app/sensing-server --source ${CSI_SOURCE} --tick-ms 100 --ui-path /app/ui --http-port 3000 --ws-port 3001"] +# MODELS_DIR controls where the server scans for .rvf model files. +# Mount a host directory here to make models visible to the API: +# docker run -v /path/to/models:/app/models -e MODELS_DIR=/app/models ... +ENV MODELS_DIR=data/models + +COPY docker/docker-entrypoint.sh /app/docker-entrypoint.sh + +# Exec-form ENTRYPOINT so Docker appends user arguments correctly. +# Pass flags directly: docker run --source esp32 --tick-ms 500 +# Or use env vars: docker run -e CSI_SOURCE=esp32 +ENTRYPOINT ["/app/docker-entrypoint.sh"] +CMD [] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 436dc198..d3d29d45 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -18,8 +18,13 @@ services: # wifi — use host Wi-Fi RSSI/scan data (Windows netsh) # simulated — generate synthetic CSI data (no hardware required) - CSI_SOURCE=${CSI_SOURCE:-auto} - # command is passed as arguments to ENTRYPOINT (/bin/sh -c), so $CSI_SOURCE is expanded by the shell. - command: ["/app/sensing-server --source ${CSI_SOURCE:-auto} --tick-ms 100 --ui-path /app/ui --http-port 3000 --ws-port 3001"] + # MODELS_DIR controls where the server scans for .rvf model files. + # Mount a host directory and set this to make models visible: + # volumes: ["/path/to/models:/app/models"] + # MODELS_DIR=/app/models + - MODELS_DIR=${MODELS_DIR:-data/models} + # No explicit command needed — docker-entrypoint.sh uses CSI_SOURCE. + # Override with: command: ["--source", "esp32", "--tick-ms", "500"] python-sensing: build: diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100755 index 00000000..ac62cb21 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Docker entrypoint for WiFi-DensePose sensing server. +# +# Supports two usage patterns: +# +# 1. No arguments — use defaults from environment: +# docker run -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest +# +# 2. Pass CLI flags directly: +# docker run ruvnet/wifi-densepose:latest --source esp32 --tick-ms 500 +# docker run ruvnet/wifi-densepose:latest --model /app/models/my.rvf +# +# Environment variables: +# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated +# MODELS_DIR — directory to scan for .rvf model files (default: data/models) +set -e + +# If the first argument looks like a flag (starts with -), prepend the +# server binary so users can just pass flags: +# docker run --source esp32 --tick-ms 500 +if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then + set -- /app/sensing-server \ + --source "${CSI_SOURCE:-auto}" \ + --tick-ms 100 \ + --ui-path /app/ui \ + --http-port 3000 \ + --ws-port 3001 \ + --bind-addr 0.0.0.0 \ + "$@" +fi + +exec "$@" diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs index 029287c1..e2a6d884 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/main.rs @@ -2797,7 +2797,7 @@ async fn delete_model( if safe_id.is_empty() || safe_id != id { return Json(serde_json::json!({ "error": "invalid model id", "success": false })); } - let path = PathBuf::from("data/models").join(format!("{}.rvf", safe_id)); + let path = effective_models_dir().join(format!("{}.rvf", safe_id)); if path.exists() { if let Err(e) = std::fs::remove_file(&path) { warn!("Failed to delete model file {:?}: {}", path, e); @@ -2842,9 +2842,18 @@ async fn activate_lora_profile( Json(serde_json::json!({ "success": true, "profile": profile })) } -/// Scan `data/models/` for `.rvf` files and return metadata. +/// Return the effective models directory, respecting the `MODELS_DIR` +/// environment variable. Defaults to `data/models`. +fn effective_models_dir() -> PathBuf { + PathBuf::from( + std::env::var("MODELS_DIR").unwrap_or_else(|_| "data/models".to_string()), + ) +} + +/// Scan the models directory for `.rvf` files and return metadata. +/// Respects the `MODELS_DIR` environment variable. fn scan_model_files() -> Vec { - let dir = PathBuf::from("data/models"); + let dir = effective_models_dir(); let mut models = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { @@ -2874,9 +2883,10 @@ fn scan_model_files() -> Vec { models } -/// Scan `data/models/` for `.lora.json` LoRA profile files. +/// Scan the models directory for `.lora.json` LoRA profile files. +/// Respects the `MODELS_DIR` environment variable. fn scan_lora_profiles() -> Vec { - let dir = PathBuf::from("data/models"); + let dir = effective_models_dir(); let mut profiles = Vec::new(); if let Ok(entries) = std::fs::read_dir(&dir) { for entry in entries.flatten() { @@ -4604,7 +4614,8 @@ async fn main() { } // Ensure data directories exist for models and recordings - let _ = std::fs::create_dir_all("data/models"); + let models_dir = effective_models_dir(); + let _ = std::fs::create_dir_all(&models_dir); let _ = std::fs::create_dir_all("data/recordings"); // Discover model and recording files on startup diff --git a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/model_manager.rs b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/model_manager.rs index 566b8107..4a960970 100644 --- a/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/model_manager.rs +++ b/rust-port/wifi-densepose-rs/crates/wifi-densepose-sensing-server/src/model_manager.rs @@ -30,8 +30,19 @@ use crate::rvf_container::RvfReader; // ── Models data directory ──────────────────────────────────────────────────── -/// Base directory for RVF model files. -pub const MODELS_DIR: &str = "data/models"; +/// Default base directory for RVF model files. +/// +/// Overridden at runtime by the `MODELS_DIR` environment variable so that +/// Docker users can point to a mounted volume without rebuilding: +/// docker run -v /path/to/models:/app/models -e MODELS_DIR=/app/models ... +pub const MODELS_DIR_DEFAULT: &str = "data/models"; + +/// Return the effective models directory, respecting `MODELS_DIR` env var. +pub fn models_dir() -> PathBuf { + PathBuf::from( + std::env::var("MODELS_DIR").unwrap_or_else(|_| MODELS_DIR_DEFAULT.to_string()), + ) +} // ── Types ──────────────────────────────────────────────────────────────────── @@ -110,7 +121,7 @@ pub type AppState = Arc>; /// Scan the models directory and build `ModelInfo` for each `.rvf` file. async fn scan_models() -> Vec { - let dir = PathBuf::from(MODELS_DIR); + let dir = models_dir(); let mut models = Vec::new(); let mut entries = match tokio::fs::read_dir(&dir).await { @@ -204,7 +215,7 @@ async fn scan_models() -> Vec { /// Load a model from disk by ID and return its `LoadedModelState`. fn load_model_from_disk(model_id: &str) -> Result { - let file_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf")); + let file_path = models_dir().join(format!("{model_id}.rvf")); let reader = RvfReader::from_file(&file_path)?; let manifest = reader.manifest().unwrap_or_default(); diff --git a/tests/test_docker_entrypoint.sh b/tests/test_docker_entrypoint.sh new file mode 100755 index 00000000..1fa980eb --- /dev/null +++ b/tests/test_docker_entrypoint.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# Regression tests for docker-entrypoint.sh +# +# Validates that the entrypoint script correctly handles: +# 1. No arguments → uses env var defaults +# 2. Flag arguments → prepends sensing-server binary +# 3. Explicit binary path → passes through unchanged +# 4. CSI_SOURCE env var substitution +# 5. MODELS_DIR env var propagation +# +# These tests use a stub sensing-server that just prints its args. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENTRYPOINT="$SCRIPT_DIR/../docker/docker-entrypoint.sh" + +PASS=0 +FAIL=0 + +assert_contains() { + local test_name="$1" + local haystack="$2" + local needle="$3" + if printf '%s\n' "$haystack" | grep -qF -- "$needle"; then + PASS=$((PASS + 1)) + echo " ✓ $test_name" + else + FAIL=$((FAIL + 1)) + echo " ✗ $test_name" + echo " expected to contain: $needle" + echo " got: $haystack" + fi +} + +assert_not_contains() { + local test_name="$1" + local haystack="$2" + local needle="$3" + if printf '%s\n' "$haystack" | grep -qF -- "$needle"; then + FAIL=$((FAIL + 1)) + echo " ✗ $test_name" + echo " expected NOT to contain: $needle" + echo " got: $haystack" + else + PASS=$((PASS + 1)) + echo " ✓ $test_name" + fi +} + +# Create a temporary stub for /app/sensing-server that just prints args +TMPDIR=$(mktemp -d) +trap "rm -rf $TMPDIR" EXIT + +STUB="$TMPDIR/sensing-server" +cat > "$STUB" << 'EOF' +#!/bin/sh +echo "EXEC_ARGS: $@" +EOF +chmod +x "$STUB" + +# We'll modify the entrypoint to use our stub path for testing +TEST_ENTRYPOINT="$TMPDIR/docker-entrypoint.sh" +sed "s|/app/sensing-server|$STUB|g" "$ENTRYPOINT" > "$TEST_ENTRYPOINT" +chmod +x "$TEST_ENTRYPOINT" + +echo "=== Docker entrypoint tests ===" + +# Test 1: No arguments — should use CSI_SOURCE default (auto) +echo "" +echo "Test 1: No arguments (default CSI_SOURCE=auto)" +OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1) +assert_contains "includes --source auto" "$OUT" "--source auto" +assert_contains "includes --tick-ms 100" "$OUT" "--tick-ms 100" +assert_contains "includes --ui-path" "$OUT" "--ui-path /app/ui" +assert_contains "includes --http-port 3000" "$OUT" "--http-port 3000" +assert_contains "includes --ws-port 3001" "$OUT" "--ws-port 3001" +assert_contains "includes --bind-addr 0.0.0.0" "$OUT" "--bind-addr 0.0.0.0" + +# Test 2: CSI_SOURCE=esp32 — should substitute +echo "" +echo "Test 2: CSI_SOURCE=esp32" +OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" 2>&1) +assert_contains "includes --source esp32" "$OUT" "--source esp32" + +# Test 3: Flag arguments — should prepend binary +echo "" +echo "Test 3: User passes --source wifi --tick-ms 500" +OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" --source wifi --tick-ms 500 2>&1) +assert_contains "includes --source wifi" "$OUT" "--source wifi" +assert_contains "includes --tick-ms 500" "$OUT" "--tick-ms 500" + +# Test 4: No CSI_SOURCE set — should default to auto +echo "" +echo "Test 4: CSI_SOURCE unset" +OUT=$(unset CSI_SOURCE; "$TEST_ENTRYPOINT" 2>&1) +assert_contains "includes --source auto (default)" "$OUT" "--source auto" + +# Test 5: User passes --model flag — should be appended +echo "" +echo "Test 5: User passes --model /app/models/my.rvf" +OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" --model /app/models/my.rvf 2>&1) +assert_contains "includes --model" "$OUT" "--model /app/models/my.rvf" +assert_contains "also includes default flags" "$OUT" "--source esp32" + +# Test 6: CSI_SOURCE=simulated +echo "" +echo "Test 6: CSI_SOURCE=simulated" +OUT=$(CSI_SOURCE=simulated "$TEST_ENTRYPOINT" 2>&1) +assert_contains "includes --source simulated" "$OUT" "--source simulated" + +# Test 7: Explicit binary path passed (e.g., docker run /bin/sh) +# First arg does NOT start with -, so entrypoint should exec it directly +echo "" +echo "Test 7: Explicit command (echo hello)" +OUT=$("$TEST_ENTRYPOINT" echo hello 2>&1) +assert_contains "passes through explicit command" "$OUT" "hello" +assert_not_contains "does not inject sensing-server flags" "$OUT" "--source" + +# Test 8: MODELS_DIR env var is passed through to the process +echo "" +echo "Test 8: MODELS_DIR env var propagation" +# Create a stub that prints MODELS_DIR +ENV_STUB="$TMPDIR/env-sensing-server" +cat > "$ENV_STUB" << 'ENVEOF' +#!/bin/sh +echo "MODELS_DIR=${MODELS_DIR:-unset}" +ENVEOF +chmod +x "$ENV_STUB" +ENV_ENTRYPOINT="$TMPDIR/env-entrypoint.sh" +sed "s|/app/sensing-server|$ENV_STUB|g" "$ENTRYPOINT" > "$ENV_ENTRYPOINT" +chmod +x "$ENV_ENTRYPOINT" + +OUT=$(MODELS_DIR=/app/models CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1) +assert_contains "MODELS_DIR is visible" "$OUT" "MODELS_DIR=/app/models" + +OUT=$(unset MODELS_DIR; CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1) +assert_contains "MODELS_DIR defaults to unset" "$OUT" "MODELS_DIR=unset" + +echo "" +echo "=== Results: $PASS passed, $FAIL failed ===" +[ "$FAIL" -eq 0 ] || exit 1