From e95ee9c93867d7dfd769d3228f3b0a9d43c7f153 Mon Sep 17 00:00:00 2001 From: bzuccaro Date: Sat, 25 Apr 2026 18:05:56 +0000 Subject: [PATCH] Support LAN deployment and managed Python runtime Made-with: Cursor --- README.md | 28 ++-- ansible/group_vars/all.yml | 18 ++- ansible/playbooks/up.yml | 1 + ansible/roles/directories/tasks/main.yml | 3 + ansible/roles/open_webui/tasks/main.yml | 36 ++++++ ansible/roles/packages/tasks/linux.yml | 1 + ansible/roles/python_runtime/tasks/main.yml | 74 +++++++++++ ansible/roles/terminal/tasks/main.yml | 28 +++- .../sshd-courseware-terminal.conf.j2 | 2 +- ansible/roles/unsloth/tasks/main.yml | 19 +++ scripts/common.sh | 5 +- scripts/service_manager.sh | 120 ++++++++++-------- 12 files changed, 263 insertions(+), 72 deletions(-) create mode 100644 ansible/roles/python_runtime/tasks/main.yml diff --git a/README.md b/README.md index 9517762..3675081 100644 --- a/README.md +++ b/README.md @@ -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=` 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://:11434` +- Open WebUI: `http://:8080` +- Netron: `http://:8338` +- ChunkViz: `http://:3001` +- Embedding Atlas: `http://:5055` +- Unsloth Studio: `http://:8888` +- Promptfoo UI: `http://:15500` +- Wiki: `http://:80` +- Lab 3 Terminal: `http://: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://: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 diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml index 4260dc0..ec36a9d 100644 --- a/ansible/group_vars/all.yml +++ b/ansible/group_vars/all.yml @@ -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" diff --git a/ansible/playbooks/up.yml b/ansible/playbooks/up.yml index 6861cbf..b21f938 100644 --- a/ansible/playbooks/up.yml +++ b/ansible/playbooks/up.yml @@ -6,6 +6,7 @@ - { role: preflight, tags: ["preflight"] } - directories - packages + - python_runtime - netron - lab1_assets - lab_assets diff --git a/ansible/roles/directories/tasks/main.yml b/ansible/roles/directories/tasks/main.yml index 42ed703..afbed35 100644 --- a/ansible/roles/directories/tasks/main.yml +++ b/ansible/roles/directories/tasks/main.yml @@ -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 }}" diff --git a/ansible/roles/open_webui/tasks/main.yml b/ansible/roles/open_webui/tasks/main.yml index 86a9ae6..ce12da0 100644 --- a/ansible/roles/open_webui/tasks/main.yml +++ b/ansible/roles/open_webui/tasks/main.yml @@ -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: diff --git a/ansible/roles/packages/tasks/linux.yml b/ansible/roles/packages/tasks/linux.yml index ce8ccc7..667a64d 100644 --- a/ansible/roles/packages/tasks/linux.yml +++ b/ansible/roles/packages/tasks/linux.yml @@ -14,6 +14,7 @@ - pkg-config - python3 - python3-pip + - python3-setuptools - python3-venv - unzip - zstd diff --git a/ansible/roles/python_runtime/tasks/main.yml b/ansible/roles/python_runtime/tasks/main.yml new file mode 100644 index 0000000..01d21c8 --- /dev/null +++ b/ansible/roles/python_runtime/tasks/main.yml @@ -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 diff --git a/ansible/roles/terminal/tasks/main.yml b/ansible/roles/terminal/tasks/main.yml index 6c35a14..d53640e 100644 --- a/ansible/roles/terminal/tasks/main.yml +++ b/ansible/roles/terminal/tasks/main.yml @@ -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: diff --git a/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 b/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 index 6d98cb4..ea1e053 100644 --- a/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 +++ b/ansible/roles/terminal/templates/sshd-courseware-terminal.conf.j2 @@ -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 diff --git a/ansible/roles/unsloth/tasks/main.yml b/ansible/roles/unsloth/tasks/main.yml index 6536e9e..ceb1ba2 100644 --- a/ansible/roles/unsloth/tasks/main.yml +++ b/ansible/roles/unsloth/tasks/main.yml @@ -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" diff --git a/scripts/common.sh b/scripts/common.sh index 3ec4758..005f0f8 100644 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -14,7 +14,10 @@ load_runtime_env() { : "${COURSEWARE_STATE_DIR:=$STATE_DIR}" : "${COURSEWARE_BIND_HOST:=127.0.0.1}" - : "${COURSEWARE_URL_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}" diff --git a/scripts/service_manager.sh b/scripts/service_manager.sh index 73a1b41..79e6bcb 100755 --- a/scripts/service_manager.sh +++ b/scripts/service_manager.sh @@ -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 >"$log_file" 2>&1 & - elif command -v setsid >/dev/null 2>&1; then - nohup setsid bash -lc "$cmd" >"$log_file" 2>&1 & - else - nohup bash -lc "$cmd" >"$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 >"$log_file" 2>&1 & + else + nohup env \ + OLLAMA_HOST="${COURSEWARE_BIND_HOST}:${COURSEWARE_OLLAMA_PORT}" \ + OLLAMA_MODELS="$OLLAMA_MODELS_DIR" \ + "$OLLAMA_BIN" serve >"$log_file" 2>&1 & + fi + elif command -v setsid >/dev/null 2>&1; then + nohup setsid bash -lc "$cmd" >"$log_file" 2>&1 & + else + nohup bash -lc "$cmd" >"$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