Support LAN deployment and managed Python runtime

Made-with: Cursor
This commit is contained in:
bzuccaro
2026-04-25 18:05:56 +00:00
parent fe568c17cd
commit e95ee9c938
12 changed files with 263 additions and 72 deletions
+14 -14
View File
@@ -101,7 +101,7 @@ If CUDA is already mounted or preinstalled outside `PATH`, the installer detects
- Run `./labctl assets lab2` when you want to populate repo-local Lab 2 assets in `assets/lab2/` from Hugging Face.
- After base setup, run `state/lab2/download_whiterabbitneo-gguf.sh` to fetch only the `Q4_K_M`, `Q8_0`, and `IQ2_M` files from `bartowski/WhiteRabbitNeo_WhiteRabbitNeo-V3-7B-GGUF` and register local Ollama models `WhiteRabbitNeo`, `WhiteRabbitNeo-Q4`, `WhiteRabbitNeo-Q8`, and `WhiteRabbitNeo-IQ2`.
- Unsloth homes are redirected into this project's `state/` tree via symlinks.
- Managed web services bind for access from both Linux and the Windows side of WSL, while `labctl urls` still reports localhost-friendly URLs.
- Managed web services bind on all interfaces for headless LAN/VPN access. `labctl urls` reports the detected LAN IP by default; set `COURSEWARE_URL_HOST=<host-or-ip>` before `./labctl up` to advertise a specific VPN DNS name or address.
- The local Ansible bootstrap in `.venv-ansible/` is machine-specific and will be recreated automatically if the folder is copied between hosts.
- `llama.cpp` uses a conservative, memory-aware build parallelism setting instead of an unbounded `-j` build, which avoids OOM failures on smaller Linux and WSL hosts.
@@ -109,25 +109,25 @@ If CUDA is already mounted or preinstalled outside `PATH`, the installer detects
After `./deploy-courseware.sh`, run `./labctl urls`.
Default endpoints:
Default endpoints use the detected host LAN IP:
- Ollama API: `http://127.0.0.1:11434`
- Open WebUI: `http://127.0.0.1:8080`
- Netron: `http://127.0.0.1:8338`
- ChunkViz: `http://127.0.0.1:3001`
- Embedding Atlas: `http://127.0.0.1:5055`
- Unsloth Studio: `http://127.0.0.1:8888`
- Promptfoo UI: `http://127.0.0.1:15500`
- Wiki: `http://127.0.0.1:80`
- Lab 3 Terminal: `http://127.0.0.1:7681/wetty`
- Ollama API: `http://<host-lan-ip>:11434`
- Open WebUI: `http://<host-lan-ip>:8080`
- Netron: `http://<host-lan-ip>:8338`
- ChunkViz: `http://<host-lan-ip>:3001`
- Embedding Atlas: `http://<host-lan-ip>:5055`
- Unsloth Studio: `http://<host-lan-ip>:8888`
- Promptfoo UI: `http://<host-lan-ip>:15500`
- Wiki: `http://<host-lan-ip>:80`
- Lab 3 Terminal: `http://<host-lan-ip>:7681/wetty`
## Lab 3 Browser Terminal
The deployment will:
- bind `sshd` to `127.0.0.1:22` only
- install WeTTY and expose it at `http://127.0.0.1:7681/wetty`
- leave login identity management to the host, so any existing local account with password-based SSH access can sign in through the browser terminal
- bind `sshd` to `0.0.0.0:22` so regular SSH clients can connect over the LAN/VPN
- install WeTTY and expose it at `http://<host-lan-ip>:7681/wetty`
- leave login identity management to the host, so any existing local account with password-based SSH access can sign in through SSH or the browser terminal
## Notes
+17 -1
View File
@@ -2,6 +2,8 @@ courseware_state_dir: "{{ courseware_root }}/state"
courseware_markers_dir: "{{ courseware_state_dir }}/markers"
courseware_logs_dir: "{{ courseware_state_dir }}/logs"
courseware_run_dir: "{{ courseware_state_dir }}/run"
courseware_cache_dir: "{{ courseware_state_dir }}/cache"
courseware_tmp_dir: "{{ courseware_state_dir }}/tmp"
courseware_repos_dir: "{{ courseware_state_dir }}/repos"
courseware_venvs_dir: "{{ courseware_state_dir }}/venvs"
courseware_models_dir: "{{ courseware_state_dir }}/models"
@@ -17,6 +19,10 @@ courseware_lab1_models_dir: "{{ courseware_models_dir }}/lab1"
courseware_ollama_models_dir: "{{ courseware_models_dir }}/ollama"
courseware_node_runtime_dir: "{{ courseware_tools_dir }}/node-runtime"
courseware_node_runtime_bin_dir: "{{ courseware_node_runtime_dir }}/node_modules/node/bin"
courseware_uv_dir: "{{ courseware_tools_dir }}/uv"
courseware_uv_bin: "{{ courseware_uv_dir }}/bin/uv"
courseware_uv_cache_dir: "{{ courseware_cache_dir }}/uv"
courseware_python_runtime_dir: "{{ courseware_tools_dir }}/python"
courseware_netron_venv_dir: "{{ courseware_venvs_dir }}/netron"
courseware_wetty_dir: "{{ courseware_tools_dir }}/wetty"
courseware_promptfoo_dir: "{{ courseware_lab6_dir }}"
@@ -25,7 +31,15 @@ courseware_wiki_runtime_config_path: "{{ courseware_wiki_repo_dir }}/public/cour
courseware_llama_cpp_bin_dir: "{{ courseware_repos_dir }}/llama.cpp/build/bin"
courseware_bind_host: "0.0.0.0"
courseware_url_host: "127.0.0.1"
courseware_url_host: >-
{{
(lookup('env', 'COURSEWARE_URL_HOST') | trim)
if (lookup('env', 'COURSEWARE_URL_HOST') | trim | length) > 0
else (
ansible_default_ipv4.address
| default(ansible_all_ipv4_addresses | default(['127.0.0.1']) | first)
)
}}
courseware_ports:
ollama: 11434
open_webui: 8080
@@ -44,6 +58,8 @@ courseware_chunkviz_commit: "a891eacafda1f28a12373ad3b00102e68f07c57f"
courseware_promptfoo_version: "0.119.0"
courseware_kiln_release_tag: "v0.18.1"
courseware_node_runtime_version: "20.20.2"
courseware_python_runtime_version: "3.12"
courseware_uv_spec: "uv"
courseware_wetty_spec: "wetty@2.5.0"
courseware_wetty_base_path: "/wetty"
courseware_wiki_repo: "https://git.zuccaro.me/bzuccaro/LLM-Labs.git"
+1
View File
@@ -6,6 +6,7 @@
- { role: preflight, tags: ["preflight"] }
- directories
- packages
- python_runtime
- netron
- lab1_assets
- lab_assets
+3
View File
@@ -8,6 +8,9 @@
- "{{ courseware_markers_dir }}"
- "{{ courseware_logs_dir }}"
- "{{ courseware_run_dir }}"
- "{{ courseware_cache_dir }}"
- "{{ courseware_tmp_dir }}"
- "{{ courseware_uv_cache_dir }}"
- "{{ courseware_repos_dir }}"
- "{{ courseware_venvs_dir }}"
- "{{ courseware_models_dir }}"
+36
View File
@@ -4,6 +4,24 @@
state: directory
mode: "0755"
- name: Check Open WebUI virtual environment Python version
command:
argv:
- "{{ courseware_venvs_dir }}/open-webui/bin/python"
- -c
- "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
register: courseware_open_webui_venv_python_version
changed_when: false
failed_when: false
- name: Remove Open WebUI virtual environment with incompatible Python
file:
path: "{{ courseware_venvs_dir }}/open-webui"
state: absent
when:
- courseware_open_webui_venv_python_version.rc == 0
- courseware_open_webui_venv_python_version.stdout != courseware_python_runtime_version
- name: Create Open WebUI virtual environment
command:
argv:
@@ -36,6 +54,24 @@
- "{{ courseware_open_webui_spec }}"
- "numpy<2"
- name: Check Embedding Atlas virtual environment Python version
command:
argv:
- "{{ courseware_venvs_dir }}/embedding-atlas/bin/python"
- -c
- "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
register: courseware_embedding_atlas_venv_python_version
changed_when: false
failed_when: false
- name: Remove Embedding Atlas virtual environment with incompatible Python
file:
path: "{{ courseware_venvs_dir }}/embedding-atlas"
state: absent
when:
- courseware_embedding_atlas_venv_python_version.rc == 0
- courseware_embedding_atlas_venv_python_version.stdout != courseware_python_runtime_version
- name: Create Embedding Atlas virtual environment
command:
argv:
+1
View File
@@ -14,6 +14,7 @@
- pkg-config
- python3
- python3-pip
- python3-setuptools
- python3-venv
- unzip
- zstd
@@ -0,0 +1,74 @@
- name: Create contained Python runtime manager virtual environment
command:
argv:
- /usr/bin/python3
- -m
- venv
- "{{ courseware_uv_dir }}"
args:
creates: "{{ courseware_uv_dir }}/bin/python"
- name: Upgrade contained Python runtime manager tooling
command:
argv:
- "{{ courseware_uv_dir }}/bin/python"
- -m
- pip
- install
- --upgrade
- pip
- setuptools
- wheel
- name: Install contained Python runtime manager
command:
argv:
- "{{ courseware_uv_dir }}/bin/python"
- -m
- pip
- install
- "{{ courseware_uv_spec }}"
- name: Install managed CPython runtime
command:
argv:
- "{{ courseware_uv_bin }}"
- python
- install
- "{{ courseware_python_runtime_version }}"
- --install-dir
- "{{ courseware_python_runtime_dir }}"
environment:
UV_PYTHON_INSTALL_DIR: "{{ courseware_python_runtime_dir }}"
UV_CACHE_DIR: "{{ courseware_uv_cache_dir }}"
XDG_CACHE_HOME: "{{ courseware_cache_dir }}"
TMPDIR: "{{ courseware_tmp_dir }}"
register: courseware_python_runtime_install
changed_when: "'Installed Python' in courseware_python_runtime_install.stdout"
- name: Resolve managed CPython runtime
command:
argv:
- "{{ courseware_uv_bin }}"
- python
- find
- "{{ courseware_python_runtime_version }}"
environment:
UV_PYTHON_INSTALL_DIR: "{{ courseware_python_runtime_dir }}"
UV_CACHE_DIR: "{{ courseware_uv_cache_dir }}"
XDG_CACHE_HOME: "{{ courseware_cache_dir }}"
TMPDIR: "{{ courseware_tmp_dir }}"
register: courseware_python_runtime_find
changed_when: false
- name: Set managed Python runtime for courseware venvs
set_fact:
courseware_python_bin: "{{ courseware_python_runtime_find.stdout | trim }}"
- name: Verify managed Python runtime version
command:
argv:
- "{{ courseware_python_bin }}"
- -c
- "import sys; expected=tuple(map(int, '{{ courseware_python_runtime_version }}'.split('.'))); raise SystemExit(0 if sys.version_info[:len(expected)] == expected else 1)"
changed_when: false
+23 -5
View File
@@ -46,6 +46,25 @@
enabled: true
when: ansible_service_mgr == "systemd"
- name: Check systemd sshd listener policy
become: true
command: ss -ltn
register: courseware_terminal_systemd_ss_listeners
changed_when: false
when: ansible_service_mgr == "systemd"
- name: Restart sshd with systemd when listener policy is not active
become: true
systemd:
name: ssh
state: restarted
enabled: true
when:
- ansible_service_mgr == "systemd"
- >-
'0.0.0.0:22' not in courseware_terminal_systemd_ss_listeners.stdout
or '[::]:22' in courseware_terminal_systemd_ss_listeners.stdout
- name: Check for running sshd when systemd is unavailable
become: true
command: pgrep -x sshd
@@ -89,19 +108,18 @@
environment:
PATH: "{{ courseware_node_runtime_bin_dir }}:{{ ansible_env.PATH }}"
- name: Check loopback sshd listener
- name: Check sshd listener
become: true
command: ss -ltn
register: courseware_terminal_ss_listeners
changed_when: false
- name: Assert sshd is loopback-only
- name: Assert sshd accepts LAN and loopback clients
assert:
that:
- "'127.0.0.1:22' in courseware_terminal_ss_listeners.stdout"
- "'0.0.0.0:22' not in courseware_terminal_ss_listeners.stdout"
- "'0.0.0.0:22' in courseware_terminal_ss_listeners.stdout"
- "'[::]:22' not in courseware_terminal_ss_listeners.stdout"
fail_msg: "sshd must listen only on 127.0.0.1:22 for the browser terminal deployment."
fail_msg: "sshd must listen on 0.0.0.0:22 so VPN/LAN SSH clients and local WeTTY can connect."
- name: Assert WeTTY binary exists
stat:
@@ -1,5 +1,5 @@
# Managed by Local Courseware Deployment.
ListenAddress 127.0.0.1
ListenAddress 0.0.0.0
AddressFamily inet
PermitRootLogin no
PasswordAuthentication yes
+19
View File
@@ -17,6 +17,10 @@
args:
executable: /bin/bash
creates: "{{ courseware_unsloth_home }}/.install_complete"
environment:
UV_CACHE_DIR: "{{ courseware_uv_cache_dir }}"
XDG_CACHE_HOME: "{{ courseware_cache_dir }}"
TMPDIR: "{{ courseware_tmp_dir }}"
rescue:
- name: Capture Unsloth installer log tail
shell: |
@@ -41,3 +45,18 @@
Last log lines:
{{ courseware_unsloth_install_log_tail.stdout | default('(no log output captured)') }}
- name: Install x86_64-compatible NumPy for Unsloth Studio
command:
argv:
- "{{ ansible_env.HOME }}/.unsloth/studio/unsloth_studio/bin/python"
- -m
- pip
- install
- "numpy<2"
environment:
UV_CACHE_DIR: "{{ courseware_uv_cache_dir }}"
XDG_CACHE_HOME: "{{ courseware_cache_dir }}"
TMPDIR: "{{ courseware_tmp_dir }}"
register: courseware_unsloth_numpy_install
changed_when: "'Successfully installed' in courseware_unsloth_numpy_install.stdout"
+3
View File
@@ -14,7 +14,10 @@ load_runtime_env() {
: "${COURSEWARE_STATE_DIR:=$STATE_DIR}"
: "${COURSEWARE_BIND_HOST:=127.0.0.1}"
if [ -z "${COURSEWARE_URL_HOST:-}" ]; then
COURSEWARE_URL_HOST=$(ip route get 1.1.1.1 2>/dev/null | sed -nE 's/.* src ([0-9.]+).*/\1/p' | head -n 1)
: "${COURSEWARE_URL_HOST:=127.0.0.1}"
fi
: "${COURSEWARE_NETRON_PORT:=8338}"
: "${COURSEWARE_PROMPTFOO_PORT:=15500}"
: "${COURSEWARE_WIKI_PORT:=80}"
+70 -50
View File
@@ -115,8 +115,9 @@ is_running() {
service_startup_attempts() {
case "$1" in
embedding-atlas)
# The first launch can be noticeably slower on cold environments.
printf '%s\n' 180
# First launch embeds the bundled dataset. On older GPU drivers this falls
# back to CPU and can take close to an hour.
printf '%s\n' 3600
;;
*)
printf '%s\n' 60
@@ -198,56 +199,13 @@ terminate_service_processes() {
done < <(service_listener_pids "$service")
}
start_one() {
wait_for_service_ready() {
local service=$1
local cmd
local log_file
local pid_file
local log_file=$2
local pid_file=$3
local startup_attempts=$4
local pid_grace_attempts=$5
local attempt
local pid_grace_attempts=5
local startup_attempts
if [ "$service" = "ollama" ] || [ "$service" = "wiki" ]; then
assert_ollama_logprobs_support
fi
if has_live_pid "$service"; then
echo "$service already running"
return 0
fi
if service_ready "$service"; then
echo "$service already available"
return 0
fi
case "$service" in
open-webui)
start_one ollama
;;
wetty)
check_wetty_prereqs
;;
*)
;;
esac
cmd=$(service_command "$service")
startup_attempts=$(service_startup_attempts "$service")
log_file=$(service_log_file "$service")
pid_file=$(service_pid_file "$service")
if [ "$service" = "ollama" ]; then
env \
OLLAMA_HOST="${COURSEWARE_BIND_HOST}:${COURSEWARE_OLLAMA_PORT}" \
OLLAMA_MODELS="$OLLAMA_MODELS_DIR" \
"$OLLAMA_BIN" serve </dev/null >>"$log_file" 2>&1 &
elif command -v setsid >/dev/null 2>&1; then
nohup setsid bash -lc "$cmd" </dev/null >>"$log_file" 2>&1 &
else
nohup bash -lc "$cmd" </dev/null >>"$log_file" 2>&1 &
fi
echo $! >"$pid_file"
for attempt in $(seq 1 "$startup_attempts"); do
if service_ready "$service"; then
@@ -270,6 +228,68 @@ start_one() {
exit 1
}
start_one() {
local service=$1
local cmd
local log_file
local pid_file
local pid_grace_attempts=5
local startup_attempts
if [ "$service" = "ollama" ] || [ "$service" = "wiki" ]; then
assert_ollama_logprobs_support
fi
startup_attempts=$(service_startup_attempts "$service")
log_file=$(service_log_file "$service")
pid_file=$(service_pid_file "$service")
if service_ready "$service"; then
echo "$service already available"
return 0
fi
if has_live_pid "$service"; then
echo "$service already starting"
wait_for_service_ready "$service" "$log_file" "$pid_file" "$startup_attempts" "$pid_grace_attempts"
return 0
fi
case "$service" in
open-webui)
start_one ollama
;;
wetty)
check_wetty_prereqs
;;
*)
;;
esac
cmd=$(service_command "$service")
if [ "$service" = "ollama" ]; then
if command -v setsid >/dev/null 2>&1; then
nohup setsid env \
OLLAMA_HOST="${COURSEWARE_BIND_HOST}:${COURSEWARE_OLLAMA_PORT}" \
OLLAMA_MODELS="$OLLAMA_MODELS_DIR" \
"$OLLAMA_BIN" serve </dev/null >>"$log_file" 2>&1 &
else
nohup env \
OLLAMA_HOST="${COURSEWARE_BIND_HOST}:${COURSEWARE_OLLAMA_PORT}" \
OLLAMA_MODELS="$OLLAMA_MODELS_DIR" \
"$OLLAMA_BIN" serve </dev/null >>"$log_file" 2>&1 &
fi
elif command -v setsid >/dev/null 2>&1; then
nohup setsid bash -lc "$cmd" </dev/null >>"$log_file" 2>&1 &
else
nohup bash -lc "$cmd" </dev/null >>"$log_file" 2>&1 &
fi
echo $! >"$pid_file"
wait_for_service_ready "$service" "$log_file" "$pid_file" "$startup_attempts" "$pid_grace_attempts"
}
stop_one() {
local service=$1
local pid_file