feat(streamlit): Multi-instance support for compartmentalized apps

- Add multi-instance mode: run multiple apps on different ports
- New UCI config structure with 'instance' sections
- Container starts multiple streamlit processes via STREAMLIT_INSTANCES env
- CLI commands: instance list/add/remove/enable/disable
- Each instance has its own port, requirements auto-install
- Backward compatible: single-app mode still works
- Bumped to 1.0.0-r4

Example config:
  config instance 'dashboard'
    option app 'dashboard.py'
    option port '8502'
    option enabled '1'

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-01-26 12:31:37 +01:00
parent 24dc62cb79
commit a596eb64d8
3 changed files with 277 additions and 75 deletions

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-streamlit PKG_NAME:=secubox-app-streamlit
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=3 PKG_RELEASE:=4
PKG_ARCH:=all PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr> PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>
@ -30,9 +30,10 @@ Streamlit App Platform - Self-hosted Python data app platform
Features: Features:
- Run Streamlit apps in LXC container - Run Streamlit apps in LXC container
- Multi-instance support (multiple apps on different ports)
- Python 3.12 with Streamlit 1.53.x - Python 3.12 with Streamlit 1.53.x
- App management (add, remove, switch)
- Auto-install requirements.txt dependencies - Auto-install requirements.txt dependencies
- HAProxy publish wizard for vhost routing
- Web dashboard integration - Web dashboard integration
- Configurable port and memory limits - Configurable port and memory limits

View File

@ -1,10 +1,9 @@
config streamlit 'main' config streamlit 'main'
option enabled '0' option enabled '0'
option http_port '8501'
option http_host '0.0.0.0'
option data_path '/srv/streamlit' option data_path '/srv/streamlit'
option memory_limit '512M' option memory_limit '1024M'
option active_app 'hello' # Base port - instances use 8501, 8502, 8503...
option base_port '8501'
config server 'server' config server 'server'
option headless 'true' option headless 'true'
@ -12,7 +11,19 @@ config server 'server'
option theme_base 'dark' option theme_base 'dark'
option theme_primary_color '#0ff' option theme_primary_color '#0ff'
config app 'hello' # Default hello app on port 8501
config instance 'hello'
option name 'Hello World' option name 'Hello World'
option path 'hello.py' option app 'hello.py'
option port '8501'
option enabled '1' option enabled '1'
option autostart '1'
# Example: Add more instances
# config instance 'dashboard'
# option name 'Dashboard App'
# option app 'dashboard.py'
# option port '8502'
# option enabled '1'
# option autostart '1'
# option domain 'dashboard.example.com'

View File

