fix: Docker entrypoint arg handling + configurable model directory

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
This commit is contained in:
voidborne-d
2026-04-18 21:55:01 +00:00
parent 8914538bfe
commit e38c0f4dcc
6 changed files with 225 additions and 16 deletions
@@ -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
@@ -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();