Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions docker/Dockerfile.rust
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
9 changes: 7 additions & 2 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
32 changes: 32 additions & 0 deletions docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
142 changes: 142 additions & 0 deletions tests/test_docker_entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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