CrowdSec: - Change LAPI default port from 8080 to 8180 (avoid Docker conflict) - Update bouncer config, init script, and RPCD dashboard - Fix port detection hex value (1FF4 for 8180) Streamlit: - Complete rewrite with folder-based app structure - Multi-instance support (multiple apps on different ports) - Gitea integration (clone, pull, setup commands) - Auto-install requirements.txt with hash-based caching HexoJS: - Multi-instance support with folder structure - Multiple blog instances on different ports HAProxy: - Auto-generate fallback backends (luci, apps, default_luci) - Add --server letsencrypt to ACME commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1154 lines
28 KiB
Bash
1154 lines
28 KiB
Bash
#!/bin/sh
|
|
# SecuBox Streamlit Platform Controller
|
|
# Copyright (C) 2025 CyberMind.fr
|
|
#
|
|
# Manages Streamlit in LXC container
|
|
# Supports multi-instance with folder-based apps and Gitea integration
|
|
|
|
# Source OpenWrt UCI functions
|
|
. /lib/functions.sh
|
|
|
|
CONFIG="streamlit"
|
|
LXC_NAME="streamlit"
|
|
|
|
# Paths
|
|
LXC_PATH="/srv/lxc"
|
|
LXC_ROOTFS="$LXC_PATH/$LXC_NAME/rootfs"
|
|
LXC_CONFIG="$LXC_PATH/$LXC_NAME/config"
|
|
DEFAULT_APP="/usr/share/streamlit/hello.py"
|
|
|
|
# Logging
|
|
log_info() { echo "[INFO] $*"; logger -t streamlit "$*"; }
|
|
log_warn() { echo "[WARN] $*" >&2; logger -t streamlit -p warning "$*"; }
|
|
log_error() { echo "[ERROR] $*" >&2; logger -t streamlit -p err "$*"; }
|
|
log_debug() { [ "$DEBUG" = "1" ] && echo "[DEBUG] $*"; }
|
|
|
|
# Helpers
|
|
require_root() {
|
|
[ "$(id -u)" -eq 0 ] || {
|
|
log_error "This command requires root privileges"
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
has_lxc() { command -v lxc-start >/dev/null 2>&1; }
|
|
has_git() { command -v git >/dev/null 2>&1; }
|
|
|
|
ensure_dir() { [ -d "$1" ] || mkdir -p "$1"; }
|
|
|
|
uci_get() { uci -q get ${CONFIG}.$1; }
|
|
uci_set() { uci set ${CONFIG}.$1="$2" && uci commit ${CONFIG}; }
|
|
|
|
# Load configuration
|
|
load_config() {
|
|
http_port="$(uci_get main.http_port)" || http_port="8501"
|
|
http_host="$(uci_get main.http_host)" || http_host="0.0.0.0"
|
|
data_path="$(uci_get main.data_path)" || data_path="/srv/streamlit"
|
|
memory_limit="$(uci_get main.memory_limit)" || memory_limit="512M"
|
|
active_app="$(uci_get main.active_app)" || active_app="hello"
|
|
|
|
# Server settings
|
|
headless="$(uci_get server.headless)" || headless="true"
|
|
gather_stats="$(uci_get server.browser_gather_usage_stats)" || gather_stats="false"
|
|
theme_base="$(uci_get server.theme_base)" || theme_base="dark"
|
|
theme_primary="$(uci_get server.theme_primary_color)" || theme_primary="#0ff"
|
|
|
|
# Gitea config
|
|
gitea_enabled="$(uci_get gitea.enabled)" || gitea_enabled="0"
|
|
gitea_url="$(uci_get gitea.url)" || gitea_url="http://192.168.255.1:3000"
|
|
gitea_user="$(uci_get gitea.user)" || gitea_user="admin"
|
|
gitea_token="$(uci_get gitea.token)" || gitea_token=""
|
|
|
|
ensure_dir "$data_path"
|
|
ensure_dir "$data_path/apps"
|
|
ensure_dir "$data_path/logs"
|
|
APPS_PATH="$data_path/apps"
|
|
}
|
|
|
|
# Usage
|
|
usage() {
|
|
cat <<EOF
|
|
SecuBox Streamlit Platform Controller (Multi-Instance)
|
|
|
|
Usage: $(basename $0) <command> [options]
|
|
|
|
Container Commands:
|
|
install Download and setup LXC container
|
|
uninstall Remove container (preserves apps)
|
|
update Update Streamlit in container
|
|
status Show service status
|
|
logs [app] Show logs
|
|
|
|
App Management (folder-based):
|
|
app list List all apps
|
|
app create <name> Create new app folder
|
|
app delete <name> Delete app folder
|
|
app deploy <name> <path> Deploy app from path/archive
|
|
|
|
Instance Management:
|
|
instance list List configured instances
|
|
instance add <app> <port> Add instance for app on port
|
|
instance remove <name> Remove instance
|
|
instance start <name> Start single instance
|
|
instance stop <name> Stop single instance
|
|
|
|
Gitea Integration:
|
|
gitea setup Configure git credentials
|
|
gitea clone <name> <repo> Clone app from Gitea repo
|
|
gitea pull <name> Pull latest from Gitea
|
|
|
|
Service Commands:
|
|
service-run Start all instances (for init)
|
|
service-stop Stop all instances
|
|
|
|
Utility:
|
|
shell Open shell in container
|
|
exec <cmd> Execute command in container
|
|
|
|
App Folder Structure:
|
|
/srv/streamlit/apps/<appname>/
|
|
app.py Main Streamlit app (or <appname>.py)
|
|
requirements.txt Python dependencies
|
|
.streamlit/ Streamlit config (optional)
|
|
... Other files
|
|
|
|
Examples:
|
|
streamlitctl app create myapp
|
|
streamlitctl instance add myapp 8502
|
|
streamlitctl gitea clone myapp myuser/myapp-repo
|
|
|
|
EOF
|
|
}
|
|
|
|
# Check prerequisites
|
|
lxc_check_prereqs() {
|
|
if ! has_lxc; then
|
|
log_error "LXC not installed. Install with: opkg install lxc lxc-common"
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Create Python LXC rootfs from Alpine
|
|
lxc_create_rootfs() {
|
|
local rootfs="$LXC_ROOTFS"
|
|
local arch=$(uname -m)
|
|
|
|
log_info "Creating Alpine rootfs for Streamlit..."
|
|
|
|
ensure_dir "$rootfs"
|
|
|
|
local alpine_version="3.21"
|
|
case "$arch" in
|
|
x86_64) alpine_arch="x86_64" ;;
|
|
aarch64) alpine_arch="aarch64" ;;
|
|
armv7l) alpine_arch="armv7" ;;
|
|
*) log_error "Unsupported architecture: $arch"; return 1 ;;
|
|
esac
|
|
|
|
local alpine_url="https://dl-cdn.alpinelinux.org/alpine/v${alpine_version}/releases/${alpine_arch}/alpine-minirootfs-${alpine_version}.0-${alpine_arch}.tar.gz"
|
|
local tmpfile="/tmp/alpine-rootfs.tar.gz"
|
|
|
|
log_info "Downloading Alpine ${alpine_version} rootfs..."
|
|
wget -q -O "$tmpfile" "$alpine_url" || {
|
|
log_error "Failed to download Alpine rootfs"
|
|
return 1
|
|
}
|
|
|
|
log_info "Extracting rootfs..."
|
|
tar -xzf "$tmpfile" -C "$rootfs" || {
|
|
log_error "Failed to extract rootfs"
|
|
return 1
|
|
}
|
|
rm -f "$tmpfile"
|
|
|
|
cp /etc/resolv.conf "$rootfs/etc/resolv.conf" 2>/dev/null || \
|
|
echo "nameserver 1.1.1.1" > "$rootfs/etc/resolv.conf"
|
|
|
|
log_info "Rootfs created successfully"
|
|
return 0
|
|
}
|
|
|
|
# Create startup script for multi-instance folder-based apps
|
|
create_startup_script() {
|
|
load_config
|
|
|
|
local start_script="$LXC_ROOTFS/opt/start-streamlit.sh"
|
|
cat > "$start_script" << 'STARTUP'
|
|
#!/bin/sh
|
|
export PATH=/usr/local/bin:/usr/bin:/bin:$PATH
|
|
export HOME=/root
|
|
|
|
APPS_BASE="/srv/apps"
|
|
PIDS_DIR="/var/run/streamlit"
|
|
LOG_DIR="/var/log/streamlit"
|
|
INSTANCES_CONF="/srv/apps/instances.conf"
|
|
|
|
mkdir -p "$PIDS_DIR" "$LOG_DIR"
|
|
|
|
# Install Python and Streamlit on first run
|
|
if [ ! -f /opt/.installed ]; then
|
|
echo "Installing Python 3.12 and dependencies..."
|
|
apk update
|
|
apk add --no-cache python3 py3-pip git procps
|
|
|
|
echo "Installing Streamlit..."
|
|
pip3 install --break-system-packages streamlit 2>/dev/null || \
|
|
pip3 install streamlit 2>/dev/null
|
|
|
|
touch /opt/.installed
|
|
echo "Installation complete!"
|
|
fi
|
|
|
|
# Create default hello app if missing
|
|
if [ ! -d "$APPS_BASE/hello" ]; then
|
|
mkdir -p "$APPS_BASE/hello"
|
|
cat > "$APPS_BASE/hello/app.py" << 'HELLO'
|
|
import streamlit as st
|
|
st.set_page_config(page_title="SecuBox Streamlit", page_icon="⚡", layout="wide")
|
|
st.title("⚡ SECUBOX STREAMLIT ⚡")
|
|
st.markdown("### Neural Data App Platform")
|
|
col1, col2, col3 = st.columns(3)
|
|
with col1:
|
|
st.metric("Status", "ONLINE", delta="Active")
|
|
with col2:
|
|
st.metric("Apps", "Ready", delta="+1")
|
|
with col3:
|
|
st.metric("Platform", "SecuBox")
|
|
st.info("Deploy your Streamlit apps via LuCI dashboard or CLI")
|
|
HELLO
|
|
fi
|
|
|
|
# Function to find main app file in folder
|
|
find_app_file() {
|
|
local app_dir="$1"
|
|
local app_name=$(basename "$app_dir")
|
|
|
|
# Priority: app.py > main.py > <appname>.py > first .py file
|
|
for candidate in "$app_dir/app.py" "$app_dir/main.py" "$app_dir/${app_name}.py"; do
|
|
[ -f "$candidate" ] && { echo "$candidate"; return 0; }
|
|
done
|
|
|
|
# Fallback to first .py file
|
|
local first_py=$(ls -1 "$app_dir"/*.py 2>/dev/null | head -1)
|
|
[ -n "$first_py" ] && { echo "$first_py"; return 0; }
|
|
|
|
return 1
|
|
}
|
|
|
|
# Function to install requirements for an app
|
|
install_requirements() {
|
|
local app_dir="$1"
|
|
local app_name=$(basename "$app_dir")
|
|
local req_file="$app_dir/requirements.txt"
|
|
|
|
if [ -f "$req_file" ]; then
|
|
REQ_HASH=$(md5sum "$req_file" 2>/dev/null | cut -d' ' -f1)
|
|
REQ_MARKER="/opt/.req_${app_name}_${REQ_HASH}"
|
|
if [ ! -f "$REQ_MARKER" ]; then
|
|
echo "Installing requirements for $app_name..."
|
|
pip3 install --break-system-packages -r "$req_file" 2>/dev/null || \
|
|
pip3 install -r "$req_file" 2>/dev/null || true
|
|
touch "$REQ_MARKER"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Function to start a single instance
|
|
start_instance() {
|
|
local app_name="$1"
|
|
local port="$2"
|
|
local app_dir="$APPS_BASE/$app_name"
|
|
|
|
if [ ! -d "$app_dir" ]; then
|
|
echo "App folder not found: $app_dir"
|
|
return 1
|
|
fi
|
|
|
|
local app_file=$(find_app_file "$app_dir")
|
|
if [ -z "$app_file" ]; then
|
|
echo "No Python app file found in $app_dir"
|
|
return 1
|
|
fi
|
|
|
|
# Install requirements
|
|
install_requirements "$app_dir"
|
|
|
|
echo "Starting instance: $app_name on port $port (file: $(basename $app_file))"
|
|
|
|
# Change to app directory so relative imports work
|
|
cd "$app_dir"
|
|
|
|
nohup streamlit run "$(basename $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}" \
|
|
> "$LOG_DIR/${app_name}.log" 2>&1 &
|
|
|
|
echo $! > "$PIDS_DIR/${app_name}.pid"
|
|
echo "Instance '$app_name' started (PID: $!)"
|
|
}
|
|
|
|
# Function to stop an instance
|
|
stop_instance() {
|
|
local app_name="$1"
|
|
local pidfile="$PIDS_DIR/${app_name}.pid"
|
|
|
|
if [ -f "$pidfile" ]; then
|
|
local pid=$(cat "$pidfile")
|
|
if kill -0 "$pid" 2>/dev/null; then
|
|
kill "$pid"
|
|
echo "Stopped instance '$app_name' (PID: $pid)"
|
|
fi
|
|
rm -f "$pidfile"
|
|
fi
|
|
}
|
|
|
|
# Read instances from config file (format: appname:port)
|
|
if [ -f "$INSTANCES_CONF" ]; then
|
|
echo "Loading instances from config..."
|
|
while IFS=: read -r app_name port; do
|
|
[ -n "$app_name" ] && [ -n "$port" ] && start_instance "$app_name" "$port"
|
|
done < "$INSTANCES_CONF"
|
|
else
|
|
# Fallback: single instance mode
|
|
if [ -n "$STREAMLIT_APP" ] && [ -n "$STREAMLIT_PORT" ]; then
|
|
start_instance "$STREAMLIT_APP" "$STREAMLIT_PORT"
|
|
else
|
|
# Default: start hello on 8501
|
|
start_instance "hello" "8501"
|
|
fi
|
|
fi
|
|
|
|
# Keep container running
|
|
echo ""
|
|
echo "Streamlit multi-instance manager running."
|
|
echo "Instances:"
|
|
ls -1 "$PIDS_DIR"/*.pid 2>/dev/null | while read f; do
|
|
name=$(basename "$f" .pid)
|
|
pid=$(cat "$f")
|
|
echo " - $name (PID: $pid)"
|
|
done
|
|
|
|
# Monitor loop
|
|
while true; do
|
|
sleep 30
|
|
if ! pgrep -f "streamlit" >/dev/null; then
|
|
echo "No streamlit processes running, exiting..."
|
|
exit 1
|
|
fi
|
|
done
|
|
STARTUP
|
|
chmod +x "$start_script"
|
|
}
|
|
|
|
# Generate instances.conf from UCI
|
|
generate_instances_conf() {
|
|
load_config
|
|
|
|
local conf_file="$data_path/apps/instances.conf"
|
|
> "$conf_file"
|
|
|
|
# Iterate over instance sections
|
|
_add_instance_to_conf() {
|
|
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
|
|
echo "${inst_app}:${inst_port}" >> "$conf_file"
|
|
fi
|
|
}
|
|
|
|
config_load "$CONFIG"
|
|
config_foreach _add_instance_to_conf instance
|
|
|
|
# If no instances, add default
|
|
if [ ! -s "$conf_file" ]; then
|
|
echo "hello:8501" > "$conf_file"
|
|
fi
|
|
|
|
log_debug "Generated instances.conf: $(cat $conf_file | tr '\n' ' ')"
|
|
}
|
|
|
|
# Create LXC config
|
|
lxc_create_config() {
|
|
load_config
|
|
|
|
ensure_dir "$(dirname "$LXC_CONFIG")"
|
|
|
|
local mem_bytes
|
|
case "$memory_limit" in
|
|
*G|*g) mem_bytes=$((${memory_limit%[Gg]} * 1024 * 1024 * 1024)) ;;
|
|
*M|*m) mem_bytes=$((${memory_limit%[Mm]} * 1024 * 1024)) ;;
|
|
*K|*k) mem_bytes=$((${memory_limit%[Kk]} * 1024)) ;;
|
|
*) mem_bytes="$memory_limit" ;;
|
|
esac
|
|
|
|
cat > "$LXC_CONFIG" << EOF
|
|
# Streamlit Platform LXC Configuration (Multi-Instance)
|
|
lxc.uts.name = $LXC_NAME
|
|
lxc.rootfs.path = dir:$LXC_ROOTFS
|
|
lxc.arch = $(uname -m)
|
|
|
|
# Network: use host network
|
|
lxc.net.0.type = none
|
|
|
|
# Mount points
|
|
lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
|
|
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
|
|
|
|
# Environment
|
|
lxc.environment = STREAMLIT_THEME_BASE=$theme_base
|
|
lxc.environment = STREAMLIT_THEME_PRIMARY=$theme_primary
|
|
|
|
# Security
|
|
lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time sys_rawio
|
|
|
|
# Resource limits
|
|
lxc.cgroup.memory.limit_in_bytes = $mem_bytes
|
|
|
|
# Init command
|
|
lxc.init.cmd = /opt/start-streamlit.sh
|
|
EOF
|
|
|
|
log_info "LXC config created"
|
|
}
|
|
|
|
# Container control
|
|
lxc_running() {
|
|
lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"
|
|
}
|
|
|
|
lxc_exists() {
|
|
[ -f "$LXC_CONFIG" ] && [ -d "$LXC_ROOTFS" ]
|
|
}
|
|
|
|
lxc_stop() {
|
|
if lxc_running; then
|
|
log_info "Stopping Streamlit container..."
|
|
lxc-stop -n "$LXC_NAME" -k 2>/dev/null || true
|
|
sleep 2
|
|
fi
|
|
}
|
|
|
|
lxc_run() {
|
|
load_config
|
|
lxc_stop
|
|
|
|
if ! lxc_exists; then
|
|
log_error "Container not installed. Run: streamlitctl install"
|
|
return 1
|
|
fi
|
|
|
|
lxc_create_config
|
|
create_startup_script
|
|
generate_instances_conf
|
|
|
|
log_info "Starting Streamlit container..."
|
|
exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG"
|
|
}
|
|
|
|
lxc_exec() {
|
|
if ! lxc_running; then
|
|
log_error "Container not running. Start with: /etc/init.d/streamlit start"
|
|
return 1
|
|
fi
|
|
lxc-attach -n "$LXC_NAME" -- env PATH=/usr/local/bin:/usr/bin:/bin "$@"
|
|
}
|
|
|
|
# App management commands
|
|
cmd_app_list() {
|
|
load_config
|
|
|
|
echo "Streamlit Apps:"
|
|
echo "---------------"
|
|
|
|
if [ -d "$APPS_PATH" ]; then
|
|
for app_dir in "$APPS_PATH"/*/; do
|
|
[ -d "$app_dir" ] || continue
|
|
local name=$(basename "$app_dir")
|
|
|
|
# Find main file
|
|
local main_file=""
|
|
for candidate in "$app_dir/app.py" "$app_dir/main.py" "$app_dir/${name}.py"; do
|
|
[ -f "$candidate" ] && { main_file=$(basename "$candidate"); break; }
|
|
done
|
|
[ -z "$main_file" ] && main_file=$(ls -1 "$app_dir"/*.py 2>/dev/null | head -1 | xargs basename 2>/dev/null)
|
|
|
|
# Check for requirements
|
|
local has_req="no"
|
|
[ -f "$app_dir/requirements.txt" ] && has_req="yes"
|
|
|
|
# Check for git repo
|
|
local has_git="no"
|
|
[ -d "$app_dir/.git" ] && has_git="yes"
|
|
|
|
printf " %-20s main:%-15s req:%-3s git:%-3s\n" "$name" "${main_file:-none}" "$has_req" "$has_git"
|
|
done
|
|
fi
|
|
|
|
# Also list legacy single-file apps
|
|
for app_file in "$APPS_PATH"/*.py; do
|
|
[ -f "$app_file" ] || continue
|
|
local name=$(basename "$app_file" .py)
|
|
# Skip if there's a folder with same name
|
|
[ -d "$APPS_PATH/$name" ] && continue
|
|
printf " %-20s (legacy single-file)\n" "$name"
|
|
done
|
|
}
|
|
|
|
cmd_app_create() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
[ -z "$name" ] && { log_error "App name required"; return 1; }
|
|
|
|
# Validate name
|
|
echo "$name" | grep -qE '^[a-z][a-z0-9_-]*$' || {
|
|
log_error "Invalid app name. Use lowercase, numbers, underscore, hyphen."
|
|
return 1
|
|
}
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
|
|
if [ -d "$app_dir" ]; then
|
|
log_error "App '$name' already exists"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Creating app folder: $name"
|
|
|
|
ensure_dir "$app_dir"
|
|
ensure_dir "$app_dir/.streamlit"
|
|
|
|
# Create template app.py
|
|
cat > "$app_dir/app.py" << 'APPTEMPLATE'
|
|
import streamlit as st
|
|
|
|
st.set_page_config(
|
|
page_title="My Streamlit App",
|
|
page_icon="🚀",
|
|
layout="wide"
|
|
)
|
|
|
|
st.title("🚀 My Streamlit App")
|
|
st.write("Edit this file to build your app!")
|
|
|
|
# Example widgets
|
|
name = st.text_input("Enter your name:")
|
|
if name:
|
|
st.success(f"Hello, {name}!")
|
|
APPTEMPLATE
|
|
|
|
# Create empty requirements.txt
|
|
cat > "$app_dir/requirements.txt" << 'REQTEMPLATE'
|
|
# Add your Python dependencies here, one per line
|
|
# Example:
|
|
# pandas>=2.0.0
|
|
# numpy
|
|
# plotly
|
|
REQTEMPLATE
|
|
|
|
# Create .streamlit/config.toml
|
|
cat > "$app_dir/.streamlit/config.toml" << 'CONFIGTEMPLATE'
|
|
[theme]
|
|
base = "dark"
|
|
primaryColor = "#0ff"
|
|
|
|
[server]
|
|
headless = true
|
|
CONFIGTEMPLATE
|
|
|
|
# Register in UCI
|
|
uci set "${CONFIG}.${name}=app"
|
|
uci set "${CONFIG}.${name}.name=$name"
|
|
uci set "${CONFIG}.${name}.enabled=1"
|
|
uci commit "$CONFIG"
|
|
|
|
log_info "App '$name' created at $app_dir"
|
|
log_info "Next steps:"
|
|
log_info " 1. Edit $app_dir/app.py"
|
|
log_info " 2. Add dependencies to $app_dir/requirements.txt"
|
|
log_info " 3. Start with: streamlitctl instance add $name <port>"
|
|
}
|
|
|
|
cmd_app_delete() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
[ -z "$name" ] && { log_error "App name required"; return 1; }
|
|
|
|
if [ "$name" = "hello" ]; then
|
|
log_error "Cannot delete the default hello app"
|
|
return 1
|
|
fi
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
|
|
# Stop any running instance
|
|
cmd_instance_stop "$name" 2>/dev/null
|
|
|
|
# Remove directory
|
|
if [ -d "$app_dir" ]; then
|
|
rm -rf "$app_dir"
|
|
log_info "App folder removed: $app_dir"
|
|
fi
|
|
|
|
# Remove legacy file
|
|
if [ -f "$APPS_PATH/${name}.py" ]; then
|
|
rm -f "$APPS_PATH/${name}.py"
|
|
fi
|
|
|
|
# Remove from UCI
|
|
uci -q delete "${CONFIG}.${name}" 2>/dev/null
|
|
uci commit "$CONFIG"
|
|
|
|
log_info "App '$name' deleted"
|
|
}
|
|
|
|
cmd_app_deploy() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
local source="$2"
|
|
|
|
[ -z "$name" ] || [ -z "$source" ] && {
|
|
log_error "Usage: streamlitctl app deploy <name> <source_path>"
|
|
return 1
|
|
}
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
ensure_dir "$app_dir"
|
|
|
|
if [ -d "$source" ]; then
|
|
# Copy directory contents
|
|
log_info "Deploying from directory: $source"
|
|
cp -r "$source"/* "$app_dir/"
|
|
elif [ -f "$source" ]; then
|
|
case "$source" in
|
|
*.tar.gz|*.tgz)
|
|
log_info "Extracting archive: $source"
|
|
tar -xzf "$source" -C "$app_dir"
|
|
;;
|
|
*.zip)
|
|
log_info "Extracting zip: $source"
|
|
unzip -q "$source" -d "$app_dir"
|
|
;;
|
|
*.py)
|
|
log_info "Deploying single file: $source"
|
|
cp "$source" "$app_dir/app.py"
|
|
;;
|
|
*)
|
|
log_error "Unsupported file type"
|
|
return 1
|
|
;;
|
|
esac
|
|
else
|
|
log_error "Source not found: $source"
|
|
return 1
|
|
fi
|
|
|
|
# Register in UCI
|
|
uci set "${CONFIG}.${name}=app"
|
|
uci set "${CONFIG}.${name}.name=$name"
|
|
uci set "${CONFIG}.${name}.enabled=1"
|
|
uci commit "$CONFIG"
|
|
|
|
log_info "App '$name' deployed to $app_dir"
|
|
}
|
|
|
|
# Instance management
|
|
cmd_instance_list() {
|
|
load_config
|
|
|
|
echo "Streamlit Instances:"
|
|
echo "--------------------"
|
|
|
|
local found=0
|
|
_print_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"
|
|
|
|
[ -z "$app" ] || [ -z "$port" ] && return
|
|
|
|
found=1
|
|
local status="disabled"
|
|
[ "$enabled" = "1" ] && status="enabled"
|
|
|
|
local running=""
|
|
if lxc_running && [ -f "$LXC_ROOTFS/var/run/streamlit/${app}.pid" ]; then
|
|
running=" [RUNNING]"
|
|
fi
|
|
|
|
printf " %-15s app:%-15s port:%-5s [%s]%s\n" "$section" "$app" "$port" "$status" "$running"
|
|
}
|
|
|
|
config_load "$CONFIG"
|
|
config_foreach _print_instance instance
|
|
|
|
[ "$found" = "0" ] && echo " (no instances configured)"
|
|
}
|
|
|
|
cmd_instance_add() {
|
|
require_root
|
|
load_config
|
|
|
|
local app="$1"
|
|
local port="$2"
|
|
|
|
[ -z "$app" ] || [ -z "$port" ] && {
|
|
log_error "Usage: streamlitctl instance add <app_name> <port>"
|
|
return 1
|
|
}
|
|
|
|
# Validate port
|
|
echo "$port" | grep -qE '^[0-9]+$' || {
|
|
log_error "Port must be a number"
|
|
return 1
|
|
}
|
|
|
|
# Check app exists
|
|
if [ ! -d "$APPS_PATH/$app" ] && [ ! -f "$APPS_PATH/${app}.py" ]; then
|
|
log_error "App not found: $app"
|
|
log_info "Create with: streamlitctl app create $app"
|
|
return 1
|
|
fi
|
|
|
|
# Use app name as instance name
|
|
local instance_name="$app"
|
|
|
|
uci set "${CONFIG}.${instance_name}=instance"
|
|
uci set "${CONFIG}.${instance_name}.name=$instance_name"
|
|
uci set "${CONFIG}.${instance_name}.app=$app"
|
|
uci set "${CONFIG}.${instance_name}.port=$port"
|
|
uci set "${CONFIG}.${instance_name}.enabled=1"
|
|
uci commit "$CONFIG"
|
|
|
|
log_info "Instance added: $app on port $port"
|
|
log_info "Restart service to apply: /etc/init.d/streamlit restart"
|
|
}
|
|
|
|
cmd_instance_remove() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
[ -z "$name" ] && { log_error "Instance name required"; return 1; }
|
|
|
|
cmd_instance_stop "$name" 2>/dev/null
|
|
|
|
uci -q delete "${CONFIG}.${name}" 2>/dev/null || {
|
|
log_error "Instance not found: $name"
|
|
return 1
|
|
}
|
|
uci commit "$CONFIG"
|
|
|
|
# Regenerate instances.conf
|
|
generate_instances_conf
|
|
|
|
log_info "Instance '$name' removed"
|
|
}
|
|
|
|
cmd_instance_start() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
[ -z "$name" ] && { log_error "Instance name required"; return 1; }
|
|
|
|
# Get instance config
|
|
local app=$(uci_get "${name}.app")
|
|
local port=$(uci_get "${name}.port")
|
|
|
|
[ -z "$app" ] || [ -z "$port" ] && {
|
|
log_error "Instance '$name' not found or incomplete config"
|
|
return 1
|
|
}
|
|
|
|
if ! lxc_running; then
|
|
log_error "Container not running. Start with: /etc/init.d/streamlit start"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Starting instance '$name' (app: $app, port: $port)..."
|
|
|
|
lxc_exec sh -c "
|
|
cd /srv/apps/$app 2>/dev/null || exit 1
|
|
|
|
# Find main file
|
|
APP_FILE=''
|
|
for f in app.py main.py ${app}.py; do
|
|
[ -f \"\$f\" ] && { APP_FILE=\"\$f\"; break; }
|
|
done
|
|
[ -z \"\$APP_FILE\" ] && APP_FILE=\$(ls -1 *.py 2>/dev/null | head -1)
|
|
[ -z \"\$APP_FILE\" ] && { echo 'No Python file found'; exit 1; }
|
|
|
|
# Install requirements
|
|
[ -f requirements.txt ] && pip3 install --break-system-packages -r requirements.txt 2>/dev/null
|
|
|
|
# Kill existing
|
|
[ -f /var/run/streamlit/${app}.pid ] && kill \$(cat /var/run/streamlit/${app}.pid) 2>/dev/null
|
|
|
|
mkdir -p /var/run/streamlit /var/log/streamlit
|
|
|
|
nohup streamlit run \"\$APP_FILE\" \
|
|
--server.address=0.0.0.0 \
|
|
--server.port=$port \
|
|
--server.headless=true \
|
|
> /var/log/streamlit/${app}.log 2>&1 &
|
|
|
|
echo \$! > /var/run/streamlit/${app}.pid
|
|
echo \"Started \$APP_FILE on port $port (PID: \$!)\"
|
|
"
|
|
}
|
|
|
|
cmd_instance_stop() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
[ -z "$name" ] && { log_error "Instance name required"; return 1; }
|
|
|
|
local app=$(uci_get "${name}.app")
|
|
[ -z "$app" ] && app="$name"
|
|
|
|
if ! lxc_running; then
|
|
return 0
|
|
fi
|
|
|
|
log_info "Stopping instance '$name'..."
|
|
|
|
lxc_exec sh -c "
|
|
if [ -f /var/run/streamlit/${app}.pid ]; then
|
|
kill \$(cat /var/run/streamlit/${app}.pid) 2>/dev/null
|
|
rm -f /var/run/streamlit/${app}.pid
|
|
echo 'Stopped'
|
|
else
|
|
echo 'Not running'
|
|
fi
|
|
"
|
|
}
|
|
|
|
# Gitea integration
|
|
cmd_gitea_setup() {
|
|
require_root
|
|
load_config
|
|
|
|
if [ -z "$gitea_token" ]; then
|
|
log_error "Gitea token not configured"
|
|
log_info "Set with: uci set streamlit.gitea.token='your-token' && uci commit streamlit"
|
|
return 1
|
|
fi
|
|
|
|
if ! lxc_running; then
|
|
log_error "Container not running"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Configuring git credentials..."
|
|
|
|
local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||')
|
|
|
|
lxc_exec sh -c "
|
|
git config --global user.name '$gitea_user'
|
|
git config --global user.email '${gitea_user}@localhost'
|
|
git config --global credential.helper store
|
|
|
|
rm -rf ~/.git-credentials
|
|
cat > ~/.git-credentials << CRED
|
|
https://${gitea_user}:${gitea_token}@${gitea_host}
|
|
http://${gitea_user}:${gitea_token}@${gitea_host}
|
|
CRED
|
|
chmod 600 ~/.git-credentials
|
|
"
|
|
|
|
log_info "Git credentials configured"
|
|
}
|
|
|
|
cmd_gitea_clone() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
local repo="$2"
|
|
|
|
[ -z "$name" ] || [ -z "$repo" ] && {
|
|
log_error "Usage: streamlitctl gitea clone <app_name> <repo>"
|
|
log_info " repo can be: username/reponame or full URL"
|
|
return 1
|
|
}
|
|
|
|
if [ "$gitea_enabled" != "1" ]; then
|
|
log_error "Gitea integration not enabled"
|
|
log_info "Enable with: uci set streamlit.gitea.enabled=1 && uci commit streamlit"
|
|
return 1
|
|
fi
|
|
|
|
if [ -z "$gitea_token" ]; then
|
|
log_error "Gitea token not configured"
|
|
return 1
|
|
fi
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
|
|
# Build clone URL
|
|
local clone_url
|
|
if echo "$repo" | grep -q '^http'; then
|
|
clone_url="$repo"
|
|
else
|
|
local gitea_host=$(echo "$gitea_url" | sed 's|^https\?://||' | sed 's|/.*||')
|
|
clone_url="http://${gitea_user}:${gitea_token}@${gitea_host}/${repo}.git"
|
|
fi
|
|
|
|
if [ -d "$app_dir/.git" ]; then
|
|
log_info "Repository already cloned, pulling latest..."
|
|
cd "$app_dir" && git pull
|
|
else
|
|
log_info "Cloning $repo to $name..."
|
|
ensure_dir "$(dirname "$app_dir")"
|
|
rm -rf "$app_dir"
|
|
|
|
git clone "$clone_url" "$app_dir" || {
|
|
log_error "Failed to clone repository"
|
|
return 1
|
|
}
|
|
fi
|
|
|
|
# Register in UCI
|
|
uci set "${CONFIG}.${name}=app"
|
|
uci set "${CONFIG}.${name}.name=$name"
|
|
uci set "${CONFIG}.${name}.repo=$repo"
|
|
uci set "${CONFIG}.${name}.enabled=1"
|
|
uci commit "$CONFIG"
|
|
|
|
log_info "App '$name' cloned from Gitea"
|
|
log_info "Add instance with: streamlitctl instance add $name <port>"
|
|
}
|
|
|
|
cmd_gitea_pull() {
|
|
require_root
|
|
load_config
|
|
|
|
local name="$1"
|
|
[ -z "$name" ] && { log_error "App name required"; return 1; }
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
|
|
if [ ! -d "$app_dir/.git" ]; then
|
|
log_error "App '$name' is not a git repository"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Pulling latest for '$name'..."
|
|
cd "$app_dir" && git pull
|
|
|
|
log_info "Update complete"
|
|
}
|
|
|
|
# Main commands
|
|
cmd_install() {
|
|
require_root
|
|
load_config
|
|
|
|
log_info "Installing Streamlit Platform..."
|
|
|
|
lxc_check_prereqs || exit 1
|
|
|
|
if ! lxc_exists; then
|
|
lxc_create_rootfs || exit 1
|
|
fi
|
|
|
|
lxc_create_config || exit 1
|
|
create_startup_script
|
|
|
|
# Setup default app
|
|
ensure_dir "$APPS_PATH/hello"
|
|
|
|
uci_set main.enabled '1'
|
|
/etc/init.d/streamlit enable 2>/dev/null || true
|
|
|
|
log_info "Installation complete!"
|
|
log_info ""
|
|
log_info "Next steps:"
|
|
log_info " 1. Create app: streamlitctl app create myapp"
|
|
log_info " 2. Add instance: streamlitctl instance add myapp 8501"
|
|
log_info " 3. Start: /etc/init.d/streamlit start"
|
|
}
|
|
|
|
cmd_uninstall() {
|
|
require_root
|
|
|
|
log_info "Uninstalling Streamlit Platform..."
|
|
|
|
/etc/init.d/streamlit stop 2>/dev/null || true
|
|
/etc/init.d/streamlit disable 2>/dev/null || true
|
|
|
|
lxc_stop
|
|
|
|
if [ -d "$LXC_PATH/$LXC_NAME" ]; then
|
|
rm -rf "$LXC_PATH/$LXC_NAME"
|
|
log_info "Container removed"
|
|
fi
|
|
|
|
uci_set main.enabled '0'
|
|
|
|
log_info "Streamlit Platform uninstalled"
|
|
log_info "Apps preserved in: $(uci_get main.data_path)/apps"
|
|
}
|
|
|
|
cmd_update() {
|
|
require_root
|
|
load_config
|
|
|
|
if ! lxc_exists; then
|
|
log_error "Container not installed"
|
|
return 1
|
|
fi
|
|
|
|
log_info "Updating Streamlit..."
|
|
|
|
rm -f "$LXC_ROOTFS/opt/.installed"
|
|
|
|
if [ "$(uci_get main.enabled)" = "1" ]; then
|
|
/etc/init.d/streamlit restart
|
|
fi
|
|
|
|
log_info "Update will apply on next start"
|
|
}
|
|
|
|
cmd_status() {
|
|
load_config
|
|
|
|
local enabled="$(uci_get main.enabled)"
|
|
local running="false"
|
|
lxc_running && running="true"
|
|
|
|
cat << EOF
|
|
Streamlit Platform Status
|
|
==========================
|
|
Enabled: $([ "$enabled" = "1" ] && echo "yes" || echo "no")
|
|
Running: $([ "$running" = "true" ] && echo "yes" || echo "no")
|
|
Data Path: $data_path
|
|
Memory: $memory_limit
|
|
Container: $LXC_NAME
|
|
|
|
EOF
|
|
|
|
echo "Apps:"
|
|
cmd_app_list | tail -n +3
|
|
|
|
echo ""
|
|
echo "Instances:"
|
|
cmd_instance_list | tail -n +3
|
|
}
|
|
|
|
cmd_logs() {
|
|
load_config
|
|
|
|
local app="${1:-}"
|
|
|
|
if [ -n "$app" ]; then
|
|
local log_file="$data_path/logs/${app}.log"
|
|
if [ -f "$log_file" ]; then
|
|
tail -100 "$log_file"
|
|
else
|
|
# Try inside container
|
|
lxc_exec cat /var/log/streamlit/${app}.log 2>/dev/null || echo "No logs for '$app'"
|
|
fi
|
|
else
|
|
# Show all logs
|
|
for log_file in "$data_path/logs"/*.log; do
|
|
[ -f "$log_file" ] || continue
|
|
echo "=== $(basename "$log_file") ==="
|
|
tail -50 "$log_file"
|
|
echo ""
|
|
done
|
|
fi
|
|
}
|
|
|
|
cmd_shell() {
|
|
require_root
|
|
|
|
if ! lxc_running; then
|
|
log_error "Container not running"
|
|
exit 1
|
|
fi
|
|
|
|
lxc-attach -n "$LXC_NAME" -- /bin/sh
|
|
}
|
|
|
|
cmd_service_run() {
|
|
require_root
|
|
load_config
|
|
|
|
lxc_check_prereqs || exit 1
|
|
lxc_run
|
|
}
|
|
|
|
cmd_service_stop() {
|
|
require_root
|
|
lxc_stop
|
|
}
|
|
|
|
# Main
|
|
case "${1:-}" in
|
|
install) shift; cmd_install "$@" ;;
|
|
uninstall) shift; cmd_uninstall "$@" ;;
|
|
update) shift; cmd_update "$@" ;;
|
|
status) shift; cmd_status "$@" ;;
|
|
logs) shift; cmd_logs "$@" ;;
|
|
shell) shift; cmd_shell "$@" ;;
|
|
|
|
app)
|
|
shift
|
|
case "${1:-}" in
|
|
list) shift; cmd_app_list "$@" ;;
|
|
create) shift; cmd_app_create "$@" ;;
|
|
delete) shift; cmd_app_delete "$@" ;;
|
|
deploy) shift; cmd_app_deploy "$@" ;;
|
|
*) echo "Usage: streamlitctl app {list|create|delete|deploy}"; exit 1 ;;
|
|
esac
|
|
;;
|
|
|
|
instance)
|
|
shift
|
|
case "${1:-}" in
|
|
list) shift; cmd_instance_list "$@" ;;
|
|
add) shift; cmd_instance_add "$@" ;;
|
|
remove) shift; cmd_instance_remove "$@" ;;
|
|
start) shift; cmd_instance_start "$@" ;;
|
|
stop) shift; cmd_instance_stop "$@" ;;
|
|
*) echo "Usage: streamlitctl instance {list|add|remove|start|stop}"; exit 1 ;;
|
|
esac
|
|
;;
|
|
|
|
gitea)
|
|
shift
|
|
case "${1:-}" in
|
|
setup) shift; cmd_gitea_setup "$@" ;;
|
|
clone) shift; cmd_gitea_clone "$@" ;;
|
|
pull) shift; cmd_gitea_pull "$@" ;;
|
|
*) echo "Usage: streamlitctl gitea {setup|clone|pull}"; exit 1 ;;
|
|
esac
|
|
;;
|
|
|
|
service-run) shift; cmd_service_run "$@" ;;
|
|
service-stop) shift; cmd_service_stop "$@" ;;
|
|
|
|
*) usage ;;
|
|
esac
|