From 199130790348d1765ebec42763b35c423c95faf5 Mon Sep 17 00:00:00 2001 From: Jino Jose Date: Wed, 24 Jun 2026 12:30:56 +0530 Subject: [PATCH] Add web setup UI for first-boot config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.py: Flask-free Python web server on port 80 - 4-step wizard: network → tier → tools → live install log - DHCP or static IP support - SSE log streaming, handles phase-1 reboot gracefully - user-data: deploy web UI service instead of TUI (whiptail) - sets hostname to cezenai (accessible as cezenai.local) - installs python3, avahi-daemon - entry.yml: skip roles based on skip_roles var from web UI - install.sh: reads /opt/cezen/install.conf for tier & skip_roles --- ansible/entry.yml | 31 +- autoinstall/user-data | 37 +- autoinstall/websetup/server.py | 666 +++++++++++++++++++++++++++++++++ install.sh | 11 +- 4 files changed, 717 insertions(+), 28 deletions(-) create mode 100644 autoinstall/websetup/server.py diff --git a/ansible/entry.yml b/ansible/entry.yml index 5ae2a4f..5fbad77 100644 --- a/ansible/entry.yml +++ b/ansible/entry.yml @@ -10,15 +10,26 @@ cezen_home: "/opt/cezen" python_version: "3.11" cuda_version: "12.4" + skip_roles: "" # comma-separated list of role names to skip (set by install.sh) roles: - - base - - docker - - k3s - - ollama - - vllm - - jupyterlab - - chromadb - - mlflow - - minio - - monitoring + - role: base + when: "'base' not in skip_roles.split(',')" + - role: docker + when: "'docker' not in skip_roles.split(',')" + - role: k3s + when: "'k3s' not in skip_roles.split(',')" + - role: ollama + when: "'ollama' not in skip_roles.split(',')" + - role: vllm + when: "'vllm' not in skip_roles.split(',')" + - role: jupyterlab + when: "'jupyterlab' not in skip_roles.split(',')" + - role: chromadb + when: "'chromadb' not in skip_roles.split(',')" + - role: mlflow + when: "'mlflow' not in skip_roles.split(',')" + - role: minio + when: "'minio' not in skip_roles.split(',')" + - role: monitoring + when: "'monitoring' not in skip_roles.split(',')" diff --git a/autoinstall/user-data b/autoinstall/user-data index 1a9da85..60467ab 100644 --- a/autoinstall/user-data +++ b/autoinstall/user-data @@ -8,7 +8,7 @@ autoinstall: layout: us # ── Network: DHCP on first ethernet ─────────── - # Final network config is set by firstboot-setup.sh wizard + # Final network config is set by the web setup UI (browser on port 80) network: network: version: 2 @@ -46,8 +46,10 @@ autoinstall: - git - curl - wget - - whiptail - net-tools + - python3 + - python3-pip + - avahi-daemon # ── Late commands ───────────────────────────── late-commands: @@ -62,35 +64,36 @@ autoinstall: # Clone the Cezen AI installer - git clone https://cgit.cezentech.com/jinojose/aipackage.git /target/opt/aipackage || true - # Deploy the firstboot setup wizard - - cp /target/opt/aipackage/autoinstall/firstboot-setup.sh /target/opt/cezen-setup.sh - - chmod +x /target/opt/cezen-setup.sh + # Deploy the web setup server + - mkdir -p /target/opt/cezen + - cp /target/opt/aipackage/autoinstall/websetup/server.py /target/opt/cezen/websetup.py + - chmod +x /target/opt/cezen/websetup.py - # Create firstboot systemd service (runs the TUI wizard on first login) + # Set hostname to cezenai so it's reachable as cezenai.local via mDNS + - echo "cezenai" > /target/etc/hostname + - sed -i 's/aiserver/cezenai/g' /target/etc/hosts || true + + # Create cezen-setup web UI systemd service - | cat > /target/etc/systemd/system/cezen-setup.service << 'EOF' [Unit] - Description=Cezen AI Suite Setup Wizard - After=network-online.target getty@tty1.service + Description=Cezen AI Suite — Web Setup UI + After=network-online.target avahi-daemon.service Wants=network-online.target ConditionPathExists=!/opt/cezen/.setup-done [Service] - Type=oneshot - ExecStart=/bin/bash /opt/cezen-setup.sh - StandardInput=tty - StandardOutput=tty - StandardError=tty - TTYPath=/dev/tty1 - TTYReset=yes - TTYVHangup=yes - RemainAfterExit=yes + Type=simple + ExecStart=/usr/bin/python3 /opt/cezen/websetup.py + Restart=on-failure + RestartSec=5 [Install] WantedBy=multi-user.target EOF - curtin in-target -- systemctl enable cezen-setup.service + - curtin in-target -- systemctl enable avahi-daemon.service # ── Skip confirmations ───────────────────────── user-data: diff --git a/autoinstall/websetup/server.py b/autoinstall/websetup/server.py new file mode 100644 index 0000000..c038000 --- /dev/null +++ b/autoinstall/websetup/server.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python3 +""" +Cezen AI Suite — First Boot Web Setup Server +Serves on port 80. Access from any browser on the same network. +""" +import os, json, subprocess, threading, time, socket, ipaddress +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse + +SETUP_DONE_FILE = "/opt/cezen/.setup-done" +INSTALL_LOG = "/var/log/cezen-install.log" +AIPACKAGE_DIR = "/opt/aipackage" +install_proc = None +install_status = {"running": False, "done": False, "error": None} + +# ─── Helpers ────────────────────────────────────────────── +def get_ip(): + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except: + return "unknown" + +def get_interfaces(): + try: + out = subprocess.check_output(["ip", "-o", "link", "show"], text=True) + ifaces = [] + for line in out.splitlines(): + name = line.split(": ")[1].split("@")[0] + if name not in ("lo",) and not name.startswith(("docker","br-","veth","k3s")): + ifaces.append(name) + return ifaces + except: + return ["eth0"] + +def apply_static_ip(iface, ip, prefix, gateway, dns): + config = f"""network: + version: 2 + ethernets: + {iface}: + dhcp4: false + addresses: + - {ip}/{prefix} + routes: + - to: default + via: {gateway} + nameservers: + addresses: [{dns}] +""" + with open("/etc/netplan/99-cezen-static.yaml", "w") as f: + f.write(config) + subprocess.run(["netplan", "apply"], capture_output=True) + time.sleep(3) + +def run_install(tier, skip_tools): + global install_status + install_status = {"running": True, "done": False, "error": None} + try: + # Write config so phase 2 (post-reboot) knows what to skip + os.makedirs("/opt/cezen", exist_ok=True) + skip_str = ",".join(skip_tools) if skip_tools else "" + with open("/opt/cezen/install.conf", "w") as f: + f.write(f"TIER={tier}\nSKIP_ROLES={skip_str}\n") + + # Mark setup done NOW so this web UI doesn't restart after the phase-1 reboot + open(SETUP_DONE_FILE, "w").close() + + env = os.environ.copy() + # Phase 1: installs NVIDIA drivers, registers cezen-phase2 systemd service, + # then reboots. Phase 2 (full stack) runs automatically after reboot. + cmd = ["bash", f"{AIPACKAGE_DIR}/install.sh", "--phase=1", f"--tier={tier}"] + with open(INSTALL_LOG, "w") as log: + proc = subprocess.Popen(cmd, stdout=log, stderr=log, env=env) + proc.wait() + # Reaches here only if no reboot happened (e.g. no GPU / drivers already installed) + install_status = {"running": False, "done": True, "error": None} + except Exception as e: + install_status = {"running": False, "done": False, "error": str(e)} + +# ─── HTML UI ────────────────────────────────────────────── +HTML = r""" + + + + +Cezen AI Suite — Server Setup + + + + +
+
+ +
Server Setup Wizard
+
+
+ +
+ +
+
1 · Network
+
2 · Select Tier
+
3 · AI Tools
+
4 · Install
+
+ + +
+
+