@ -72,7 +72,12 @@ Commands:
app list List deployed apps app list List deployed apps
app add <name> <path> Deploy new app app add <name> <path> Deploy new app
app remove <name> Remove app app remove <name> Remove app
app run <name> Switch active app
instance list List running instances
instance add <name> <app> <port> Add instance config
instance remove <name> Remove instance config
instance enable <name> Enable instance
instance disable <name> Disable instance
service-run Start service (used by init) service-run Start service (used by init)
service-stop Stop service (used by init) service-stop Stop service (used by init)
@ -83,12 +88,18 @@ Configuration:
Data directory: Data directory:
/srv/streamlit /srv/streamlit
Multi-Instance Mode:
Add instances in /etc/config/streamlit:
config instance 'myapp'
option app 'myapp.py'
option port '8502'
option enabled '1'
Requirements: Requirements:
Place a requirements.txt file in /srv/streamlit/apps/ to auto-install Place requirements.txt in /srv/streamlit/apps/
Python dependencies. Supported naming conventions: - <appname>.requirements.txt
- <appname>.requirements.txt (e.g., sappix.requirements.txt) - <appname>_requirements.txt
- <appname>_requirements.txt (e.g., sappix_requirements.txt) - requirements.txt (global)
- requirements.txt (global fallback)
EOF EOF
} }
@ -140,16 +151,15 @@ lxc_create_rootfs() {
cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \ cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \
echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf" echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf"
# Create startup script # Create startup script - Multi-instance support
cat > "$rootfs/opt/start-streamlit.sh" << 'STARTUP' cat > "$rootfs/opt/start-streamlit.sh" << 'STARTUP'
#!/bin/sh #!/bin/sh
set -e
# Install Python and Streamlit on first run # Install Python and Streamlit on first run
if [ ! -f /opt/.installed ]; then if [ ! -f /opt/.installed ]; then
echo "Installing Python 3.12 and dependencies..." echo "Installing Python 3.12 and dependencies..."
apk update apk update
apk add --no-cache python3 py3-pip apk add --no-cache python3 py3-pip procps
echo "Installing Streamlit..." echo "Installing Streamlit..."
pip3 install --break-system-packages streamlit 2>/dev/null || \ pip3 install --break-system-packages streamlit 2>/dev/null || \
@ -159,18 +169,10 @@ if [ ! -f /opt/.installed ]; then
echo "Installation complete!" echo "Installation complete!"
fi fi
# Find active app # Create default hello app if missing
ACTIVE_APP="${STREAMLIT_APP:-hello.py}" mkdir -p /srv/apps
APP_PATH="/srv/apps/${ACTIVE_APP}" if [ ! -f "/srv/apps/hello.py" ]; then
cat > /srv/apps/hello.py << 'HELLO'
# Fallback to hello.py if app not found
if [ ! -f "$APP_PATH" ]; then
if [ -f "/srv/apps/hello.py" ]; then
APP_PATH="/srv/apps/hello.py"
else
echo "No app found, creating default..."
mkdir -p /srv/apps
cat > /srv/apps/hello.py << 'HELLO'
import streamlit as st import streamlit as st
st.set_page_config(page_title="SecuBox Streamlit", page_icon="⚡", layout="wide") st.set_page_config(page_title="SecuBox Streamlit", page_icon="⚡", layout="wide")
st.title("⚡ SECUBOX STREAMLIT ⚡") st.title("⚡ SECUBOX STREAMLIT ⚡")
@ -184,42 +186,80 @@ with col3:
st.metric("Platform", "SecuBox") st.metric("Platform", "SecuBox")
st.info("Deploy your Streamlit apps via LuCI dashboard") st.info("Deploy your Streamlit apps via LuCI dashboard")
HELLO HELLO
APP_PATH="/srv/apps/hello.py"
fi
fi fi
# Get app name without .py extension # Function to install requirements for an app
APP_NAME=$(basename "$APP_PATH" .py) install_requirements() {
local app_name="$1"
# Install app-specific requirements if present for req_file in "/srv/apps/${app_name}.requirements.txt" \
# Check for: <app>.requirements.txt, <app>_requirements.txt, or global requirements.txt "/srv/apps/${app_name}_requirements.txt" \
for req_file in "/srv/apps/${APP_NAME}.requirements.txt" \ "/srv/apps/requirements.txt"; do
"/srv/apps/${APP_NAME}_requirements.txt" \ if [ -f "$req_file" ]; then
"/srv/apps/requirements.txt"; do REQ_HASH=$(md5sum "$req_file" 2>/dev/null | cut -d' ' -f1)
if [ -f "$req_file" ]; then REQ_MARKER="/opt/.req_${app_name}_${REQ_HASH}"
REQ_HASH=$(md5sum "$req_file" 2>/dev/null | cut -d' ' -f1) if [ ! -f "$REQ_MARKER" ]; then
REQ_MARKER="/opt/.req_${APP_NAME}_${REQ_HASH}" echo "Installing requirements for $app_name from: $req_file"
if [ ! -f "$REQ_MARKER" ]; then pip3 install --break-system-packages -r "$req_file" 2>/dev/null || \
echo "Installing requirements from: $req_file" pip3 install -r "$req_file" 2>/dev/null || true
pip3 install --break-system-packages -r "$req_file" 2>/dev/null || \ touch "$REQ_MARKER"
pip3 install -r "$req_file" 2>/dev/null || \ fi
echo "Warning: Some requirements may have failed to install" break
touch "$REQ_MARKER"
fi fi
break done
}
# Function to start a single Streamlit instance
start_instance() {
local app_file="$1"
local port="$2"
local app_name=$(basename "$app_file" .py)
if [ ! -f "/srv/apps/$app_file" ]; then
echo "App not found: $app_file"
return 1
fi
install_requirements "$app_name"
echo "Starting instance: $app_name on port $port"
cd /srv/apps
streamlit run "$app_file" \
--server.address="0.0.0.0" \
--server.port="$port" \
--server.headless=true \
--browser.gatherUsageStats=false \
--theme.base="${STREAMLIT_THEME_BASE:-dark}" \
--theme.primaryColor="${STREAMLIT_THEME_PRIMARY:-#0ff}" &
echo $! > "/tmp/streamlit_${app_name}.pid"
}
# Parse STREAMLIT_INSTANCES env var (format: "app1.py:8501,app2.py:8502")
if [ -n "$STREAMLIT_INSTANCES" ]; then
echo "Multi-instance mode: $STREAMLIT_INSTANCES"
IFS=','
for instance in $STREAMLIT_INSTANCES; do
app_file=$(echo "$instance" | cut -d: -f1)
port=$(echo "$instance" | cut -d: -f2)
start_instance "$app_file" "$port"
done
unset IFS
else
# Single instance mode (backward compatible)
ACTIVE_APP="${STREAMLIT_APP:-hello.py}"
PORT="${STREAMLIT_PORT:-8501}"
start_instance "$ACTIVE_APP" "$PORT"
fi
# Keep container running and monitor processes
echo "Streamlit instances started. Monitoring..."
while true; do
sleep 30
# Check if any streamlit process is running
if ! pgrep -f "streamlit" >/dev/null; then
echo "No streamlit processes running, exiting..."
exit 1
fi fi
done done
echo "Starting Streamlit with app: $APP_PATH"
cd /srv/apps
exec streamlit run "$APP_PATH" \
--server.address="${STREAMLIT_HOST:-0.0.0.0}" \
--server.port="${STREAMLIT_PORT:-8501}" \
--server.headless=true \
--browser.gatherUsageStats="${STREAMLIT_STATS:-false}" \
--theme.base="${STREAMLIT_THEME_BASE:-dark}" \
--theme.primaryColor="${STREAMLIT_THEME_PRIMARY:-#0ff}"
STARTUP STARTUP
chmod +x "$rootfs/opt/start-streamlit.sh" chmod +x "$rootfs/opt/start-streamlit.sh"
@ -227,6 +267,26 @@ STARTUP
return 0 return 0
} }
# Build instances string from UCI config
build_instances_string() {
local instances=""
local _add_instance() {
local section="$1"
local inst_enabled inst_app inst_port
config_get inst_enabled "$section" enabled "0"
config_get inst_app "$section" app ""
config_get inst_port "$section" port ""
if [ "$inst_enabled" = "1" ] && [ -n "$inst_app" ] && [ -n "$inst_port" ]; then
[ -n "$instances" ] && instances="${instances},"
instances="${instances}${inst_app}:${inst_port}"
fi
}
config_load "$CONFIG"
config_foreach _add_instance instance
echo "$instances"
}
# Create LXC config # Create LXC config
lxc_create_config() { lxc_create_config() {
load_config load_config
@ -242,14 +302,20 @@ lxc_create_config() {
*) mem_bytes="$memory_limit" ;; *) mem_bytes="$memory_limit" ;;
esac esac
# Determine active app file # Build multi-instance string or fallback to single app
local app_file local instances_str
if [ -f "$APPS_PATH/${active_app}.py" ]; then instances_str=$(build_instances_string)
app_file="${active_app}.py"
elif [ -f "$APPS_PATH/${active_app}" ]; then # Fallback: if no instances defined, use active_app
app_file="${active_app}" local app_file=""
else if [ -z "$instances_str" ]; then
app_file="hello.py" if [ -f "$APPS_PATH/${active_app}.py" ]; then
app_file="${active_app}.py"
elif [ -f "$APPS_PATH/${active_app}" ]; then
app_file="${active_app}"
else
app_file="hello.py"
fi
fi fi
cat > "$LXC_CONFIG" << EOF cat > "$LXC_CONFIG" << EOF
@ -267,13 +333,21 @@ lxc.mount.entry = $APPS_PATH srv/apps none bind,create=dir 0 0
lxc.mount.entry = $data_path/logs srv/logs none bind,create=dir 0 0 lxc.mount.entry = $data_path/logs srv/logs none bind,create=dir 0 0
# Environment # Environment
lxc.environment = STREAMLIT_HOST=$http_host
lxc.environment = STREAMLIT_PORT=$http_port
lxc.environment = STREAMLIT_APP=$app_file
lxc.environment = STREAMLIT_HEADLESS=$headless
lxc.environment = STREAMLIT_STATS=$gather_stats
lxc.environment = STREAMLIT_THEME_BASE=$theme_base lxc.environment = STREAMLIT_THEME_BASE=$theme_base
lxc.environment = STREAMLIT_THEME_PRIMARY=$theme_primary lxc.environment = STREAMLIT_THEME_PRIMARY=$theme_primary
EOF
# Add multi-instance or single-instance env vars
if [ -n "$instances_str" ]; then
echo "lxc.environment = STREAMLIT_INSTANCES=$instances_str" >> "$LXC_CONFIG"
else
cat >> "$LXC_CONFIG" << EOF
lxc.environment = STREAMLIT_APP=$app_file
lxc.environment = STREAMLIT_PORT=$http_port
EOF
fi
cat >> "$LXC_CONFIG" << EOF
# Security # Security
lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio
@ -665,6 +739,112 @@ cmd_service_stop() {
lxc_stop lxc_stop
} }
# Instance management
cmd_instance_list() {
load_config
echo "{"
echo ' "instances": ['
local first=1
_list_instance() {
local section="$1"
local name app port enabled
config_get name "$section" name "$section"
config_get app "$section" app ""
config_get port "$section" port ""
config_get enabled "$section" enabled "0"
[ $first -eq 0 ] && echo ","
first=0
printf ' {"id": "%s", "name": "%s", "app": "%s", "port": "%s", "enabled": %s}' \
"$section" "$name" "$app" "$port" "$([ "$enabled" = "1" ] && echo "true" || echo "false")"
}
config_load "$CONFIG"
config_foreach _list_instance instance
echo ""
echo " ]"
echo "}"
}
cmd_instance_add() {
local name="$1"
local app="$2"
local port="$3"
if [ -z "$name" ] || [ -z "$app" ] || [ -z "$port" ]; then
log_error "Usage: streamlitctl instance add <name> <app.py> <port>"
return 1
fi
# Validate port is numeric
if ! echo "$port" | grep -qE '^[0-9]+$'; then
log_error "Port must be a number"
return 1
fi
uci set "${CONFIG}.${name}=instance"
uci set "${CONFIG}.${name}.name=$name"
uci set "${CONFIG}.${name}.app=$app"
uci set "${CONFIG}.${name}.port=$port"
uci set "${CONFIG}.${name}.enabled=1"
uci commit "$CONFIG"
log_info "Instance '$name' added (app: $app, port: $port)"
echo '{"success": true, "message": "Instance added", "name": "'"$name"'", "port": '"$port"'}'
}
cmd_instance_remove() {
local name="$1"
if [ -z "$name" ]; then
log_error "Usage: streamlitctl instance remove <name>"
return 1
fi
uci -q delete "${CONFIG}.${name}" 2>/dev/null || {
log_error "Instance not found: $name"
return 1
}
uci commit "$CONFIG"
log_info "Instance '$name' removed"
echo '{"success": true, "message": "Instance removed", "name": "'"$name"'"}'
}
cmd_instance_enable() {
local name="$1"
if [ -z "$name" ]; then
log_error "Usage: streamlitctl instance enable <name>"
return 1
fi
uci set "${CONFIG}.${name}.enabled=1" && uci commit "$CONFIG" || {
log_error "Instance not found: $name"
return 1
}
log_info "Instance '$name' enabled"
echo '{"success": true, "message": "Instance enabled", "name": "'"$name"'"}'
}
cmd_instance_disable() {
local name="$1"
if [ -z "$name" ]; then
log_error "Usage: streamlitctl instance disable <name>"
return 1
fi
uci set "${CONFIG}.${name}.enabled=0" && uci commit "$CONFIG" || {
log_error "Instance not found: $name"
return 1
}
log_info "Instance '$name' disabled"
echo '{"success": true, "message": "Instance disabled", "name": "'"$name"'"}'
}
# Main # Main
case "${1:-}" in case "${1:-}" in
install) shift; cmd_install "$@" ;; install) shift; cmd_install "$@" ;;
@ -686,8 +866,18 @@ case "${1:-}" in
list) shift; cmd_app_list "$@" ;; list) shift; cmd_app_list "$@" ;;
add) shift; cmd_app_add "$@" ;; add) shift; cmd_app_add "$@" ;;
remove) shift; cmd_app_remove "$@" ;; remove) shift; cmd_app_remove "$@" ;;
run) shift; cmd_app_run "$@" ;; *) echo "Usage: streamlitctl app {list|add|remove}"; exit 1 ;;
*) echo "Usage: streamlitctl app {list|add|remove|run}"; exit 1 ;; esac
;;
instance)
shift
case "${1:-}" in
list) shift; cmd_instance_list "$@" ;;
add) shift; cmd_instance_add "$@" ;;
remove) shift; cmd_instance_remove "$@" ;;
enable) shift; cmd_instance_enable "$@" ;;
disable) shift; cmd_instance_disable "$@" ;;
*) echo "Usage: streamlitctl instance {list|add|remove|enable|disable}"; exit 1 ;;
esac esac
;; ;;
service-run) shift; cmd_service_run "$@" ;; service-run) shift; cmd_service_run "$@" ;;