From e38c0f4dcc3f6f44e0ef28b8ea7b6d8cb2778c2d Mon Sep 17 00:00:00 2001 From: voidborne-d Date: Sat, 18 Apr 2026 21:55:01 +0000 Subject: [PATCH] fix: Docker entrypoint arg handling + configurable model directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #384: docker run with --source/--tick-ms flags now works correctly. Fixes #399: model files in mounted volumes are now discoverable via MODELS_DIR env var. Root cause (issue #384): The Dockerfile used ENTRYPOINT ["/bin/sh", "-c"] with a shell-form CMD. When users passed flags like `--source wifi --tick-ms 500` as docker run arguments, Docker replaced CMD entirely, resulting in `/bin/sh -c "--source wifi --tick-ms 500"` which executes `--source` as a shell command → `--source: not found`. Root cause (issue #399): Model directory was hardcoded to the relative path `data/models`. When Docker users mounted models to `/app/models/`, the scan looked in the wrong place. Changes: 1. docker/docker-entrypoint.sh (new): - Proper entrypoint script that handles both env-var-based defaults and user-passed CLI flags - No arguments → starts server with CSI_SOURCE env var as --source - Flag arguments (start with -) → prepends /app/sensing-server + defaults, appends user flags (clap last-wins allows overrides) - Non-flag first arg → exec passthrough (e.g., /bin/sh for debugging) - Sets --bind-addr 0.0.0.0 (was 127.0.0.1 which blocks container access) 2. docker/Dockerfile.rust: - Switch from ENTRYPOINT ["/bin/sh", "-c"] to exec-form entrypoint - Add MODELS_DIR env var (default: data/models) - COPY the entrypoint script into the image 3. docker/docker-compose.yml: - Remove shell-form command (entrypoint handles defaults) - Add MODELS_DIR env var 4. model_manager.rs + main.rs: - Replace hardcoded `data/models` path with `effective_models_dir()` / `models_dir()` that reads MODELS_DIR env var at runtime - Docker users can now: docker run -v /host/models:/app/models -e MODELS_DIR=/app/models 5. tests/test_docker_entrypoint.sh (new, 17 tests): - Default CSI_SOURCE substitution (6 assertions) - Custom CSI_SOURCE propagation - User-passed flag arguments (--source, --tick-ms, --model) - Unset CSI_SOURCE defaults to auto - Explicit command passthrough - MODELS_DIR env var propagation --- docker/Dockerfile.rust | 16 +- docker/docker-compose.yml | 9 +- docker/docker-entrypoint.sh | 32 ++++ .../wifi-densepose-sensing-server/src/main.rs | 23 ++- .../src/model_manager.rs | 19 ++- tests/test_docker_entrypoint.sh | 142 ++++++++++++++++++ 6 files changed, 225 insertions(+), 16 deletions(-) create mode 100755 docker/docker-entrypoint.sh create mode 100755 tests/test_docker_entrypoint.sh diff --git a/docker/Dockerfile.rust b/docker/Dockerfile.rust index 73cc58a15..76f7afd96 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 436dc1988..d3d29d45f 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 000000000..ac62cb21b --- /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 029287c1c..e2a6d8847 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 566b8107c..4a9609707 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 000000000..1fa980eb1 --- /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