aipackage/autoinstall/firstboot-setup.sh

504 lines
22 KiB
Bash
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 bash
# ─────────────────────────────────────────────────────────────
# Nexus One AI — First Boot Setup Wizard
# Runs on first boot after OS install via systemd service.
# Uses whiptail for the TUI.
# ─────────────────────────────────────────────────────────────
set -e
AIPACKAGE_DIR="/opt/aipackage"
FEASIBILITY_SCRIPT="$AIPACKAGE_DIR/scripts/cezen-feasibility.sh"
LICENSE_CHECK_SCRIPT="$AIPACKAGE_DIR/scripts/cezen-license-check.py"
PUBLIC_KEY_PATH="${CEZEN_LICENSE_PUBLIC_KEY:-$AIPACKAGE_DIR/autoinstall/keys/cezen-license-public.pem}"
LOG_FILE="/var/log/cezen-setup.log"
INSTALL_LOG_FILE="/var/log/cezen-install.log"
SETUP_OPERATION_FILE="/opt/cezen/setup-operation.json"
export TERM="${TERM:-linux}"
touch "$LOG_FILE" "$INSTALL_LOG_FILE"
chmod 0644 "$LOG_FILE" "$INSTALL_LOG_FILE"
write_setup_operation() {
local state="$1" progress="$2" label="$3" message="$4"
mkdir -p /opt/cezen
STATE="$state" PROGRESS="$progress" LABEL="$label" MESSAGE="$message" SETUP_OPERATION_FILE="$SETUP_OPERATION_FILE" python3 - <<'PY'
import json, os, time, uuid
path = os.environ["SETUP_OPERATION_FILE"]
try:
with open(path) as fh:
payload = json.load(fh)
except Exception:
payload = {
"schema": "cezen.setup_operation.v1",
"operation_id": str(uuid.uuid4()),
"correlation_id": str(uuid.uuid4()),
"idempotency_key": str(uuid.uuid4()),
"kind": "install",
"acknowledged_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
payload.update({
"state": os.environ["STATE"],
"progress_percent": int(os.environ["PROGRESS"]),
"progress_label": os.environ["LABEL"],
"customer_message": os.environ["MESSAGE"],
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"recovery_actions": ["retry", "view_log", "escalate"] if os.environ["STATE"] == "recovery_required" else [],
})
tmp = path + ".tmp"
with open(tmp, "w") as fh:
json.dump(payload, fh, indent=2)
os.replace(tmp, path)
os.chmod(path, 0o640)
PY
}
if [ -f "$SETUP_OPERATION_FILE" ] && grep -q '"state": "recovery_required"' "$SETUP_OPERATION_FILE"; then
whiptail --title "Nexus One AI Setup Recovery" --yesno "A previous setup attempt needs recovery.\n\nChoose Yes to resume setup safely. Choose No to leave the existing state unchanged and inspect $INSTALL_LOG_FILE." 14 72 || exit 1
fi
write_setup_operation "validating" 0 "Collecting setup choices" "Setup is ready to resume safely if interrupted."
detect_iface() {
ip route show default 2>/dev/null | awk '/default/ {print $5; exit}'
}
IFACE="$(detect_iface)"
IFACE="${IFACE:-$(ip -o link show | awk -F': ' '$2 !~ /lo|docker|br-|veth/ {print $2; exit}')}"
# ── Nexus One AI Workstation detection ─────────────────────
# Workstation is a standalone product category, not a rung on the Server
# S/M/L/Max ladder (see cezen_license.WORKSTATION_TIER). It must never be
# offered as a choice in the Server tier-selection menu below (Step 3).
# When this ISO was built as a Workstation image (CEZEN_TIER=workstation,
# or the /opt/cezen/tier marker says "workstation"), Step 3 is skipped
# entirely and the existing "starter" Ansible profile is reused for
# provisioning — same compact single-GPU stack, different commercial
# category. TIER="starter" here only selects which Ansible playbook runs;
# it does not change the "workstation" marker written to /opt/cezen/tier
# or the install-record tier fields below.
WORKSTATION_MODE=false
if [ "${CEZEN_TIER:-}" = "workstation" ]; then
WORKSTATION_MODE=true
elif [ -f /opt/cezen/tier ] && [ "$(tr -d '[:space:]' < /opt/cezen/tier)" = "workstation" ]; then
WORKSTATION_MODE=true
fi
netmask_to_prefix() {
case "$1" in
32|31|30|29|28|27|26|25|24|23|22|21|20|19|18|17|16|15|14|13|12|11|10|9|8|7|6|5|4|3|2|1) echo "$1" ;;
255.255.255.255) echo 32 ;;
255.255.255.254) echo 31 ;;
255.255.255.252) echo 30 ;;
255.255.255.248) echo 29 ;;
255.255.255.240) echo 28 ;;
255.255.255.224) echo 27 ;;
255.255.255.192) echo 26 ;;
255.255.255.128) echo 25 ;;
255.255.255.0) echo 24 ;;
255.255.254.0) echo 23 ;;
255.255.252.0) echo 22 ;;
255.255.248.0) echo 21 ;;
255.255.240.0) echo 20 ;;
255.255.224.0) echo 19 ;;
255.255.192.0) echo 18 ;;
255.255.128.0) echo 17 ;;
255.255.0.0) echo 16 ;;
*) return 1 ;;
esac
}
# ── Colors / terminal setup ────────────────────────────────
export NEWT_COLORS='
root=,black
window=black,white
border=white,black
title=black,white
button=black,white
actbutton=white,blue
checkbox=black,white
actcheckbox=white,blue
entry=black,white
label=black,white
listbox=black,white
actlistbox=white,blue
textbox=black,white
acttextbox=white,blue
'
TITLE=" Nexus One AI — Server Setup "
H=20
W=70
# ── Welcome ────────────────────────────────────────────────
whiptail --title "$TITLE" \
--msgbox "\nWelcome to the Nexus One AI installer.\n\nThis wizard will configure your network, capture license/customer details, and install the AI stack.\n\nMake sure this server is connected to the internet before continuing." \
$H $W
# ════════════════════════════════════════════════════════════
# STEP 1: NETWORK CONFIGURATION
# ════════════════════════════════════════════════════════════
NET_MODE=$(whiptail --title "$TITLE" \
--menu "\nStep 1 of 4: Network Configuration\n\nHow should this server get its IP address?" \
$H $W 2 \
"DHCP" "Automatic (get IP from network)" \
"Static" "Manual (enter IP address)" \
3>&1 1>&2 2>&3)
if [ "$NET_MODE" = "Static" ]; then
mkdir -p /etc/cloud/cloud.cfg.d
printf "network: {config: disabled}\n" > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
rm -f /etc/netplan/50-cloud-init.yaml /etc/netplan/99-cezen-dhcp.yaml || true
IP_ADDR=$(whiptail --title "$TITLE" \
--inputbox "\nEnter static IP address:\n(Example: 192.168.1.100)" \
$H $W "" 3>&1 1>&2 2>&3)
NETMASK=$(whiptail --title "$TITLE" \
--inputbox "\nEnter subnet mask / CIDR prefix:\n(Examples: 24 or 255.255.255.0)" \
$H $W "24" 3>&1 1>&2 2>&3)
PREFIX="$(netmask_to_prefix "$NETMASK")" || {
whiptail --title "$TITLE" \
--msgbox "\nInvalid subnet mask or CIDR prefix:\n ${NETMASK}\n\nUse a CIDR prefix such as 24, or a mask such as 255.255.255.0." \
$H $W
exit 1
}
GATEWAY=$(whiptail --title "$TITLE" \
--inputbox "\nEnter default gateway:\n(Example: 192.168.1.1)" \
$H $W "" 3>&1 1>&2 2>&3)
DNS=$(whiptail --title "$TITLE" \
--inputbox "\nEnter DNS server:\n(Example: 8.8.8.8)" \
$H $W "8.8.8.8" 3>&1 1>&2 2>&3)
# Write netplan static config
cat > /etc/netplan/99-cezen-static.yaml << EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: false
addresses:
- ${IP_ADDR}/${PREFIX}
routes:
- to: default
via: ${GATEWAY}
nameservers:
addresses: [${DNS}]
EOF
chmod 0600 /etc/netplan/99-cezen-static.yaml
if ! netplan apply; then
whiptail --title "$TITLE" \
--msgbox "\nFailed to apply network settings.\n\nIP: ${IP_ADDR}/${PREFIX}\nGateway: ${GATEWAY}\nDNS: ${DNS}\n\nPlease verify the address, gateway, and subnet." \
$H $W
exit 1
fi
sleep 3
# Verify connectivity
if ! ping -c 2 -W 3 8.8.8.8 &>/dev/null; then
whiptail --title "$TITLE" \
--msgbox "\nWARNING: Cannot reach internet with these settings.\n\nIP: ${IP_ADDR}/${PREFIX}\nGateway: ${GATEWAY}\nDNS: ${DNS}\n\nPlease verify your network settings. The install requires internet access." \
$H $W
else
whiptail --title "$TITLE" \
--msgbox "\nNetwork configured successfully!\n\nIP: ${IP_ADDR}/${PREFIX}\nGateway: ${GATEWAY}\nDNS: ${DNS}" \
$H $W
fi
else
# DHCP — persist it explicitly and confirm it's working
rm -f /etc/netplan/99-cezen-static.yaml || true
cat > /etc/netplan/99-cezen-dhcp.yaml << EOF
network:
version: 2
ethernets:
${IFACE}:
dhcp4: true
EOF
netplan apply 2>/dev/null || true
sleep 2
MY_IP=$(hostname -I | awk '{print $1}')
if [ -n "$MY_IP" ]; then
whiptail --title "$TITLE" \
--msgbox "\nDHCP configured successfully!\n\nServer IP: ${MY_IP}\n\nYou can switch to a static IP later by editing:\n /etc/netplan/99-cezen-dhcp.yaml" \
$H $W
else
whiptail --title "$TITLE" \
--msgbox "\nWARNING: No IP address obtained via DHCP.\n\nMake sure the network cable is connected and try rebooting." \
$H $W
fi
fi
# ════════════════════════════════════════════════════════════
# STEP 2: LICENSE / CUSTOMER DETAILS
# ════════════════════════════════════════════════════════════
CUSTOMER_NAME=$(whiptail --title "$TITLE" \
--inputbox "\nStep 2 of 4: License & Customer Details\n\nCustomer / organisation name:" \
$H $W "" 3>&1 1>&2 2>&3)
CUSTOMER_ID=$(whiptail --title "$TITLE" \
--inputbox "\nStep 2 of 4: License & Customer Details\n\nCustomer ID / project code:\n\nLeave blank for evaluation or field staging." \
$H $W "" 3>&1 1>&2 2>&3)
CONTACT_EMAIL=$(whiptail --title "$TITLE" \
--inputbox "\nStep 2 of 4: License & Customer Details\n\nCustomer/admin contact email:\n\nLeave blank if unavailable on site." \
$H $W "" 3>&1 1>&2 2>&3)
LICENSE_PATH=$(whiptail --title "$TITLE" \
--inputbox "\nStep 2 of 4: License & Customer Details\n\nPath to signed license JSON:\n\nLeave blank for offline field staging.\nExample: /media/usb/customer-license.json" \
$H $W "" 3>&1 1>&2 2>&3)
OVERRIDE_PATH=$(whiptail --title "$TITLE" \
--inputbox "\nOptional: Cezen hardware override JSON path\n\nLeave blank unless support explicitly provided one." \
$H $W "" 3>&1 1>&2 2>&3)
SUPPORT_UNTIL=$(whiptail --title "$TITLE" \
--inputbox "\nStep 2 of 4: License & Customer Details\n\nSupport valid until (YYYY-MM-DD):\n\nOptional local record field for field installs." \
$H $W "" 3>&1 1>&2 2>&3)
mkdir -p /opt/cezen
rm -f /opt/cezen/license.json /opt/cezen/license.override.json
if [ -n "$LICENSE_PATH" ]; then
if [ ! -f "$LICENSE_PATH" ]; then
whiptail --title "$TITLE" \
--msgbox "\nLicense file not found:\n $LICENSE_PATH" \
$H $W
exit 1
fi
cp "$LICENSE_PATH" /opt/cezen/license.json
chmod 0640 /opt/cezen/license.json
chown root:cezen /opt/cezen/license.json 2>/dev/null || true
fi
if [ -n "$OVERRIDE_PATH" ]; then
if [ ! -f "$OVERRIDE_PATH" ]; then
whiptail --title "$TITLE" \
--msgbox "\nOverride file not found:\n $OVERRIDE_PATH" \
$H $W
exit 1
fi
cp "$OVERRIDE_PATH" /opt/cezen/license.override.json
chmod 0640 /opt/cezen/license.override.json
chown root:cezen /opt/cezen/license.override.json 2>/dev/null || true
fi
bash "$FEASIBILITY_SCRIPT" /opt/cezen/feasibility.json >> "$LOG_FILE" 2>&1
python3 "$LICENSE_CHECK_SCRIPT" \
--license /opt/cezen/license.json \
--override /opt/cezen/license.override.json \
--feasibility /opt/cezen/feasibility.json \
--public-key "$PUBLIC_KEY_PATH" > /tmp/cezen-license-check.json
LICENSE_STATUS=$(python3 - <<'PY'
import json
d=json.load(open("/tmp/cezen-license-check.json"))
print((d.get("license") or {}).get("status","missing"))
PY
)
# Human-readable label for display only — the raw code above (e.g.
# "invalid_signature", "machine_mismatch") is what gets persisted to
# install-record.json and is meant for machines/support, not end users.
LICENSE_STATUS_LABEL=$(LICENSE_STATUS="$LICENSE_STATUS" python3 - <<'PY'
import os
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",
}
status = os.environ.get("LICENSE_STATUS", "missing")
print(labels.get(status, status))
PY
)
LICENSE_ALLOWED_TIER=$(python3 - <<'PY'
import json
d=json.load(open("/tmp/cezen-license-check.json"))
print((d.get("license") or {}).get("allowed_tier","basic"))
PY
)
HARDWARE_TIER=$(python3 - <<'PY'
import json
d=json.load(open("/tmp/cezen-license-check.json"))
print((d.get("hardware") or {}).get("recommended_tier","starter"))
PY
)
# ════════════════════════════════════════════════════════════
# STEP 3: SELECT TIER
# (skipped entirely for Nexus One AI Workstation — it is not a Server
# tier and must never appear in, or be selected from, this menu)
# ════════════════════════════════════════════════════════════
if [ "$WORKSTATION_MODE" = true ]; then
TIER="starter" # Ansible/install.sh routing only — reuses the compact
# single-GPU profile. The "workstation" marker in
# /opt/cezen/tier and the install-record below is what
# actually drives Workstation branding/entitlement.
whiptail --title "$TITLE" \
--msgbox "\nNexus One AI Workstation\n\nLicense status: ${LICENSE_STATUS_LABEL}\nHardware recommendation: ${HARDWARE_TIER}\n\nThis image installs the personal-appliance stack (local chat, personal RAG, document intelligence, prompt studio) — it is not a Server tier and will not be offered a Server S/M/L/Max upgrade path." \
$H $W
else
mapfile -t TIER_MENU < <(python3 - <<'PY'
import json
d=json.load(open("/tmp/cezen-license-check.json"))
labels = {
"starter": "Server S — 1x RTX 5090 / 32GB VRAM · Small team",
"basic": "Server M — 1x NVIDIA RTX Pro 6000 (96GB) · Up to 20 users",
"pro": "Server L — 2x RTX 5090 / RTX Pro class · Up to 100 users",
"max": "Server Max — 4-8x H100/H200/A100 class · 100+ users",
}
for opt in d.get("tier_options", []):
if opt.get("selectable"):
print(opt["tier"])
print(labels.get(opt["tier"], opt["tier"]))
PY
)
if [ "${#TIER_MENU[@]}" -eq 0 ]; then
whiptail --title "$TITLE" \
--msgbox "\nNo installable tiers are available.\n\nLicense status: ${LICENSE_STATUS_LABEL}\nHardware recommendation: ${HARDWARE_TIER}\n\nCheck the signed license or contact Cezen support." \
$H $W
exit 1
fi
whiptail --title "$TITLE" \
--msgbox "\nLicense status: ${LICENSE_STATUS_LABEL}\nAllowed tier: ${LICENSE_ALLOWED_TIER}\nHardware recommendation: ${HARDWARE_TIER}\n\nOnly valid tiers will be shown next." \
$H $W
TIER=$(whiptail --title "$TITLE" \
--menu "\nStep 3 of 4: Select AI Package Tier\n\nChoose the tier allowed by your license and hardware:" \
$H $W 4 \
"${TIER_MENU[@]}" \
3>&1 1>&2 2>&3)
fi
# Display-only label — $TIER itself stays the Ansible/install.sh routing
# value (e.g. "starter"); TIER_DISPLAY is what the operator sees and what
# gets recorded as the product category for Workstation installs.
if [ "$WORKSTATION_MODE" = true ]; then
TIER_DISPLAY="workstation"
else
TIER_DISPLAY="$TIER"
fi
# ════════════════════════════════════════════════════════════
# STEP 4: SELECT AI TOOLS
# ════════════════════════════════════════════════════════════
TOOLS=$(whiptail --title "$TITLE" \
--checklist "\nStep 4 of 4: Select AI Tools to Install\n\nSpace to toggle, Enter to confirm:" \
$H $W 8 \
"ollama" "Ollama + Open WebUI (LLM inference + chat UI)" ON \
"jupyterlab" "JupyterLab (Notebook environment)" ON \
"chromadb" "ChromaDB (Vector DB for RAG)" ON \
"vllm" "vLLM (OpenAI-compatible API)" ON \
"mlflow" "MLflow (Experiment tracking)" ON \
"minio" "MinIO (S3 model storage)" ON \
"monitoring" "Grafana + Prometheus (GPU & system monitoring)" ON \
"k3s" "K3s (Lightweight Kubernetes)" ON \
3>&1 1>&2 2>&3)
# ════════════════════════════════════════════════════════════
# CONFIRM
# ════════════════════════════════════════════════════════════
# Format tools list for display
TOOLS_DISPLAY=$(echo "$TOOLS" | tr -d '"' | tr ' ' '\n' | sed 's/^/ · /' | tr '\n' '\n')
MY_IP=$(hostname -I | awk '{print $1}')
LICENSE_DISPLAY="Field staging / evaluation"
if [ -n "$LICENSE_PATH" ]; then
LICENSE_DISPLAY="Signed file (${LICENSE_STATUS_LABEL})"
fi
whiptail --title "$TITLE" \
--yesno "\nReady to install. Please confirm:\n\nNetwork: ${NET_MODE} (${MY_IP})\nCustomer: ${CUSTOMER_NAME:-Not entered}\nLicense: ${LICENSE_DISPLAY}\nAllowed: ${LICENSE_ALLOWED_TIER}\nHardware: ${HARDWARE_TIER}\nTier: ${TIER_DISPLAY}\n\nTools:\n${TOOLS_DISPLAY}\n\nThis will take 2040 minutes.\nThe server will reboot once during install (NVIDIA drivers).\n\nContinue?" \
$H $W
# ════════════════════════════════════════════════════════════
# RUN INSTALLER
# ════════════════════════════════════════════════════════════
clear
echo ""
write_setup_operation "running" 5 "Starting appliance installation" "Installation has started."
echo "╔══════════════════════════════════════════╗"
echo "║ Nexus One AI — Installing... ║"
echo "║ Check progress: journalctl -f ║"
echo "╚══════════════════════════════════════════╝"
echo ""
# Write selected tools to a config file so install.sh can read it
mkdir -p /opt/cezen
SKIP_ROLES=""
for role in ollama jupyterlab chromadb vllm mlflow minio monitoring k3s; do
if ! echo "$TOOLS" | grep -q "$role"; then
if [ -n "$SKIP_ROLES" ]; then
SKIP_ROLES="${SKIP_ROLES},${role}"
else
SKIP_ROLES="${role}"
fi
fi
done
cat > /opt/cezen/install.conf << EOF
TIER=${TIER}
SKIP_ROLES=${SKIP_ROLES}
EOF
export CUSTOMER_NAME CUSTOMER_ID CONTACT_EMAIL SUPPORT_UNTIL TIER TIER_DISPLAY LICENSE_STATUS LICENSE_ALLOWED_TIER HARDWARE_TIER WORKSTATION_MODE
python3 - <<'PY'
import json, os, time
# NOTE: install.sh (Phase 2) rewrites /opt/cezen/install-record.json after
# this. For Workstation, it preserves category="workstation" and selected_tier
# = "workstation", while provisioned_tier becomes "starter" to reflect the
# Ansible profile actually installed. The persistent Workstation marker is
# still /opt/cezen/tier, and install.sh never touches that marker.
payload = {
"schema": "cezen.install_record.v1",
"customer_name": os.environ.get("CUSTOMER_NAME", "").strip(),
"customer_id": os.environ.get("CUSTOMER_ID", "").strip(),
"contact_email": os.environ.get("CONTACT_EMAIL", "").strip(),
"selected_tier": os.environ.get("TIER_DISPLAY", "basic").strip(),
"provisioned_tier": os.environ.get("TIER_DISPLAY", "basic").strip(),
"category": "workstation" if os.environ.get("WORKSTATION_MODE") == "true" else "server",
"support_until": os.environ.get("SUPPORT_UNTIL", "").strip(),
"install_type": "licensed" if os.path.exists("/opt/cezen/license.json") else "field-staging",
"license_status": os.environ.get("LICENSE_STATUS", "missing").strip(),
"licensed_tier": os.environ.get("LICENSE_ALLOWED_TIER", "basic").strip(),
"hardware_recommended_tier": os.environ.get("HARDWARE_TIER", "starter").strip(),
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
with open("/opt/cezen/install-record.json", "w") as f:
json.dump(payload, f, indent=2)
PY
chown root:cezen /opt/cezen/install-record.json 2>/dev/null || true
chmod 0640 /opt/cezen/install-record.json
whiptail --title "$TITLE" \
--infobox "\nInstalling Nexus One AI stack...\n\nThis can take several minutes.\n\nLogs are being written to:\n $INSTALL_LOG_FILE" \
$H $W
if bash "$AIPACKAGE_DIR/install.sh" --tier="$TIER" >> "$INSTALL_LOG_FILE" 2>&1; then
# Mark as configured only after the installer finishes successfully.
touch /opt/cezen/.setup-done
write_setup_operation "succeeded" 100 "Installation completed" "Installation completed successfully."
whiptail --title "$TITLE" \
--msgbox "\nInstaller command finished successfully.\n\nPortal -> http://localhost\nOllama API -> http://localhost:11434\nGrafana -> http://localhost:3000\n\nAdmin login -> admin / Cezen@2024!\n(you will be required to change this on first login)\n\nFor detailed logs, run:\n sudo tail -f $INSTALL_LOG_FILE" \
$H $W
else
write_setup_operation "recovery_required" 0 "Installation needs attention" "Installation did not complete. Review the local log, then retry or contact support."
whiptail --title "$TITLE" \
--msgbox "\nInstaller command failed.\n\nThe setup wizard will run again on next boot.\n\nCheck the log with:\n sudo tail -n 120 $INSTALL_LOG_FILE" \
$H $W
exit 1
fi