Network Configuration

+

Choose how this server gets its IP address. You can change this later.

+ +
+ + +
+ +
+ Server will get an IP automatically from your network. Current IP: detecting... +
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + + + + + + + + + +
+ + + + +""" + +# ─── HTTP Handler ───────────────────────────────────────── +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): pass # suppress access log + + def send_json(self, data, code=200): + body = json.dumps(data).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", len(body)) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = urlparse(self.path).path + + if path == "/" or path == "/index.html": + body = HTML.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", len(body)) + self.end_headers() + self.wfile.write(body) + + elif path == "/api/status": + self.send_json({ + "ip": get_ip(), + "interfaces": get_interfaces(), + "setup_done": os.path.exists(SETUP_DONE_FILE), + "install_status": install_status, + }) + + elif path == "/api/progress": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + try: + with open(INSTALL_LOG, "r") as f: + f.seek(0, 2) # seek to end + while install_status["running"]: + line = f.readline() + if line: + msg = f"data: {line.rstrip()}\n\n" + self.wfile.write(msg.encode()) + self.wfile.flush() + else: + time.sleep(0.5) + # Stream remaining lines after finish + for line in f: + msg = f"data: {line.rstrip()}\n\n" + self.wfile.write(msg.encode()) + self.wfile.write(b"data: === INSTALL COMPLETE ===\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + path = urlparse(self.path).path + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) if length else {} + + if path == "/api/network": + try: + if body.get("mode") == "static": + ifaces = get_interfaces() + iface = ifaces[0] if ifaces else "eth0" + apply_static_ip(iface, body["ip"], body["prefix"], body["gateway"], body["dns"]) + self.send_json({"ok": True, "ip": get_ip()}) + except Exception as e: + self.send_json({"ok": False, "error": str(e)}, 500) + + elif path == "/api/install": + global install_proc + tier = body.get("tier", "entry") + skip = body.get("skip_tools", []) + if not install_status["running"]: + t = threading.Thread(target=run_install, args=(tier, skip), daemon=True) + t.start() + self.send_json({"ok": True}) + else: + self.send_json({"ok": False, "error": "Install already running"}) + + else: + self.send_response(404) + self.end_headers() + +# ─── Main ───────────────────────────────────────────────── +if __name__ == "__main__": + # Ensure log file exists + open(INSTALL_LOG, "a").close() + ip = get_ip() + print(f"\n{'='*50}") + print(f" Cezen AI Suite — Setup Server") + print(f" Open in browser: http://{ip}") + print(f" Or: http://cezenai.local") + print(f"{'='*50}\n") + server = HTTPServer(("0.0.0.0", 80), Handler) + server.serve_forever() diff --git a/install.sh b/install.sh index b7862cf..148028d 100644 --- a/install.sh +++ b/install.sh @@ -9,13 +9,18 @@ set -e TIER="entry" PHASE="1" +SKIP_ROLES="" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ANSIBLE_DIR="$SCRIPT_DIR/ansible" +# Load saved config (written by web setup UI before phase 1) +[ -f /opt/cezen/install.conf ] && source /opt/cezen/install.conf + for arg in "$@"; do case $arg in --tier=*) TIER="${arg#*=}" ;; --phase=*) PHASE="${arg#*=}" ;; + --skip=*) SKIP_ROLES="${arg#*=}" ;; esac done @@ -101,9 +106,13 @@ run_phase2() { echo "✓ NVIDIA driver: $(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1)" fi + # Build skip_roles extra var (comma-separated list, empty string = skip nothing) + EXTRA_VARS="tier=$TIER skip_roles=\"$SKIP_ROLES\"" + echo "→ Tier: $TIER | Skip: ${SKIP_ROLES:-none}" + ANSIBLE_STDOUT_CALLBACK=yaml \ ansible-playbook -i localhost, -c local "$ANSIBLE_DIR/entry.yml" \ - -e "tier=$TIER" + -e "$EXTRA_VARS" # Disable one-shot service so it doesn't run again on next reboot systemctl disable cezen-phase2.service 2>/dev/null || true