mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
* docs(plan): #331 streamlit idle-timeout + wake-on-demand (ref #331) * feat(streamlit): add idle-tracking helpers to streamlitctl (ref #331) Add the foundation for per-app idle timeout / wake-on-demand: - IDLE_STATE_DIR constant at /var/lib/secubox/streamlit/idle - _idle_config: read [idle] settings from /etc/secubox/streamlit.toml with safe defaults - _app_running: check whether an app is listening on its port INSIDE the LXC (existing inline ss check is host-side and unreliable) - _app_active_conns: count ESTABLISHED connections to an app's port - _app_last_active / _app_touch_active: per-app last-activity epoch tracked via state-file mtime Wire cmd_app_start to touch the state file on every successful start so a freshly-started app does not get immediately reaped by the upcoming idle-check. All helpers degrade gracefully when the LXC is down (lxc_running guard) and the helpers themselves do not change any existing behaviour — they are purely additive. * feat(streamlit): streamlitctl app idle-check — stop idle apps past timeout (ref #331) Add cmd_app_idle_check that iterates apps under $APPS_PATH and stops those that have had zero ESTABLISHED connections for longer than the configured timeout_minutes (default 30, from [idle] in streamlit.toml). Apps with active connections get their state file touched; apps with no state file yet are touched too (grace period). Master switch via [idle] enabled in /etc/secubox/streamlit.toml. Wire idle-check + wake into the `app` subcommand dispatcher; wake's function (cmd_app_wake) lands in the next commit (dead branch until then). Usage line updated to include both new subcommands. * feat(streamlit): streamlitctl app wake — lazy start + poll for port (ref #331) Adds cmd_app_wake <name> [wait_seconds] (default 30s): - returns 0 if already running OR if the app comes up within wait_seconds - returns 1 on start failure or poll timeout - returns 2 on misuse (missing name, unknown app) Uses _app_running for readiness probing and _app_touch_active to refresh the idle marker on a successful wake. Polls every 1s for portability. The case-block dispatcher (idle-check / wake) was already wired in Task 2. * feat(streamlit): systemd idle-sweep timer (every 5 min) (ref #331) Ship secubox-streamlit-idle.service (oneshot, runs `streamlitctl app idle-check`) and secubox-streamlit-idle.timer (OnBootSec=5min, OnUnitActiveSec=5min) so idle Streamlit apps are auto-suspended without a separate cron. Wire the units through debian/secubox-streamlit.install (the package uses a hand-rolled override_dh_auto_install, so dh_installsystemd's auto-detection by package.suffix name doesn't pick them up — explicit .install is needed). Enable/disable the timer in postinst/prerm alongside the main service. * feat(streamlit): POST /apps/{name}/wake — lazy start API endpoint (ref #331) * feat(streamlit): default [idle] config block (timeout 30 min) (ref #331) * chore(streamlit): bump to v1.2.2 with idle-timeout + wake summary (closes #331) --------- Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
935 lines
30 KiB
Bash
Executable File
935 lines
30 KiB
Bash
Executable File
#!/bin/bash
|
|
# streamlitctl - Streamlit Platform Manager for SecuBox Debian
|
|
# Control script for LXC-based Streamlit application hosting
|
|
# Three-fold architecture: Components, Status, Access
|
|
set -e
|
|
|
|
VERSION="1.1.0"
|
|
|
|
LXC_NAME="streamlit"
|
|
LXC_PATH="/var/lib/lxc/$LXC_NAME"
|
|
DATA_PATH="/srv/streamlit"
|
|
APPS_PATH="$DATA_PATH/apps"
|
|
CONF_PATH="/etc/secubox/streamlit.toml"
|
|
LOG_FILE="/var/log/secubox/streamlit.log"
|
|
|
|
# Logging
|
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" 2>/dev/null || echo "$*"; }
|
|
error() { log "ERROR: $*" >&2; }
|
|
|
|
# Load config
|
|
load_config() {
|
|
if [ -f "$CONF_PATH" ]; then
|
|
DATA_PATH=$(grep -E '^data_path\s*=' "$CONF_PATH" | cut -d'"' -f2 || echo "$DATA_PATH")
|
|
APPS_PATH="$DATA_PATH/apps"
|
|
fi
|
|
}
|
|
|
|
# LXC helpers
|
|
lxc_running() { lxc-info -n "$LXC_NAME" -s 2>/dev/null | grep -q "RUNNING"; }
|
|
lxc_exists() { [ -d "$LXC_PATH/rootfs" ]; }
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# IDLE-TRACKING HELPERS (#331)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
IDLE_STATE_DIR="/var/lib/secubox/streamlit/idle"
|
|
|
|
# Read [idle] settings from /etc/secubox/streamlit.toml with safe defaults.
|
|
# Note: `grep | head | cut | tr` always exits 0 (tr's status) even when grep
|
|
# matched nothing, so a trailing `|| echo $default` never fires and missing
|
|
# keys silently returned empty. Capture explicitly and fall back via
|
|
# `${v:-$default}` for the missing-or-blank case.
|
|
_idle_config() {
|
|
local key="$1" default="$2" v
|
|
v=$(grep -E "^\s*${key}\s*=" "$CONF_PATH" 2>/dev/null | head -1 | cut -d'=' -f2- | tr -d ' "')
|
|
echo "${v:-$default}"
|
|
}
|
|
|
|
# Returns 0 if the app is listening on its port INSIDE the LXC, else 1.
|
|
_app_running() {
|
|
local name="$1"
|
|
local port; port=$(grep -E "^port\s*=" "$APPS_PATH/$name/.streamlit.toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
|
|
[ -z "$port" ] && return 1
|
|
lxc_running || return 1
|
|
lxc-attach -n "$LXC_NAME" -- ss -tln "sport = :$port" 2>/dev/null \
|
|
| awk -v p="$port" 'NR>1 && $4 ~ ":"p"$" {found=1} END {exit found?0:1}'
|
|
}
|
|
|
|
# Returns the count of ESTABLISHED connections to the app's port (inside LXC).
|
|
_app_active_conns() {
|
|
local name="$1"
|
|
local port; port=$(grep -E "^port\s*=" "$APPS_PATH/$name/.streamlit.toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' ')
|
|
[ -z "$port" ] && { echo 0; return; }
|
|
lxc_running || { echo 0; return; }
|
|
lxc-attach -n "$LXC_NAME" -- ss -tn state established "sport = :$port" 2>/dev/null \
|
|
| awk 'NR>1' | wc -l
|
|
}
|
|
|
|
# Last-activity epoch for the app (file mtime). Touched by cmd_app_idle_check
|
|
# when ESTABLISHED>0; created at start time by cmd_app_start.
|
|
_app_last_active() {
|
|
local name="$1"
|
|
local f="$IDLE_STATE_DIR/$name.state"
|
|
[ -f "$f" ] || { echo 0; return; }
|
|
stat -c %Y "$f"
|
|
}
|
|
|
|
_app_touch_active() {
|
|
local name="$1"
|
|
mkdir -p "$IDLE_STATE_DIR"
|
|
touch "$IDLE_STATE_DIR/$name.state"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# CONTAINER MANAGEMENT
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_install() {
|
|
if lxc_exists; then
|
|
log "Container already exists. Use 'update' to upgrade."
|
|
return 1
|
|
fi
|
|
|
|
log "Installing Streamlit LXC container..."
|
|
mkdir -p "$DATA_PATH" "$APPS_PATH" "$(dirname $LOG_FILE)"
|
|
|
|
# Create Alpine-based LXC container
|
|
lxc-create -n "$LXC_NAME" -t download -- \
|
|
-d alpine -r 3.19 -a arm64 || {
|
|
error "Failed to create LXC container"
|
|
return 1
|
|
}
|
|
|
|
# Configure container
|
|
cat >> "$LXC_PATH/config" << EOF
|
|
|
|
# Streamlit bind mounts
|
|
lxc.mount.entry = $APPS_PATH srv/apps none bind,create=dir 0 0
|
|
lxc.mount.entry = $DATA_PATH/logs var/log/streamlit none bind,create=dir 0 0
|
|
|
|
# Network
|
|
lxc.net.0.type = veth
|
|
lxc.net.0.link = br-lan
|
|
lxc.net.0.flags = up
|
|
lxc.net.0.name = eth0
|
|
|
|
# Resource limits
|
|
lxc.cgroup2.memory.max = 1G
|
|
lxc.cgroup2.cpu.max = 100000 100000
|
|
EOF
|
|
|
|
# Start container and install packages
|
|
lxc-start -n "$LXC_NAME"
|
|
sleep 5
|
|
|
|
log "Installing packages inside container..."
|
|
lxc-attach -n "$LXC_NAME" -- sh -c '
|
|
apk update
|
|
apk add python3 py3-pip py3-virtualenv git bash curl
|
|
pip3 install --break-system-packages streamlit pandas numpy plotly altair pillow
|
|
mkdir -p /srv/apps /var/log/streamlit
|
|
'
|
|
|
|
# Create hello world app
|
|
cat > "$APPS_PATH/hello/app.py" << 'PYEOF'
|
|
import streamlit as st
|
|
|
|
st.set_page_config(page_title="SecuBox Streamlit", page_icon=":rocket:")
|
|
st.title("Welcome to SecuBox Streamlit Platform")
|
|
st.markdown("""
|
|
This is your Streamlit application hosting environment.
|
|
|
|
### Getting Started
|
|
1. Upload a ZIP file containing your app
|
|
2. The app should have an `app.py` or `main.py` entrypoint
|
|
3. Include `requirements.txt` for dependencies
|
|
|
|
### Features
|
|
- Isolated LXC container execution
|
|
- Automatic dependency installation
|
|
- Domain/vhost publishing
|
|
- Gitea integration for version control
|
|
""")
|
|
PYEOF
|
|
|
|
log "Container installation complete"
|
|
return 0
|
|
}
|
|
|
|
cmd_uninstall() {
|
|
log "Uninstalling Streamlit container..."
|
|
|
|
if lxc_running; then
|
|
lxc-stop -n "$LXC_NAME" || true
|
|
sleep 2
|
|
fi
|
|
|
|
if lxc_exists; then
|
|
lxc-destroy -n "$LXC_NAME" || true
|
|
fi
|
|
|
|
log "Container removed (data preserved in $DATA_PATH)"
|
|
}
|
|
|
|
cmd_update() {
|
|
if ! lxc_exists; then
|
|
error "Container not installed. Run install first."
|
|
return 1
|
|
fi
|
|
|
|
local was_running=false
|
|
if lxc_running; then
|
|
was_running=true
|
|
log "Stopping container for update..."
|
|
lxc-stop -n "$LXC_NAME"
|
|
sleep 2
|
|
fi
|
|
|
|
log "Updating packages inside container..."
|
|
lxc-start -n "$LXC_NAME"
|
|
sleep 3
|
|
|
|
lxc-attach -n "$LXC_NAME" -- sh -c '
|
|
apk update && apk upgrade
|
|
pip3 install --break-system-packages --upgrade streamlit pandas numpy
|
|
'
|
|
|
|
if ! $was_running; then
|
|
lxc-stop -n "$LXC_NAME"
|
|
fi
|
|
|
|
log "Update complete"
|
|
}
|
|
|
|
cmd_start() {
|
|
if ! lxc_exists; then
|
|
error "Container not installed"
|
|
return 1
|
|
fi
|
|
|
|
if lxc_running; then
|
|
log "Container already running"
|
|
return 0
|
|
fi
|
|
|
|
log "Starting Streamlit container..."
|
|
lxc-start -n "$LXC_NAME"
|
|
sleep 2
|
|
|
|
# Start any autostart instances
|
|
cmd_autostart
|
|
|
|
log "Container started"
|
|
}
|
|
|
|
cmd_stop() {
|
|
if ! lxc_running; then
|
|
log "Container not running"
|
|
return 0
|
|
fi
|
|
|
|
log "Stopping Streamlit container..."
|
|
lxc-stop -n "$LXC_NAME"
|
|
log "Container stopped"
|
|
}
|
|
|
|
cmd_status() {
|
|
local running="false"
|
|
local installed="false"
|
|
|
|
lxc_exists && installed="true"
|
|
lxc_running && running="true"
|
|
|
|
local app_count=0
|
|
[ -d "$APPS_PATH" ] && app_count=$(find "$APPS_PATH" -maxdepth 1 -type d | wc -l)
|
|
app_count=$((app_count - 1))
|
|
[ $app_count -lt 0 ] && app_count=0
|
|
|
|
cat << EOF
|
|
{
|
|
"installed": $installed,
|
|
"running": $running,
|
|
"container": "$LXC_NAME",
|
|
"data_path": "$DATA_PATH",
|
|
"app_count": $app_count
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# THREE-FOLD ARCHITECTURE: Components (What)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_components() {
|
|
local installed=false
|
|
local running=false
|
|
lxc_exists && installed=true
|
|
lxc_running && running=true
|
|
|
|
local app_count=0
|
|
[ -d "$APPS_PATH" ] && app_count=$(find "$APPS_PATH" -maxdepth 1 -type d 2>/dev/null | wc -l)
|
|
app_count=$((app_count - 1))
|
|
[ $app_count -lt 0 ] && app_count=0
|
|
|
|
cat <<EOF
|
|
{
|
|
"components": [
|
|
{
|
|
"name": "Streamlit Container",
|
|
"type": "lxc",
|
|
"container": "$LXC_NAME",
|
|
"description": "Python Streamlit application runtime",
|
|
"installed": $installed,
|
|
"running": $running
|
|
},
|
|
{
|
|
"name": "Applications",
|
|
"type": "apps",
|
|
"path": "$APPS_PATH",
|
|
"description": "Deployed Streamlit applications",
|
|
"count": $app_count
|
|
},
|
|
{
|
|
"name": "Python Runtime",
|
|
"type": "runtime",
|
|
"description": "Python 3 with Streamlit packages",
|
|
"installed": $installed
|
|
}
|
|
]
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# THREE-FOLD ARCHITECTURE: Access (How to connect)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_access() {
|
|
local apps_json="["
|
|
local first=true
|
|
|
|
for app_dir in "$APPS_PATH"/*/; do
|
|
[ -d "$app_dir" ] || continue
|
|
local name=$(basename "$app_dir")
|
|
[ "$name" = "*" ] && continue
|
|
|
|
local port=$(grep -E "^port\s*=" "$app_dir/.streamlit.toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "")
|
|
local running=false
|
|
[ -n "$port" ] && ss -tln "sport = :$port" 2>/dev/null | grep -q "$port" && running=true
|
|
|
|
$first || apps_json+=","
|
|
first=false
|
|
apps_json+="{\"name\":\"$name\",\"port\":${port:-0},\"running\":$running,\"url\":\"http://localhost:${port:-8501}\"}"
|
|
done
|
|
apps_json+="]"
|
|
|
|
cat <<EOF
|
|
{
|
|
"data_path": "$DATA_PATH",
|
|
"apps_path": "$APPS_PATH",
|
|
"apps": $apps_json,
|
|
"admin_panel": "/streamlit/",
|
|
"default_port_range": "8501-8600"
|
|
}
|
|
EOF
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# APP MANAGEMENT
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_app_list() {
|
|
echo '{"apps":['
|
|
local first=true
|
|
|
|
for app_dir in "$APPS_PATH"/*/; do
|
|
[ -d "$app_dir" ] || continue
|
|
local name=$(basename "$app_dir")
|
|
[ "$name" = "*" ] && continue
|
|
|
|
# Find main py file
|
|
local main_py=""
|
|
for candidate in app.py main.py streamlit_app.py; do
|
|
[ -f "$app_dir/$candidate" ] && main_py="$candidate" && break
|
|
done
|
|
|
|
# Check if running (by checking port)
|
|
local running="false"
|
|
local port=$(grep -E "^port\s*=" "$app_dir/.streamlit.toml" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "")
|
|
[ -n "$port" ] && ss -tln "sport = :$port" 2>/dev/null | grep -q "$port" && running="true"
|
|
|
|
$first || echo ","
|
|
first=false
|
|
|
|
cat << EOF
|
|
{"name": "$name", "main": "$main_py", "running": $running, "port": ${port:-0}}
|
|
EOF
|
|
done
|
|
|
|
echo ']}'
|
|
}
|
|
|
|
# app info <name> — print metadata + runtime state from manifest + pid file (#182)
|
|
cmd_app_info() {
|
|
local name="$1"
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app info <name>"; return 1; }
|
|
local d="$APPS_PATH/$name"
|
|
[ -d "$d" ] || { error "app not found: $name"; return 1; }
|
|
# Entry point detection (same logic as cmd_app_start)
|
|
local entry=""
|
|
for c in app.py main.py streamlit_app.py; do
|
|
[ -f "$d/$c" ] && entry="$c" && break
|
|
done
|
|
local port=""
|
|
[ -f "$d/.streamlit.toml" ] && port=$(grep -E "^port" "$d/.streamlit.toml" 2>/dev/null | cut -d= -f2 | tr -d ' ')
|
|
local pidf="/var/run/streamlit-${name}.pid"
|
|
local pid="" alive="no"
|
|
if lxc_running; then
|
|
pid=$(lxc-attach -n "$LXC_NAME" -- cat "$pidf" 2>/dev/null || true)
|
|
if [ -n "$pid" ]; then
|
|
lxc-attach -n "$LXC_NAME" -- kill -0 "$pid" >/dev/null 2>&1 && alive="yes"
|
|
fi
|
|
fi
|
|
cat <<EOF
|
|
name: $name
|
|
path: $d
|
|
entrypoint: ${entry:-(none)}
|
|
port: ${port:-(unset)}
|
|
pid_file: $pidf
|
|
pid: ${pid:-(none)}
|
|
running: $alive
|
|
EOF
|
|
}
|
|
|
|
# app restart <name> — stop + start (#182)
|
|
cmd_app_restart() {
|
|
local name="$1"
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app restart <name>"; return 1; }
|
|
cmd_app_stop "$name" || true
|
|
sleep 1
|
|
# Recover the previous port from .streamlit.toml so restart preserves it
|
|
local port=""
|
|
[ -f "$APPS_PATH/$name/.streamlit.toml" ] && \
|
|
port=$(grep -E "^port" "$APPS_PATH/$name/.streamlit.toml" 2>/dev/null | cut -d= -f2 | tr -d ' ')
|
|
cmd_app_start "$name" "${port:-8501}"
|
|
}
|
|
|
|
cmd_app_start() {
|
|
local name="$1"
|
|
local port="${2:-8501}"
|
|
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app start <name> [port]"; return 1; }
|
|
[ -d "$APPS_PATH/$name" ] || { error "App not found: $name"; return 1; }
|
|
|
|
if ! lxc_running; then
|
|
error "Container not running. Start it first."
|
|
return 1
|
|
fi
|
|
|
|
# Find entrypoint
|
|
local entrypoint=""
|
|
for candidate in app.py main.py streamlit_app.py; do
|
|
[ -f "$APPS_PATH/$name/$candidate" ] && entrypoint="$candidate" && break
|
|
done
|
|
|
|
[ -z "$entrypoint" ] && { error "No entrypoint found in $name"; return 1; }
|
|
|
|
# Save port config
|
|
mkdir -p "$APPS_PATH/$name"
|
|
echo "port = $port" > "$APPS_PATH/$name/.streamlit.toml"
|
|
|
|
log "Starting app $name on port $port..."
|
|
|
|
lxc-attach -n "$LXC_NAME" -- sh -c "
|
|
cd /srv/apps/$name
|
|
nohup streamlit run $entrypoint \
|
|
--server.port=$port \
|
|
--server.headless=true \
|
|
--server.address=0.0.0.0 \
|
|
> /var/log/streamlit/$name.log 2>&1 &
|
|
echo \$! > /var/run/streamlit-$name.pid
|
|
"
|
|
|
|
log "App $name started"
|
|
_app_touch_active "$name"
|
|
}
|
|
|
|
cmd_app_stop() {
|
|
local name="$1"
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app stop <name>"; return 1; }
|
|
|
|
if ! lxc_running; then
|
|
return 0
|
|
fi
|
|
|
|
log "Stopping app $name..."
|
|
|
|
lxc-attach -n "$LXC_NAME" -- sh -c "
|
|
if [ -f /var/run/streamlit-$name.pid ]; then
|
|
kill \$(cat /var/run/streamlit-$name.pid) 2>/dev/null || true
|
|
rm -f /var/run/streamlit-$name.pid
|
|
fi
|
|
pkill -f \"streamlit.*$name\" 2>/dev/null || true
|
|
"
|
|
|
|
log "App $name stopped"
|
|
}
|
|
|
|
cmd_app_idle_check() {
|
|
local enabled; enabled=$(_idle_config "enabled" "true")
|
|
[ "$enabled" = "true" ] || { log "idle-check disabled in $CONF_PATH"; return 0; }
|
|
|
|
local timeout_min; timeout_min=$(_idle_config "timeout_minutes" "30")
|
|
local timeout_sec=$(( timeout_min * 60 ))
|
|
local now; now=$(date +%s)
|
|
|
|
local stopped=0 active=0 idle=0
|
|
for app_dir in "$APPS_PATH"/*/; do
|
|
[ -d "$app_dir" ] || continue
|
|
local name; name=$(basename "$app_dir")
|
|
[ "$name" = "*" ] && continue
|
|
|
|
_app_running "$name" || continue
|
|
|
|
local conns; conns=$(_app_active_conns "$name")
|
|
if [ "$conns" -gt 0 ]; then
|
|
_app_touch_active "$name"
|
|
active=$(( active + 1 ))
|
|
continue
|
|
fi
|
|
|
|
local last; last=$(_app_last_active "$name")
|
|
[ "$last" -eq 0 ] && { _app_touch_active "$name"; continue; }
|
|
local idle_sec=$(( now - last ))
|
|
|
|
if [ "$idle_sec" -ge "$timeout_sec" ]; then
|
|
log "idle-stop: $name (idle ${idle_sec}s >= ${timeout_sec}s)"
|
|
cmd_app_stop "$name" >/dev/null 2>&1 || true
|
|
stopped=$(( stopped + 1 ))
|
|
else
|
|
idle=$(( idle + 1 ))
|
|
fi
|
|
done
|
|
|
|
log "idle-check: active=$active idle=$idle stopped=$stopped"
|
|
}
|
|
|
|
cmd_app_wake() {
|
|
local name="$1"
|
|
local wait_sec="${2:-30}"
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app wake <name> [wait_seconds]"; return 2; }
|
|
[ -d "$APPS_PATH/$name" ] || { error "App not found: $name"; return 2; }
|
|
|
|
if _app_running "$name"; then
|
|
_app_touch_active "$name"
|
|
log "wake: $name already running"
|
|
return 0
|
|
fi
|
|
|
|
log "wake: starting $name (will wait up to ${wait_sec}s)"
|
|
cmd_app_start "$name" >/dev/null 2>&1 || { error "wake: failed to start $name"; return 1; }
|
|
|
|
local elapsed=0
|
|
while [ "$elapsed" -lt "$wait_sec" ]; do
|
|
if _app_running "$name"; then
|
|
_app_touch_active "$name"
|
|
log "wake: $name listening after ${elapsed}s"
|
|
return 0
|
|
fi
|
|
sleep 1
|
|
elapsed=$(( elapsed + 1 ))
|
|
done
|
|
|
|
error "wake: $name did not come up within ${wait_sec}s"
|
|
return 1
|
|
}
|
|
|
|
cmd_app_deploy() {
|
|
local zip_file="$1"
|
|
local name="$2"
|
|
|
|
[ -z "$zip_file" ] || [ ! -f "$zip_file" ] && {
|
|
error "Usage: streamlitctl app deploy <zipfile> [name]"
|
|
return 1
|
|
}
|
|
|
|
# Auto-name from filename if not specified
|
|
[ -z "$name" ] && name=$(basename "$zip_file" .zip | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/_/g')
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
log "Deploying $name from $zip_file..."
|
|
|
|
# Remove existing
|
|
[ -d "$app_dir" ] && rm -rf "$app_dir"
|
|
mkdir -p "$app_dir"
|
|
|
|
# Extract with flattening
|
|
local tmpdir="/tmp/streamlit_extract_$$"
|
|
mkdir -p "$tmpdir"
|
|
unzip -q "$zip_file" -d "$tmpdir"
|
|
|
|
# Check for single root directory
|
|
local root_count=$(ls -1 "$tmpdir" | wc -l)
|
|
if [ "$root_count" = "1" ]; then
|
|
local single_root="$tmpdir/$(ls -1 $tmpdir | head -1)"
|
|
if [ -d "$single_root" ]; then
|
|
mv "$single_root"/* "$app_dir/" 2>/dev/null || true
|
|
mv "$single_root"/.[!.]* "$app_dir/" 2>/dev/null || true
|
|
else
|
|
mv "$single_root" "$app_dir/"
|
|
fi
|
|
else
|
|
mv "$tmpdir"/* "$app_dir/" 2>/dev/null || true
|
|
fi
|
|
rm -rf "$tmpdir"
|
|
|
|
# Install requirements if present and container is running
|
|
if [ -f "$app_dir/requirements.txt" ] && lxc_running; then
|
|
log "Installing requirements..."
|
|
lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/$name/requirements.txt" || true
|
|
fi
|
|
|
|
log "App $name deployed to $app_dir"
|
|
echo '{"success": true, "name": "'"$name"'", "path": "'"$app_dir"'"}'
|
|
}
|
|
|
|
cmd_app_remove() {
|
|
local name="$1"
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app remove <name>"; return 1; }
|
|
|
|
# Stop if running
|
|
cmd_app_stop "$name" 2>/dev/null || true
|
|
|
|
# Remove directory
|
|
if [ -d "$APPS_PATH/$name" ]; then
|
|
rm -rf "$APPS_PATH/$name"
|
|
log "App $name removed"
|
|
else
|
|
error "App not found: $name"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
cmd_app_logs() {
|
|
local name="$1"
|
|
local lines="${2:-100}"
|
|
|
|
[ -z "$name" ] && { error "Usage: streamlitctl app logs <name> [lines]"; return 1; }
|
|
|
|
local log_file="$DATA_PATH/logs/$name.log"
|
|
if [ -f "$log_file" ]; then
|
|
tail -n "$lines" "$log_file"
|
|
else
|
|
echo "No logs found for $name"
|
|
fi
|
|
}
|
|
|
|
cmd_autostart() {
|
|
# Start apps marked for autostart in config
|
|
if [ -f "$CONF_PATH" ]; then
|
|
local apps=$(grep -E '^\[apps\.' "$CONF_PATH" | sed 's/\[apps\.//;s/\]//')
|
|
for name in $apps; do
|
|
local autostart=$(grep -A5 "^\[apps\.$name\]" "$CONF_PATH" | grep -E '^autostart\s*=' | grep -q 'true' && echo "true" || echo "false")
|
|
local port=$(grep -A5 "^\[apps\.$name\]" "$CONF_PATH" | grep -E '^port\s*=' | cut -d'=' -f2 | tr -d ' ')
|
|
if [ "$autostart" = "true" ] && [ -n "$port" ]; then
|
|
cmd_app_start "$name" "$port" 2>/dev/null || true
|
|
fi
|
|
done
|
|
fi
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# INSTANCE MANAGEMENT (multiple instances per app)
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_instance_list() {
|
|
echo '{"instances":['
|
|
local first=true
|
|
|
|
if [ -f "$CONF_PATH" ]; then
|
|
grep -E '^\[instances\.' "$CONF_PATH" | while read -r line; do
|
|
local id=$(echo "$line" | sed 's/\[instances\.//;s/\]//')
|
|
|
|
# Parse instance config
|
|
local section=$(sed -n "/^\[instances\.$id\]/,/^\[/p" "$CONF_PATH" | head -n -1)
|
|
local app=$(echo "$section" | grep -E '^app\s*=' | cut -d'"' -f2)
|
|
local port=$(echo "$section" | grep -E '^port\s*=' | cut -d'=' -f2 | tr -d ' ')
|
|
local enabled=$(echo "$section" | grep -E '^enabled\s*=' | grep -q 'true' && echo "true" || echo "false")
|
|
|
|
local running="false"
|
|
[ -n "$port" ] && ss -tln "sport = :$port" 2>/dev/null | grep -q "$port" && running="true"
|
|
|
|
$first || echo ","
|
|
first=false
|
|
|
|
echo " {\"id\": \"$id\", \"app\": \"$app\", \"port\": ${port:-0}, \"enabled\": $enabled, \"running\": $running}"
|
|
done
|
|
fi
|
|
|
|
echo ']}'
|
|
}
|
|
|
|
cmd_instance_start() {
|
|
local id="$1"
|
|
[ -z "$id" ] && { error "Usage: streamlitctl instance start <id>"; return 1; }
|
|
|
|
# Read instance config
|
|
if [ -f "$CONF_PATH" ]; then
|
|
local section=$(sed -n "/^\[instances\.$id\]/,/^\[/p" "$CONF_PATH" | head -n -1)
|
|
local app=$(echo "$section" | grep -E '^app\s*=' | cut -d'"' -f2)
|
|
local port=$(echo "$section" | grep -E '^port\s*=' | cut -d'=' -f2 | tr -d ' ')
|
|
|
|
[ -z "$app" ] || [ -z "$port" ] && { error "Instance $id not configured"; return 1; }
|
|
|
|
cmd_app_start "$app" "$port"
|
|
else
|
|
error "No config file found"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
cmd_instance_stop() {
|
|
local id="$1"
|
|
[ -z "$id" ] && { error "Usage: streamlitctl instance stop <id>"; return 1; }
|
|
|
|
if [ -f "$CONF_PATH" ]; then
|
|
local section=$(sed -n "/^\[instances\.$id\]/,/^\[/p" "$CONF_PATH" | head -n -1)
|
|
local app=$(echo "$section" | grep -E '^app\s*=' | cut -d'"' -f2)
|
|
|
|
[ -n "$app" ] && cmd_app_stop "$app"
|
|
fi
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# MIGRATION
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_migrate() {
|
|
local source="${1:-192.168.255.1}"
|
|
|
|
log "Migrating Streamlit data from $source..."
|
|
mkdir -p "$DATA_PATH" "$APPS_PATH"
|
|
|
|
# Sync apps directory
|
|
log "Syncing applications..."
|
|
rsync -avz --progress "root@$source:/srv/streamlit/apps/" "$APPS_PATH/" 2>/dev/null || {
|
|
# Try alternative path
|
|
rsync -avz --progress "root@$source:/overlay/upper/srv/streamlit/apps/" "$APPS_PATH/" 2>/dev/null || true
|
|
}
|
|
|
|
# Convert UCI config to TOML
|
|
log "Converting UCI configuration to TOML..."
|
|
ssh "root@$source" "uci show streamlit 2>/dev/null" | while read -r line; do
|
|
case "$line" in
|
|
streamlit.main.*)
|
|
key=$(echo "$line" | cut -d'.' -f3 | cut -d'=' -f1)
|
|
val=$(echo "$line" | cut -d'=' -f2- | tr -d "'")
|
|
echo "$key = \"$val\""
|
|
;;
|
|
esac
|
|
done > "$CONF_PATH.tmp"
|
|
|
|
# Add apps sections
|
|
for app_dir in "$APPS_PATH"/*/; do
|
|
[ -d "$app_dir" ] || continue
|
|
local name=$(basename "$app_dir")
|
|
[ "$name" = "*" ] && continue
|
|
|
|
# Get port from UCI if available
|
|
local port=$(ssh "root@$source" "uci -q get streamlit.$name.port 2>/dev/null" || echo "")
|
|
local enabled=$(ssh "root@$source" "uci -q get streamlit.$name.enabled 2>/dev/null" || echo "1")
|
|
|
|
cat >> "$CONF_PATH.tmp" << EOF
|
|
|
|
[apps.$name]
|
|
enabled = $([ "$enabled" = "1" ] && echo "true" || echo "false")
|
|
port = ${port:-0}
|
|
EOF
|
|
done
|
|
|
|
# Finalize config
|
|
[ -s "$CONF_PATH.tmp" ] && mv "$CONF_PATH.tmp" "$CONF_PATH"
|
|
|
|
# Count migrated apps
|
|
local count=$(find "$APPS_PATH" -maxdepth 1 -type d | wc -l)
|
|
count=$((count - 1))
|
|
|
|
log "Migration complete: $count apps imported"
|
|
echo "{\"success\": true, \"apps_migrated\": $count}"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# GITEA INTEGRATION
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
cmd_gitea_push() {
|
|
local name="$1"
|
|
[ -z "$name" ] && { error "Usage: streamlitctl gitea push <app>"; return 1; }
|
|
[ -d "$APPS_PATH/$name" ] || { error "App not found: $name"; return 1; }
|
|
|
|
# Read Gitea config
|
|
if [ ! -f "$CONF_PATH" ]; then
|
|
error "No config file. Configure Gitea first."
|
|
return 1
|
|
fi
|
|
|
|
local gitea_url=$(grep -E '^url\s*=' "$CONF_PATH" | head -1 | cut -d'"' -f2)
|
|
local gitea_user=$(grep -E '^user\s*=' "$CONF_PATH" | head -1 | cut -d'"' -f2)
|
|
local gitea_token=$(grep -E '^token\s*=' "$CONF_PATH" | head -1 | cut -d'"' -f2)
|
|
|
|
[ -z "$gitea_url" ] && { error "Gitea URL not configured"; return 1; }
|
|
|
|
cd "$APPS_PATH/$name"
|
|
|
|
# Initialize git if needed
|
|
if [ ! -d .git ]; then
|
|
git init
|
|
git add -A
|
|
git commit -m "Initial commit from SecuBox Streamlit"
|
|
fi
|
|
|
|
# Create repo on Gitea if it doesn't exist
|
|
if [ -n "$gitea_token" ]; then
|
|
curl -s -X POST "$gitea_url/api/v1/user/repos" \
|
|
-H "Authorization: token $gitea_token" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"name\": \"$name\", \"private\": true}" 2>/dev/null || true
|
|
fi
|
|
|
|
# Set remote and push
|
|
local remote_url="$gitea_url/${gitea_user}/${name}.git"
|
|
git remote remove origin 2>/dev/null || true
|
|
git remote add origin "$remote_url"
|
|
|
|
if [ -n "$gitea_token" ]; then
|
|
# Use token in URL
|
|
remote_url=$(echo "$remote_url" | sed "s|://|://$gitea_token@|")
|
|
git push -u "$remote_url" main 2>/dev/null || git push -u "$remote_url" master 2>/dev/null || true
|
|
fi
|
|
|
|
log "Pushed $name to Gitea"
|
|
}
|
|
|
|
cmd_gitea_clone() {
|
|
local name="$1"
|
|
local repo="$2"
|
|
|
|
[ -z "$name" ] || [ -z "$repo" ] && {
|
|
error "Usage: streamlitctl gitea clone <name> <repo_url>"
|
|
return 1
|
|
}
|
|
|
|
local app_dir="$APPS_PATH/$name"
|
|
[ -d "$app_dir" ] && { error "App already exists: $name"; return 1; }
|
|
|
|
log "Cloning $repo to $name..."
|
|
git clone "$repo" "$app_dir"
|
|
|
|
# Install requirements if present
|
|
if [ -f "$app_dir/requirements.txt" ] && lxc_running; then
|
|
lxc-attach -n "$LXC_NAME" -- pip3 install --break-system-packages -r "/srv/apps/$name/requirements.txt" || true
|
|
fi
|
|
|
|
log "Cloned $name from Gitea"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# MAIN
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
load_config
|
|
|
|
case "${1:-}" in
|
|
# Three-fold architecture
|
|
components) cmd_components ;;
|
|
access) cmd_access ;;
|
|
status) cmd_status ;;
|
|
# Container control
|
|
install) cmd_install ;;
|
|
uninstall) cmd_uninstall ;;
|
|
update) cmd_update ;;
|
|
start) cmd_start ;;
|
|
stop) cmd_stop ;;
|
|
restart) cmd_stop; sleep 1; cmd_start ;;
|
|
|
|
app)
|
|
case "${2:-}" in
|
|
list) cmd_app_list ;;
|
|
idle-check) cmd_app_idle_check ;;
|
|
wake) cmd_app_wake "$3" "$4" ;;
|
|
start) cmd_app_start "$3" "$4" ;;
|
|
stop) cmd_app_stop "$3" ;;
|
|
deploy) cmd_app_deploy "$3" "$4" ;;
|
|
remove) cmd_app_remove "$3" ;;
|
|
logs) cmd_app_logs "$3" "$4" ;;
|
|
info) cmd_app_info "$3" ;;
|
|
restart) cmd_app_restart "$3" ;;
|
|
*) echo "Usage: streamlitctl app {list|start|stop|restart|deploy|remove|logs|info|idle-check|wake} [args]" ;;
|
|
esac
|
|
;;
|
|
|
|
instance)
|
|
case "${2:-}" in
|
|
list) cmd_instance_list ;;
|
|
start) cmd_instance_start "$3" ;;
|
|
stop) cmd_instance_stop "$3" ;;
|
|
*) echo "Usage: streamlitctl instance {list|start|stop} [id]" ;;
|
|
esac
|
|
;;
|
|
|
|
migrate) cmd_migrate "$2" ;;
|
|
autostart) cmd_autostart ;;
|
|
|
|
gitea)
|
|
case "${2:-}" in
|
|
push) cmd_gitea_push "$3" ;;
|
|
clone) cmd_gitea_clone "$3" "$4" ;;
|
|
*) echo "Usage: streamlitctl gitea {push|clone} [args]" ;;
|
|
esac
|
|
;;
|
|
|
|
*)
|
|
cat << EOF
|
|
streamlitctl v$VERSION - Streamlit Platform Manager
|
|
Three-fold architecture: Components, Status, Access
|
|
|
|
Information (Three-fold):
|
|
components List system components (JSON)
|
|
status Show container status (JSON)
|
|
access Show apps and access info (JSON)
|
|
|
|
Container:
|
|
install Install LXC container with Streamlit
|
|
uninstall Remove container (preserves data)
|
|
update Update packages in container
|
|
start Start container
|
|
stop Stop container
|
|
restart Restart container
|
|
|
|
Apps:
|
|
app list List all apps
|
|
app start <name> [port] Start an app
|
|
app stop <name> Stop an app
|
|
app deploy <zip> [name] Deploy from ZIP
|
|
app remove <name> Remove an app
|
|
app logs <name> [lines] View app logs
|
|
|
|
Instances:
|
|
instance list List configured instances
|
|
instance start <id> Start instance
|
|
instance stop <id> Stop instance
|
|
|
|
Integration:
|
|
migrate [source] Migrate from OpenWrt (default: 192.168.255.1)
|
|
gitea push <app> Push app to Gitea
|
|
gitea clone <name> <url> Clone from Gitea
|
|
|
|
Examples:
|
|
streamlitctl components # JSON list of components
|
|
streamlitctl access # JSON list of apps with URLs
|
|
|
|
EOF
|
|
;;
|
|
esac
|