aipackage/autoinstall/websetup/server.py

1101 lines
52 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Nexus One AI — 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, sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
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"
SCRIPT_ROOT = Path(__file__).resolve().parents[2]
SCRIPTS_DIR = Path(AIPACKAGE_DIR) / "scripts"
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR if SCRIPTS_DIR.exists() else SCRIPT_ROOT / "scripts"))
from cezen_license import build_tier_options, evaluate_license, evaluate_override, normalize_tier, read_json_file, write_json_file
install_proc = None
install_status = {"running": False, "done": False, "error": None}
FEASIBILITY_SCRIPT = f"{AIPACKAGE_DIR}/scripts/cezen-feasibility.sh"
FEASIBILITY_JSON = "/opt/cezen/feasibility.json"
LICENSE_JSON = "/opt/cezen/license.json"
OVERRIDE_JSON = "/opt/cezen/license.override.json"
INSTALL_RECORD_JSON = "/opt/cezen/install-record.json"
INSTALL_OPERATION_JSON = os.environ.get("CEZEN_SETUP_OPERATION_JSON", "/opt/cezen/setup-operation.json")
PUBLIC_KEY_PATH = os.environ.get("CEZEN_LICENSE_PUBLIC_KEY", f"{AIPACKAGE_DIR}/autoinstall/keys/cezen-license-public.pem")
def read_install_operation():
return read_json_file(INSTALL_OPERATION_JSON)
def write_install_operation(operation):
operation = dict(operation or {})
operation["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
write_json_file(INSTALL_OPERATION_JSON, operation)
try:
os.chmod(INSTALL_OPERATION_JSON, 0o640)
except OSError:
pass
return operation
def start_install_operation(tier, skip_tools, idempotency_key):
existing = read_install_operation()
if idempotency_key and existing.get("idempotency_key") == idempotency_key:
existing["duplicate_request"] = True
return existing, False
if existing.get("state") in {"validating", "running", "awaiting_reboot"}:
existing["duplicate_request"] = True
return existing, False
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
operation = {
"schema": "cezen.setup_operation.v1",
"operation_id": str(__import__("uuid").uuid4()),
"correlation_id": str(__import__("uuid").uuid4()),
"idempotency_key": idempotency_key or str(__import__("uuid").uuid4()),
"kind": "install",
"state": "validating",
"tier": tier,
"skip_tools": list(skip_tools or []),
"progress_percent": 0,
"progress_label": "Validating installation",
"acknowledged_at": now,
"updated_at": now,
"duplicate_request": False,
"customer_message": "Installation request accepted.",
"recovery_actions": [],
}
return write_install_operation(operation), True
# ─── 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 has_nvidia_gpu():
"""Detect NVIDIA PCI devices before the driver or nvidia-smi exists."""
try:
for root, _, files in os.walk("/sys/bus/pci/devices"):
if "vendor" not in files:
continue
with open(os.path.join(root, "vendor")) as f:
if f.read().strip().lower() == "0x10de":
return True
except Exception:
pass
return False
def validate_static_network(ip, prefix, gateway, dns):
ipaddress.ip_address(ip)
ipaddress.ip_address(gateway)
ipaddress.ip_address(dns)
prefix_int = int(prefix)
if prefix_int < 1 or prefix_int > 32:
raise ValueError("CIDR prefix must be between 1 and 32")
return str(prefix_int)
def is_ip_in_use(ip):
"""Best-effort conflict check before taking a static IP."""
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
except Exception:
return False
def apply_static_ip(iface, ip, prefix, gateway, dns):
prefix = validate_static_network(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 save_license_artifacts(license_data):
os.makedirs("/opt/cezen", exist_ok=True)
for target in (LICENSE_JSON, OVERRIDE_JSON):
try:
os.remove(target)
except FileNotFoundError:
pass
raw_license = (license_data.get("license_json") or "").strip()
raw_override = (license_data.get("override_json") or "").strip()
if raw_license:
payload = json.loads(raw_license)
write_json_file(LICENSE_JSON, payload)
subprocess.run(["chown", "root:cezen", LICENSE_JSON], check=False)
os.chmod(LICENSE_JSON, 0o640)
if raw_override:
payload = json.loads(raw_override)
write_json_file(OVERRIDE_JSON, payload)
subprocess.run(["chown", "root:cezen", OVERRIDE_JSON], check=False)
os.chmod(OVERRIDE_JSON, 0o640)
def ensure_feasibility():
os.makedirs("/opt/cezen", exist_ok=True)
if os.path.exists(FEASIBILITY_SCRIPT):
subprocess.run(["bash", FEASIBILITY_SCRIPT, FEASIBILITY_JSON], check=False)
return read_json_file(FEASIBILITY_JSON)
def evaluate_install_constraints(license_data):
save_license_artifacts(license_data or {})
feasibility = ensure_feasibility()
hardware_tier = normalize_tier(((feasibility.get("recommendation") or {}).get("recommended_tier")), "basic")
license_record = read_json_file(LICENSE_JSON)
override_record = read_json_file(OVERRIDE_JSON)
license_eval = evaluate_license(license_record, PUBLIC_KEY_PATH)
override_eval = evaluate_override(override_record, PUBLIC_KEY_PATH)
return {
"license": license_eval,
"override": override_eval,
"hardware": {
"recommended_tier": hardware_tier,
"recommended_profile": (feasibility.get("recommendation") or {}).get("recommended_profile", ""),
"estimated_concurrent_users": (feasibility.get("recommendation") or {}).get("estimated_concurrent_users", ""),
"notes": (feasibility.get("recommendation") or {}).get("notes", []),
},
"tier_options": build_tier_options(license_eval, hardware_tier, override_eval),
}
def write_install_record(license_data, tier, skip_tools, preview):
payload = {
"schema": "cezen.install_record.v1",
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"customer_name": (license_data.get("customer_name") or "").strip(),
"customer_id": (license_data.get("customer_id") or "").strip(),
"contact_email": (license_data.get("contact_email") or "").strip(),
"support_until": (license_data.get("support_until") or "").strip(),
"selected_tier": tier,
"provisioned_tier": tier,
"licensed_tier": ((preview.get("license") or {}).get("allowed_tier") or "basic"),
"license_status": ((preview.get("license") or {}).get("status") or "missing"),
"hardware_recommended_tier": ((preview.get("hardware") or {}).get("recommended_tier") or "starter"),
"hardware_recommended_profile": ((preview.get("hardware") or {}).get("recommended_profile") or "core"),
"install_type": ((preview.get("license") or {}).get("install_type") or "field-staging"),
"override_active": bool((preview.get("override") or {}).get("allow_hardware_mismatch")),
"skip_roles": skip_tools or [],
"components": {k: k not in set(skip_tools or []) for k in ["ollama", "jupyterlab", "chromadb", "vllm", "mlflow", "minio", "monitoring", "k3s"]},
}
write_json_file(INSTALL_RECORD_JSON, payload)
subprocess.run(["chown", "root:cezen", INSTALL_RECORD_JSON], check=False)
os.chmod(INSTALL_RECORD_JSON, 0o640)
def run_install(tier, skip_tools, license_data=None, preview=None, operation_id=""):
global install_status
install_status = {"running": True, "done": False, "error": None}
try:
operation = read_install_operation()
if operation.get("operation_id") == operation_id:
operation.update({"state": "running", "progress_percent": 5, "progress_label": "Preparing appliance"})
write_install_operation(operation)
# 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 ""
save_license_artifacts(license_data or {})
write_install_record(license_data or {}, tier, skip_tools or [], preview or {})
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()
# Fresh NVIDIA servers do not have nvidia-smi yet, so detect the PCI
# device and run phase 1 to install drivers before the AI stack.
phase = "1" if has_nvidia_gpu() else "2"
if operation.get("operation_id") == operation_id and phase == "1":
operation.update({"state": "awaiting_reboot", "progress_percent": 40, "progress_label": "Installing drivers before reboot"})
write_install_operation(operation)
cmd = ["bash", f"{AIPACKAGE_DIR}/install.sh", f"--phase={phase}", 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}
operation = read_install_operation()
if operation.get("operation_id") == operation_id:
operation.update({"state": "succeeded", "progress_percent": 100, "progress_label": "Installation command completed", "customer_message": "Installation completed successfully."})
write_install_operation(operation)
except Exception as e:
install_status = {"running": False, "done": False, "error": str(e)}
operation = read_install_operation()
if operation.get("operation_id") == operation_id:
operation.update({"state": "recovery_required", "progress_label": "Installation needs attention", "result_code": "install_failed", "customer_message": "Installation did not complete. Review the local log and retry or contact support.", "recovery_actions": ["retry", "view_log", "escalate"]})
write_install_operation(operation)
# ─── HTML UI ──────────────────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus One AI — Server Setup</title>
<style>
:root {
--navy: #1B2A4A;
--navy2: #223A5E;
--teal: #0D9488;
--teal2: #14B8A6;
--white: #ffffff;
--off: #F1F5F9;
--text: #1E293B;
--muted: #64748B;
--red: #EF4444;
--green: #10B981;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--off); color: var(--text); min-height: 100vh; }
header { background: var(--navy); padding: 18px 32px; display: flex; align-items: center; gap: 16px; }
header .logo { color: var(--teal2); font-size: 22px; font-weight: 700; letter-spacing: 1px; }
header .sub { color: #94A3B8; font-size: 13px; }
.container { max-width: 860px; margin: 40px auto; padding: 0 20px; }
/* Steps */
.steps { display: flex; gap: 0; margin-bottom: 36px; }
.step { flex: 1; text-align: center; padding: 10px; font-size: 13px; color: var(--muted);
border-bottom: 3px solid #CBD5E1; position: relative; }
.step.active { color: var(--teal); border-color: var(--teal); font-weight: 600; }
.step.done { color: var(--navy); border-color: var(--navy2); }
/* Cards */
.card { background: white; border-radius: 12px; padding: 28px 32px; margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.card h2 { font-size: 18px; color: var(--navy); margin-bottom: 6px; }
.card p.desc { font-size: 14px; color: var(--muted); margin-bottom: 24px; }
/* Tier cards */
.tier-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.tier-card { border: 2px solid #E2E8F0; border-radius: 10px; padding: 20px 16px; cursor: pointer;
transition: all .2s; text-align: center; }
.tier-card:hover { border-color: var(--teal2); background: #F0FDFA; }
.tier-card.selected { border-color: var(--teal); background: #CCFBF1; }
.tier-card.disabled { cursor: not-allowed; opacity: .58; background: #F8FAFC; }
.tier-card.disabled:hover { border-color: #E2E8F0; background: #F8FAFC; }
.tier-card.override { border-style: dashed; }
.tier-card .tier-name { font-size: 16px; font-weight: 700; color: var(--navy); margin-bottom: 4px; }
.tier-card .tier-gpu { font-size: 12px; color: var(--teal); font-weight: 600; margin-bottom: 8px; }
.tier-card .tier-users { font-size: 12px; color: var(--muted); }
.tier-card .tier-note { font-size: 11px; color: var(--red); margin-top: 8px; min-height: 28px; }
/* Tool toggles */
.tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.tool-item { display: flex; align-items: center; gap: 12px; padding: 14px 16px;
border: 1.5px solid #E2E8F0; border-radius: 8px; cursor: pointer; transition: all .15s; }
.tool-item:hover { border-color: var(--teal2); background: #F0FDFA; }
.tool-item.selected { border-color: var(--teal); background: #CCFBF1; }
.tool-item .tool-icon { width: 36px; height: 36px; border-radius: 8px; background: var(--navy);
display: flex; align-items: center; justify-content: center;
color: var(--teal2); font-size: 16px; flex-shrink: 0; }
.tool-item .tool-name { font-size: 14px; font-weight: 600; color: var(--navy); }
.tool-item .tool-desc { font-size: 12px; color: var(--muted); }
.tool-toggle { margin-left: auto; width: 40px; height: 22px; border-radius: 11px;
background: #CBD5E1; position: relative; flex-shrink: 0; transition: background .2s; }
.tool-item.selected .tool-toggle { background: var(--teal); }
.tool-toggle::after { content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
border-radius: 50%; background: white; transition: left .2s; }
.tool-item.selected .tool-toggle::after { left: 21px; }
/* Network */
.net-mode { display: flex; gap: 12px; margin-bottom: 20px; }
.net-btn { flex: 1; padding: 14px; border: 2px solid #E2E8F0; border-radius: 8px; cursor: pointer;
text-align: center; font-size: 14px; font-weight: 600; color: var(--muted); background: white; transition: all .15s; }
.net-btn.active { border-color: var(--teal); color: var(--teal); background: #F0FDFA; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 600; color: var(--navy); margin-bottom: 6px; }
.form-group input, .form-group textarea { width: 100%; padding: 10px 14px; border: 1.5px solid #CBD5E1; border-radius: 8px;
font-size: 14px; outline: none; transition: border .15s; }
.form-group textarea { min-height: 110px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; }
.form-group input:focus, .form-group textarea:focus { border-color: var(--teal); }
.static-fields { display: none; }
.static-fields.show { display: block; }
.ip-row { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
/* Buttons */
.btn-row { display: flex; gap: 12px; justify-content: flex-end; margin-top: 28px; }
.btn { padding: 12px 28px; border-radius: 8px; font-size: 15px; font-weight: 600;
cursor: pointer; border: none; transition: all .15s; }
.btn-primary { background: var(--teal); color: white; }
.btn-primary:hover { background: var(--teal2); }
.btn-secondary { background: var(--off); color: var(--navy); border: 1.5px solid #CBD5E1; }
.btn-secondary:hover { background: #E2E8F0; }
/* Progress */
.progress-wrap { display: none; }
.progress-wrap.show { display: block; }
.progress-bar-bg { background: #E2E8F0; border-radius: 99px; height: 10px; margin: 16px 0; overflow: hidden; }
.progress-bar { height: 10px; border-radius: 99px; background: linear-gradient(90deg, var(--teal), var(--teal2));
width: 0%; transition: width .5s; }
.log-box { background: #0F172A; color: #94A3B8; font-family: monospace; font-size: 12px;
border-radius: 8px; padding: 16px; height: 280px; overflow-y: auto; line-height: 1.6; }
.log-line.ok { color: #34D399; }
.log-line.fail { color: #F87171; }
/* Summary */
.summary-row { display: flex; justify-content: space-between; padding: 10px 0;
border-bottom: 1px solid #F1F5F9; font-size: 14px; }
.summary-row:last-child { border: none; }
.summary-row .key { color: var(--muted); }
.summary-row .val { font-weight: 600; color: var(--navy); }
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: 12px;
font-weight: 600; background: #CCFBF1; color: var(--teal); }
.alert { padding: 12px 16px; border-radius: 8px; font-size: 13px; margin-bottom: 16px; }
.alert-info { background: #EFF6FF; color: #1D4ED8; border: 1px solid #BFDBFE; }
.alert-success { background: #ECFDF5; color: #065F46; border: 1px solid #A7F3D0; }
.alert-error { background: #FEF2F2; color: #991B1B; border: 1px solid #FECACA; }
.alert-warning { background: #FFFBEB; color: #92400E; border: 1px solid #FDE68A; }
.hidden { display: none !important; }
#done-screen { text-align: center; padding: 48px 0; }
#done-screen .done-icon { font-size: 64px; margin-bottom: 16px; }
#done-screen h2 { font-size: 24px; color: var(--navy); margin-bottom: 8px; }
#done-screen p { color: var(--muted); font-size: 15px; }
#done-screen .services { margin: 28px auto; max-width: 400px; text-align: left; }
</style>
</head>
<body>
<div id="wizard-banner" class="alert hidden" style="position:fixed;top:20px;right:20px;max-width:380px;z-index:1000;box-shadow:0 8px 24px rgba(0,0,0,.12)"></div>
<header>
<div>
<div class="logo">NEXUS ONE AI</div>
<div class="sub">Powered by Cezen &nbsp;·&nbsp; Server Setup Wizard</div>
</div>
</header>
<div class="container">
<div class="steps" id="step-bar">
<div class="step active" id="sbar-1">1 · Network</div>
<div class="step" id="sbar-2">2 · License</div>
<div class="step" id="sbar-3">3 · Select Tier</div>
<div class="step" id="sbar-4">4 · AI Tools</div>
<div class="step" id="sbar-5">5 · Install</div>
</div>
<!-- ── STEP 1: NETWORK ── -->
<div id="step-1">
<div class="card">
<h2>Network Configuration</h2>
<p class="desc">Choose how this server gets its IP address. You can change this later.</p>
<div class="net-mode">
<button class="net-btn active" id="btn-dhcp" onclick="setNetMode('dhcp')">
⚡ DHCP (Automatic)
</button>
<button class="net-btn" id="btn-static" onclick="setNetMode('static')">
📌 Static IP (Manual)
</button>
</div>
<div id="dhcp-info" class="alert alert-info">
Server will get an IP automatically from your network. Current IP: <strong id="current-ip">detecting...</strong>
</div>
<div class="static-fields" id="static-fields">
<div class="alert alert-info">
We will ping the IP before applying it. If it replies, we will block the change to avoid taking an address that is already in use.
</div>
<div class="ip-row">
<div class="form-group">
<label>IP Address</label>
<input type="text" id="ip-addr" placeholder="e.g. 192.168.1.100">
</div>
<div class="form-group">
<label>Prefix (CIDR)</label>
<input type="text" id="ip-prefix" placeholder="24" value="24">
</div>
</div>
<div class="form-group">
<label>Default Gateway</label>
<input type="text" id="ip-gateway" placeholder="e.g. 192.168.1.1">
</div>
<div class="form-group">
<label>DNS Server</label>
<input type="text" id="ip-dns" placeholder="e.g. 8.8.8.8" value="8.8.8.8">
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="goStep(2)">Next →</button>
</div>
</div>
<!-- ── STEP 2: LICENSE ── -->
<div id="step-2" class="hidden">
<div class="card">
<h2>License & Customer Details</h2>
<p class="desc">Enter the local customer record and paste the signed license JSON. Leave license blank for field staging.</p>
<div class="form-group">
<label>Customer / Organisation Name</label>
<input type="text" id="lic-customer-name" placeholder="e.g. Bharat Electronics Limited">
</div>
<div class="form-group">
<label>Customer ID / Project Code</label>
<input type="text" id="lic-customer-id" placeholder="e.g. BEL-PSU-2026">
</div>
<div class="form-group">
<label>Contact Email</label>
<input type="email" id="lic-contact-email" placeholder="admin@example.gov.in">
</div>
<div class="form-group">
<label>Signed License JSON</label>
<textarea id="lic-license-json" placeholder='Paste the signed cezen.license.v2 JSON here'></textarea>
</div>
<div class="form-group">
<label>Cezen Override JSON</label>
<textarea id="lic-override-json" placeholder='Optional: signed cezen.override.v1 JSON for hardware mismatch'></textarea>
</div>
<div class="form-group">
<label>Support Until</label>
<input type="date" id="lic-support-until">
</div>
<div class="form-group">
<label>License Preview</label>
<div id="license-preview" style="padding:12px 14px;border:1px solid #CBD5E1;border-radius:8px;background:#F8FAFC;color:var(--muted);font-size:13px">No license evaluated yet.</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(1)">← Back</button>
<button class="btn btn-primary" onclick="prepareTierStep()">Next →</button>
</div>
</div>
<!-- ── STEP 3: TIER ── -->
<div id="step-3" class="hidden">
<div class="card">
<h2>Select AI Package Tier</h2>
<p class="desc">Choose the tier that matches your GPU hardware.</p>
<div class="tier-grid">
<div class="tier-card" id="tier-starter" onclick="selectTier('starter')">
<div class="tier-name">Server S</div>
<div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div>
<div class="tier-users">Small team deployment</div>
<div class="tier-note" id="tier-note-starter"></div>
</div>
<div class="tier-card" id="tier-basic" onclick="selectTier('basic')">
<div class="tier-name">Server M</div>
<div class="tier-gpu">1× NVIDIA RTX Pro 6000 (96GB)</div>
<div class="tier-users">Up to 20 concurrent users</div>
<div class="tier-note" id="tier-note-basic"></div>
</div>
<div class="tier-card" id="tier-pro" onclick="selectTier('pro')">
<div class="tier-name">Server L</div>
<div class="tier-gpu">2× RTX 5090 / RTX Pro class</div>
<div class="tier-users">Up to 100 concurrent users</div>
<div class="tier-note" id="tier-note-pro"></div>
</div>
<div class="tier-card" id="tier-max" onclick="selectTier('max')">
<div class="tier-name">Server Max</div>
<div class="tier-gpu">48× H100/H200/A100 class</div>
<div class="tier-users">200+ concurrent users</div>
<div class="tier-note" id="tier-note-max"></div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(2)">← Back</button>
<button class="btn btn-primary" onclick="goStep(4)">Next →</button>
</div>
</div>
<!-- ── STEP 4: TOOLS ── -->
<div id="step-4" class="hidden">
<div class="card">
<h2>Select AI Tools</h2>
<p class="desc">Toggle the components you want installed. Recommended defaults are pre-selected.</p>
<div class="tool-grid" id="tool-grid">
<!-- injected by JS -->
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(3)">← Back</button>
<button class="btn btn-primary" onclick="goStep(5)">Review & Install →</button>
</div>
</div>
<!-- ── STEP 5: CONFIRM + INSTALL ── -->
<div id="step-5" class="hidden">
<div class="card" id="summary-card">
<h2>Review Configuration</h2>
<p class="desc">Confirm your settings before installation begins.</p>
<div id="summary-rows"></div>
<div class="alert alert-info" style="margin-top:20px">
⏱ Installation takes <strong>2040 minutes</strong>. The server will reboot once to load NVIDIA drivers, then continue automatically.
</div>
</div>
<div class="progress-wrap" id="progress-wrap">
<div class="card">
<h2>Installing Nexus One AI...</h2>
<div class="progress-bar-bg"><div class="progress-bar" id="progress-bar"></div></div>
<p id="progress-label" style="font-size:13px;color:var(--muted);margin-bottom:12px">Starting...</p>
<div class="log-box" id="log-box"></div>
</div>
</div>
<div id="done-screen" class="hidden">
<div class="done-icon">&#10003;</div>
<h2>Installation Complete!</h2>
<p>Your Nexus One AI is ready.</p>
<div class="services card" style="margin-top:24px;text-align:left">
<div class="summary-row"><span class="key">Portal</span><span class="val badge">http://localhost</span></div>
<div class="summary-row"><span class="key">Open WebUI</span><span class="val badge">:3001</span></div>
<div class="summary-row"><span class="key">JupyterLab</span><span class="val badge">:8888</span></div>
<div class="summary-row"><span class="key">MLflow</span><span class="val badge">:5000</span></div>
<div class="summary-row"><span class="key">MinIO</span><span class="val badge">:9000</span></div>
<div class="summary-row"><span class="key">Grafana</span><span class="val badge">:3000</span></div>
</div>
<div class="alert alert-info" style="max-width:420px;margin:20px auto 0;text-align:left">
<strong>Admin login:</strong> admin / Cezen@2024!<br>
You will be required to change this password on first login.
</div>
</div>
<div class="btn-row" id="install-btn-row">
<button class="btn btn-secondary" onclick="goStep(4)">← Back</button>
<button class="btn btn-primary" id="install-btn" onclick="startInstall()">Start Installation</button>
</div>
</div>
</div><!-- /container -->
<script>
// ── Shared banner (replaces native alert() for validation/errors) ──
let _bannerTimer = null;
function showBanner(msg, type) {
type = type || 'error';
const el = document.getElementById('wizard-banner');
el.className = 'alert alert-' + type;
el.textContent = msg;
el.classList.remove('hidden');
clearTimeout(_bannerTimer);
_bannerTimer = setTimeout(() => el.classList.add('hidden'), 6000);
}
// ── State ──────────────────────────────────────────────────
let netMode = 'dhcp';
let selectedTier = 'basic';
let tierPreview = null;
const TIER_LABELS = { starter: 'Server S', basic: 'Server M', pro: 'Server L', max: 'Server Max' };
function tierLabel(slug) { return TIER_LABELS[slug] || (slug ? slug.charAt(0).toUpperCase()+slug.slice(1) : 'Unknown'); }
// Raw codes from cezen_license.evaluate_license() (valid/missing/expired/
// invalid_signature/not_yet_valid/machine_mismatch) aren't meant for an
// end-user installer screen — translate them the same way the portal's
// /api/health does, so a customer never sees a bare internal status code.
const LICENSE_STATUS_LABELS = {
valid: 'Valid',
missing: 'No license installed (field-staging mode)',
expired: 'Expired — contact support@cezentech.com',
invalid_signature: 'Invalid signature — re-upload a valid signed license',
not_yet_valid: 'Not yet in its valid date range',
machine_mismatch: 'Bound to different hardware — contact support@cezentech.com',
};
function licenseStatusLabel(status) { return LICENSE_STATUS_LABELS[status] || (status || 'Unknown'); }
let tools = {
ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true },
jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true },
chromadb: { name: 'ChromaDB', desc: 'Vector DB for RAG', icon: '🗄', on: true },
vllm: { name: 'vLLM', desc: 'OpenAI-compatible API', icon: '', on: true },
mlflow: { name: 'MLflow', desc: 'Experiment tracking', icon: '📊', on: true },
minio: { name: 'MinIO', desc: 'S3-compatible storage', icon: '🪣', on: true },
monitoring: { name: 'Grafana + Prometheus',desc: 'GPU & system monitoring', icon: '📈', on: true },
k3s: { name: 'K3s', desc: 'Lightweight Kubernetes', icon: '', on: true },
};
// ── Init ───────────────────────────────────────────────────
window.onload = () => {
fetch('/api/status').then(r=>r.json()).then(d=>{
document.getElementById('current-ip').textContent = d.ip || 'unknown';
if (d.operation && ['validating','running','awaiting_reboot','recovery_required'].includes(d.operation.state)) resumeOperation(d.operation);
});
renderTools();
renderTierAvailability(null);
};
// ── Navigation ─────────────────────────────────────────────
function goStep(n) {
if (n === 4 && !selectedTier) { showBanner('Please select a tier before continuing.', 'error'); return; }
[1,2,3,4,5].forEach(i => {
document.getElementById('step-'+i).classList.toggle('hidden', i !== n);
const bar = document.getElementById('sbar-'+i);
bar.classList.remove('active','done');
if (i === n) bar.classList.add('active');
if (i < n) bar.classList.add('done');
});
if (n === 5) renderSummary();
if (n === 2 && netMode === 'static') applyStaticIP();
}
// ── Network ────────────────────────────────────────────────
function setNetMode(m) {
netMode = m;
document.getElementById('btn-dhcp').classList.toggle('active', m==='dhcp');
document.getElementById('btn-static').classList.toggle('active', m==='static');
document.getElementById('dhcp-info').style.display = m==='dhcp' ? 'block' : 'none';
document.getElementById('static-fields').classList.toggle('show', m==='static');
}
function applyStaticIP() {
const body = {
mode: 'static',
ip: document.getElementById('ip-addr').value,
prefix: document.getElementById('ip-prefix').value,
gateway: document.getElementById('ip-gateway').value,
dns: document.getElementById('ip-dns').value,
};
fetch('/api/network', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) })
.then(r=>r.json()).then(d=>{
if(!d.ok) {
showBanner('Network config failed: ' + d.error, 'error');
return;
}
if (d.warning) {
showBanner(d.warning, 'warning');
}
});
}
// ── Tier ───────────────────────────────────────────────────
function renderTierAvailability(preview) {
tierPreview = preview;
const options = {};
(preview?.tier_options || []).forEach(opt => options[opt.tier] = opt);
['starter','basic','pro','max'].forEach(tier => {
const card = document.getElementById('tier-' + tier);
const note = document.getElementById('tier-note-' + tier);
const meta = options[tier] || { state: 'disabled_by_license', reason: 'Preview license to continue.', selectable: false };
card.classList.toggle('disabled', !meta.selectable);
card.classList.toggle('override', meta.state === 'override_required');
if (!meta.selectable && selectedTier === tier) selectedTier = '';
note.textContent = meta.reason || (meta.state === 'override_required' ? 'Override active for hardware mismatch.' : '');
});
const fallback = (preview?.tier_options || []).find(opt => opt.selectable);
if (fallback && (!selectedTier || !(options[selectedTier] || {}).selectable)) {
selectTier(fallback.tier);
}
}
function selectTier(t) {
const opt = (tierPreview?.tier_options || []).find(x => x.tier === t);
if (opt && !opt.selectable) return;
selectedTier = t;
['starter','basic','pro','max'].forEach(x =>
document.getElementById('tier-'+x).classList.toggle('selected', x===t));
}
// ── Tools ──────────────────────────────────────────────────
function renderTools() {
const g = document.getElementById('tool-grid');
g.innerHTML = Object.entries(tools).map(([k,v]) => `
<div class="tool-item ${v.on?'selected':''}" id="tool-${k}" onclick="toggleTool('${k}')">
<div class="tool-icon">${v.icon}</div>
<div>
<div class="tool-name">${v.name}</div>
<div class="tool-desc">${v.desc}</div>
</div>
<div class="tool-toggle"></div>
</div>`).join('');
}
function toggleTool(k) {
tools[k].on = !tools[k].on;
document.getElementById('tool-'+k).classList.toggle('selected', tools[k].on);
}
// ── License ────────────────────────────────────────────────
function collectLicense() {
return {
customer_name: document.getElementById('lic-customer-name').value.trim(),
customer_id: document.getElementById('lic-customer-id').value.trim(),
contact_email: document.getElementById('lic-contact-email').value.trim(),
license_json: document.getElementById('lic-license-json').value.trim(),
override_json: document.getElementById('lic-override-json').value.trim(),
support_until: document.getElementById('lic-support-until').value.trim(),
install_type: document.getElementById('lic-license-json').value.trim() ? 'licensed' : 'field-staging'
};
}
async function prepareTierStep() {
try {
const res = await api('/api/license-preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ license: collectLicense() })
});
renderTierAvailability(res);
document.getElementById('license-preview').innerHTML = `
<strong>Status:</strong> ${esc(licenseStatusLabel(res.license?.status || 'missing'))}<br>
<strong>Allowed tier:</strong> ${esc(res.license?.allowed_tier || 'basic')}<br>
<strong>Hardware recommendation:</strong> ${esc(res.hardware?.recommended_tier || 'starter')}
`;
goStep(3);
} catch (err) {
showBanner('License preview failed: ' + err.message, 'error');
}
}
// ── Summary ────────────────────────────────────────────────
function renderSummary() {
const ip = netMode === 'dhcp'
? document.getElementById('current-ip').textContent + ' (DHCP)'
: document.getElementById('ip-addr').value + '/' + document.getElementById('ip-prefix').value;
const license = collectLicense();
const onTools = Object.entries(tools).filter(([,v])=>v.on).map(([,v])=>v.name).join(', ');
const offTools = Object.entries(tools).filter(([,v])=>!v.on);
document.getElementById('summary-rows').innerHTML = `
<div class="summary-row"><span class="key">Network</span><span class="val">${ip}</span></div>
<div class="summary-row"><span class="key">Customer</span><span class="val">${license.customer_name || 'Not entered'}</span></div>
<div class="summary-row"><span class="key">License</span><span class="val">${license.license_json ? licenseStatusLabel(tierPreview?.license?.status || 'missing') : 'Field staging / evaluation'}</span></div>
<div class="summary-row"><span class="key">Allowed Tier</span><span class="val">${tierLabel(tierPreview?.license?.allowed_tier || 'basic')}</span></div>
<div class="summary-row"><span class="key">Hardware Tier</span><span class="val">${tierLabel(tierPreview?.hardware?.recommended_tier || 'starter')}</span></div>
<div class="summary-row"><span class="key">Tier</span><span class="val">${tierLabel(selectedTier)}</span></div>
<div class="summary-row"><span class="key">Tools</span><span class="val" style="font-size:13px;text-align:right;max-width:60%">${onTools}</span></div>
${offTools.length ? `<div class="summary-row"><span class="key">Skipped</span><span class="val" style="color:var(--muted);font-size:13px">${offTools.map(([,v])=>v.name).join(', ')}</span></div>` : ''}
`;
}
// ── Install ────────────────────────────────────────────────
function startInstall() {
document.getElementById('install-btn-row').classList.add('hidden');
document.getElementById('summary-card').classList.add('hidden');
document.getElementById('progress-wrap').classList.add('show');
const skip = Object.entries(tools).filter(([,v])=>!v.on).map(([k])=>k);
fetch('/api/install', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ tier: selectedTier, skip_tools: skip, license: collectLicense(), idempotency_key: sessionStorage.getItem('cezen-install-key') || (() => { const k = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now())); sessionStorage.setItem('cezen-install-key', k); return k; })() })
}).then(async r => {
const data = await r.json();
if (!r.ok || !data.ok) throw new Error(data.error || 'Install start failed');
window.currentOperationId = data.operation_id;
}).catch(err => {
showBanner(err.message, 'error');
document.getElementById('install-btn-row').classList.remove('hidden');
document.getElementById('summary-card').classList.remove('hidden');
document.getElementById('progress-wrap').classList.remove('show');
});
streamLog();
}
function resumeOperation(operation) {
window.currentOperationId = operation.operation_id;
document.getElementById('install-btn-row').classList.add('hidden');
document.getElementById('summary-card').classList.add('hidden');
document.getElementById('progress-wrap').classList.add('show');
document.getElementById('progress-label').textContent = operation.progress_label || 'Resuming installation status…';
document.getElementById('progress-bar').style.width = (operation.progress_percent || 0) + '%';
if (operation.state === 'recovery_required') showBanner(operation.customer_message || 'Installation needs attention. Retry or contact support.', 'error');
else streamLog();
}
function streamLog() {
const log = document.getElementById('log-box');
const bar = document.getElementById('progress-bar');
const lbl = document.getElementById('progress-label');
const steps = ['base','nvidia','docker','k3s','ollama','vllm','jupyterlab','chromadb','mlflow','minio','monitoring'];
let stepIdx = 0;
let installDone = false;
const es = new EventSource('/api/progress');
es.onmessage = e => {
const line = e.data;
const div = document.createElement('div');
div.className = 'log-line' + (line.includes('ok:') || line.includes('changed:') ? ' ok' : line.includes('fatal:') || line.includes('FAILED') ? ' fail' : '');
div.textContent = line;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
steps.forEach((s,i) => {
if (line.toLowerCase().includes(s) && i >= stepIdx) {
stepIdx = i;
bar.style.width = Math.round((i+1)/steps.length * 90) + '%';
lbl.textContent = 'Installing ' + s + '...';
}
});
if (line.includes('Rebooting') || line.includes('reboot')) {
installDone = true;
bar.style.width = '45%';
lbl.textContent = 'Server is rebooting — Phase 2 installs automatically...';
es.close();
showRebootNotice();
}
if (line.includes('INSTALL COMPLETE') || line.includes('installation complete')) {
installDone = true;
bar.style.width = '100%';
lbl.textContent = 'Complete!';
es.close();
setTimeout(() => {
document.getElementById('progress-wrap').style.display = 'none';
document.getElementById('done-screen').classList.remove('hidden');
}, 2000);
}
if (line.includes('FAILED') && line.includes('PLAY RECAP')) {
es.close();
lbl.textContent = 'Error — check log above';
lbl.style.color = 'var(--red)';
}
};
let reconnectAttempts = 0;
es.onerror = () => {
es.close();
if (installDone) return;
reconnectAttempts++;
lbl.textContent = `Connection lost — reconnecting... (${reconnectAttempts})`;
if (reconnectAttempts >= 5) {
// After 5 failed reconnects assume it's a real reboot
showRebootNotice();
} else {
// Try reconnecting after a delay
setTimeout(() => { if (!installDone) streamLog(); }, 4000);
}
};
}
function showRebootNotice() {
document.getElementById('progress-wrap').style.display = 'none';
document.getElementById('done-screen').classList.remove('hidden');
document.getElementById('done-screen').innerHTML = `
<div class="done-icon">&#8635;</div>
<h2>Server is Rebooting</h2>
<p style="margin-bottom:16px">NVIDIA drivers installed. Phase 2 (AI stack) is installing automatically after reboot.</p>
<div class="alert alert-info" style="max-width:500px;margin:0 auto 24px">
Phase 2 takes <strong>2030 more minutes</strong>. You can monitor it via:<br>
<code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">ssh cezen@&lt;server-ip&gt;</code>
then
<code style="background:#E0F2FE;padding:2px 6px;border-radius:4px">journalctl -fu cezen-phase2</code>
</div>
<div class="services card" style="margin:0 auto;max-width:400px;text-align:left">
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Services will be available at:</p>
<div class="summary-row"><span class="key">Portal</span><span class="val badge">http://localhost</span></div>
<div class="summary-row"><span class="key">Open WebUI</span><span class="val badge">:3001</span></div>
<div class="summary-row"><span class="key">JupyterLab</span><span class="val badge">:8888</span></div>
<div class="summary-row"><span class="key">MLflow</span><span class="val badge">:5000</span></div>
<div class="summary-row"><span class="key">MinIO</span><span class="val badge">:9000</span></div>
<div class="summary-row"><span class="key">Grafana</span><span class="val badge">:3000</span></div>
</div>
<p style="font-size:13px;color:var(--muted);margin-top:16px">Admin login (admin / Cezen@2024!, change required on first login) will be ready once Phase 2 completes.</p>
`;
}
</script>
</body>
</html>
"""
# ─── 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,
"operation": read_install_operation(),
})
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"
validate_static_network(body["ip"], body["prefix"], body["gateway"], body["dns"])
if is_ip_in_use(body["ip"]):
self.send_json({
"ok": False,
"error": f"IP address {body['ip']} is already replying to ping. Choose a different address."
}, 409)
return
apply_static_ip(iface, body["ip"], body["prefix"], body["gateway"], body["dns"])
self.send_json({
"ok": True,
"ip": get_ip(),
"warning": "Ping check passed before applying the static IP. Note: a non-reply does not guarantee the address is unused if another host blocks ICMP."
})
return
self.send_json({"ok": True, "ip": get_ip()})
except Exception as e:
self.send_json({"ok": False, "error": str(e)}, 500)
elif path == "/api/license-preview":
try:
preview = evaluate_install_constraints(body.get("license", {}) or {})
self.send_json(preview)
except Exception as e:
self.send_json({"ok": False, "error": str(e)}, 400)
elif path == "/api/install":
global install_proc
tier = body.get("tier", "basic")
skip = body.get("skip_tools", [])
license_data = body.get("license", {})
idempotency_key = str(body.get("idempotency_key") or "").strip()
try:
preview = evaluate_install_constraints(license_data or {})
except Exception as e:
self.send_json({"ok": False, "error": f"License validation failed: {e}"}, 400)
return
selected = next((opt for opt in preview.get("tier_options", []) if opt.get("tier") == tier), None)
if not selected or not selected.get("selectable"):
self.send_json({"ok": False, "error": f"Tier '{tier}' is not allowed for this license/hardware."}, 400)
return
operation, created = start_install_operation(tier, skip, idempotency_key)
if created and not install_status["running"]:
t = threading.Thread(target=run_install, args=(tier, skip, license_data, preview, operation["operation_id"]), daemon=True)
t.start()
self.send_json({"ok": True, "operation_id": operation["operation_id"], "acknowledged_at": operation["acknowledged_at"], "duplicate_request": False})
else:
self.send_json({"ok": True, "operation_id": operation.get("operation_id"), "acknowledged_at": operation.get("acknowledged_at"), "duplicate_request": True})
else:
self.send_response(404)
self.end_headers()
# ─── Main ─────────────────────────────────────────────────
def show_console_banner(ip):
"""Write the setup URL banner to /dev/tty1 so it appears on the physical console."""
banner = f"""
\033[1;36m╔══════════════════════════════════════════════════════╗
║ ║
║ NEXUS ONE AI — SERVER SETUP ║
║ ║
║ Open a browser on any computer on this network: ║
║ ║
\033[1;33m➜ http://{ip:<42}\033[1;36m║
\033[1;33m➜ http://cezenai.local\033[1;36m ║
║ ║
║ Complete setup from your browser — no keyboard ║
║ input needed here. ║
║ ║
╚══════════════════════════════════════════════════════╝\033[0m
"""
# Write to tty1 (physical console) and stdout (journalctl)
print(banner)
try:
with open("/dev/tty1", "w") as tty:
tty.write(banner)
except Exception:
pass # tty1 may not be accessible in all environments
# Also update /etc/issue so the URL appears above the login prompt
try:
with open("/etc/issue", "w") as f:
f.write(f"Ubuntu 22.04.5 LTS \\n \\l\n\n")
f.write(f" \033[1;36mNexus One AI Setup:\033[0m http://{ip} | http://cezenai.local\n\n")
except Exception:
pass
if __name__ == "__main__":
# Ensure log file exists
open(INSTALL_LOG, "a").close()
ip = get_ip()
show_console_banner(ip)
server = HTTPServer(("0.0.0.0", 80), Handler)
server.serve_forever()