Merge pull request #402 from voidborne-d/fix/docker-entrypoint-and-model-path

fix: Docker entrypoint arg handling + configurable model directory
This commit is contained in:
rUv 2026-04-20 10:25:27 -04:00 committed by GitHub
commit b816292ead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 225 additions and 16 deletions

View file

@ -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 <image> --source esp32 --tick-ms 500
# Or use env vars: docker run -e CSI_SOURCE=esp32 <image>
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD []

View file

@ -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:

32
docker/docker-entrypoint.sh Executable file
View file

@ -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 <image> --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 "$@"

View file

@ -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<serde_json::Value> {
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<serde_json::Value> {
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<serde_json::Value> {
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

View file

@ -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<RwLock<super::AppStateInner>>;
/// Scan the models directory and build `ModelInfo` for each `.rvf` file.
async fn scan_models() -> Vec<ModelInfo> {
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<ModelInfo> {
/// Load a model from disk by ID and return its `LoadedModelState`.
fn load_model_from_disk(model_id: &str) -> Result<LoadedModelState, String> {
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();

142
tests/test_docker_entrypoint.sh Executable file
View file

@ -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 <image> /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