Add signed licensing enforcement and ISO notes
This commit is contained in:
1 parent
dee4486826
commit
d645249a13
14 files changed
+1526
-103
No files matched your search
@@ -185,10 +185,13 @@ echo "╔═══════════════════════
|
||||
echo "║ Done! ║"
|
||||
echo "╚══════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "→ ISO path:"
|
||||
echo " $OUTPUT_ISO"
|
||||
echo ""
|
||||
ls -lh "$OUTPUT_ISO"
|
||||
echo ""
|
||||
echo "→ Copy to your MacBook:"
|
||||
echo " scp user@172.16.10.180:~/aipackage/autoinstall/cezen-ai-ubuntu2204.iso ."
|
||||
echo " scp user@<build-server>:$(dirname "$OUTPUT_ISO")/$(basename "$OUTPUT_ISO") ."
|
||||
echo ""
|
||||
echo "→ Flash to USB on MacBook:"
|
||||
echo " diskutil list # find USB e.g. /dev/disk4"
|
||||
|
||||
+106
-21
@@ -7,6 +7,9 @@
|
||||
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"
|
||||
export TERM="${TERM:-linux}"
|
||||
@@ -187,25 +190,106 @@ 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_KEY=$(whiptail --title "$TITLE" \
|
||||
--passwordbox "\nStep 2 of 4: License & Customer Details\n\nLicense key / activation code:\n\nLeave blank for offline field staging. Models and keys can be added later." \
|
||||
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\nLeave blank if not issued yet." \
|
||||
--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
|
||||
)
|
||||
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
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
mapfile -t TIER_MENU < <(python3 - <<'PY'
|
||||
import json
|
||||
d=json.load(open("/tmp/cezen-license-check.json"))
|
||||
labels = {
|
||||
"starter": "Starter — 1x RTX 5090 / 32GB VRAM · Small team",
|
||||
"basic": "Entry — 1x NVIDIA RTX Pro 6000 (96GB) · Up to 20 users",
|
||||
"pro": "Pro — 2x RTX 5090 / RTX Pro class · Up to 100 users",
|
||||
"max": "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}\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}\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 that matches your hardware:" \
|
||||
--menu "\nStep 3 of 4: Select AI Package Tier\n\nChoose the tier allowed by your license and hardware:" \
|
||||
$H $W 4 \
|
||||
"starter" "Starter — 1× RTX 5090 / 32GB VRAM · Small team" \
|
||||
"basic" "Entry — 1× NVIDIA RTX Pro 6000 (96GB) · Up to 20 users" \
|
||||
"pro" "Pro — 2× RTX 5090 / RTX Pro class · Up to 100 users" \
|
||||
"max" "Max — 4–8× H100/H200/A100 class · 100+ users" \
|
||||
"${TIER_MENU[@]}" \
|
||||
3>&1 1>&2 2>&3)
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -233,12 +317,12 @@ TOOLS=$(whiptail --title "$TITLE" \
|
||||
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_KEY" ]; then
|
||||
LICENSE_DISPLAY="Provided"
|
||||
if [ -n "$LICENSE_PATH" ]; then
|
||||
LICENSE_DISPLAY="Signed file (${LICENSE_STATUS})"
|
||||
fi
|
||||
|
||||
whiptail --title "$TITLE" \
|
||||
--yesno "\nReady to install. Please confirm:\n\nNetwork: ${NET_MODE} (${MY_IP})\nCustomer: ${CUSTOMER_NAME:-Not entered}\nLicense: ${LICENSE_DISPLAY}\nTier: ${TIER}\n\nTools:\n${TOOLS_DISPLAY}\n\nThis will take 20–40 minutes.\nThe server will reboot once during install (NVIDIA drivers).\n\nContinue?" \
|
||||
--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}\n\nTools:\n${TOOLS_DISPLAY}\n\nThis will take 20–40 minutes.\nThe server will reboot once during install (NVIDIA drivers).\n\nContinue?" \
|
||||
$H $W
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
@@ -271,29 +355,30 @@ TIER=${TIER}
|
||||
SKIP_ROLES=${SKIP_ROLES}
|
||||
EOF
|
||||
|
||||
export CUSTOMER_NAME CUSTOMER_ID CONTACT_EMAIL LICENSE_KEY SUPPORT_UNTIL TIER
|
||||
export CUSTOMER_NAME CUSTOMER_ID CONTACT_EMAIL SUPPORT_UNTIL TIER LICENSE_STATUS LICENSE_ALLOWED_TIER HARDWARE_TIER
|
||||
python3 - <<'PY'
|
||||
import json, os, time
|
||||
|
||||
payload = {
|
||||
"schema": "cezen.license.v1",
|
||||
"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(),
|
||||
"license_key": os.environ.get("LICENSE_KEY", "").strip(),
|
||||
"tier": os.environ.get("TIER", "basic").strip(),
|
||||
"selected_tier": os.environ.get("TIER", "basic").strip(),
|
||||
"provisioned_tier": os.environ.get("TIER", "basic").strip(),
|
||||
"support_until": os.environ.get("SUPPORT_UNTIL", "").strip(),
|
||||
"install_type": "licensed" if os.environ.get("LICENSE_KEY", "").strip() else "field-staging",
|
||||
"issued_by": "Cezen",
|
||||
"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()),
|
||||
"signature_status": "unsigned-field-install",
|
||||
}
|
||||
|
||||
with open("/opt/cezen/license.json", "w") as f:
|
||||
with open("/opt/cezen/install-record.json", "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
PY
|
||||
chown root:cezen /opt/cezen/license.json 2>/dev/null || true
|
||||
chmod 0640 /opt/cezen/license.json
|
||||
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" \
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxOGixLLE9iwPNngpX2gr
|
||||
pUiVPHN0//LcBUovRsKr1qq5xXRxhMmE1Fgux8yMHBlTLRCKdd4wH1N3E7EGMg9T
|
||||
vHeLQFZJ8uGkK2U7X9nY6h9prAe9VVNvz6OwdYQqxPbttW723w2cy2p2/Jdxry5x
|
||||
9iqJ3Q084cDyT30QHdkhqGTiYFAJ7+K95acktuKUs/A2WuHgEmhbK8aCWW2kQzLS
|
||||
x50aFjxGwXbHydXG7D0WJYCXKPjTXusjTNuPopjUH3Yp9xqieGqqxSFXFkIXdrgc
|
||||
wkpMmgJEz/RQlG+fFkGd3VdoxD3taRMamuVky+9Kf6MbMDZKoxte4gf2FjXav9j8
|
||||
mQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
+174
-27
@@ -3,15 +3,28 @@
|
||||
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
|
||||
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"
|
||||
PUBLIC_KEY_PATH = os.environ.get("CEZEN_LICENSE_PUBLIC_KEY", f"{AIPACKAGE_DIR}/autoinstall/keys/cezen-license-public.pem")
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────
|
||||
def get_ip():
|
||||
@@ -91,35 +104,84 @@ def apply_static_ip(iface, ip, prefix, gateway, dns):
|
||||
subprocess.run(["netplan", "apply"], capture_output=True)
|
||||
time.sleep(3)
|
||||
|
||||
def write_license_file(license_data, tier):
|
||||
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.license.v1",
|
||||
"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(),
|
||||
"license_key": (license_data.get("license_key") or "").strip(),
|
||||
"tier": tier,
|
||||
"support_until": (license_data.get("support_until") or "").strip(),
|
||||
"install_type": (license_data.get("install_type") or "customer").strip(),
|
||||
"issued_by": "Cezen",
|
||||
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"signature_status": "unsigned-field-install",
|
||||
"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"]},
|
||||
}
|
||||
with open("/opt/cezen/license.json", "w") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
subprocess.run(["chown", "root:cezen", "/opt/cezen/license.json"], check=False)
|
||||
os.chmod("/opt/cezen/license.json", 0o640)
|
||||
return payload
|
||||
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):
|
||||
def run_install(tier, skip_tools, license_data=None, preview=None):
|
||||
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 ""
|
||||
write_license_file(license_data or {}, tier)
|
||||
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")
|
||||
|
||||
@@ -187,9 +249,13 @@ HTML = r"""<!DOCTYPE html>
|
||||
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; }
|
||||
@@ -217,9 +283,10 @@ HTML = r"""<!DOCTYPE html>
|
||||
|
||||
.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 { width: 100%; padding: 10px 14px; border: 1.5px solid #CBD5E1; border-radius: 8px;
|
||||
.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 input:focus { border-color: var(--teal); }
|
||||
.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; }
|
||||
@@ -338,7 +405,7 @@ HTML = r"""<!DOCTYPE html>
|
||||
<div id="step-2" class="hidden">
|
||||
<div class="card">
|
||||
<h2>License & Customer Details</h2>
|
||||
<p class="desc">Enter the customer/license details for this installation. Leave the license key blank for evaluation or PSU field staging.</p>
|
||||
<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">
|
||||
@@ -352,17 +419,25 @@ HTML = r"""<!DOCTYPE html>
|
||||
<input type="email" id="lic-contact-email" placeholder="admin@example.gov.in">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>License Key / Activation Code</label>
|
||||
<input type="text" id="lic-license-key" placeholder="Optional for offline field install">
|
||||
<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="goStep(3)">Next →</button>
|
||||
<button class="btn btn-primary" onclick="prepareTierStep()">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -376,21 +451,25 @@ HTML = r"""<!DOCTYPE html>
|
||||
<div class="tier-name">Starter</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">Entry</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">Pro</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">Max</div>
|
||||
<div class="tier-gpu">4–8× 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>
|
||||
@@ -461,6 +540,7 @@ HTML = r"""<!DOCTYPE html>
|
||||
// ── State ──────────────────────────────────────────────────
|
||||
let netMode = 'dhcp';
|
||||
let selectedTier = 'basic';
|
||||
let tierPreview = null;
|
||||
let tools = {
|
||||
ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true },
|
||||
jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true },
|
||||
@@ -478,7 +558,7 @@ window.onload = () => {
|
||||
document.getElementById('current-ip').textContent = d.ip || 'unknown';
|
||||
});
|
||||
renderTools();
|
||||
selectTier('basic');
|
||||
renderTierAvailability(null);
|
||||
};
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────
|
||||
@@ -525,7 +605,28 @@ function applyStaticIP() {
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
@@ -556,12 +657,32 @@ function collectLicense() {
|
||||
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_key: document.getElementById('lic-license-key').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-key').value.trim() ? 'licensed' : 'field-staging'
|
||||
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(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) {
|
||||
alert('License preview failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────
|
||||
function renderSummary() {
|
||||
const ip = netMode === 'dhcp'
|
||||
@@ -573,7 +694,9 @@ function renderSummary() {
|
||||
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_key ? 'Provided' : 'Field staging / evaluation'}</span></div>
|
||||
<div class="summary-row"><span class="key">License</span><span class="val">${license.license_json ? (tierPreview?.license?.status || 'Provided') : 'Field staging / evaluation'}</span></div>
|
||||
<div class="summary-row"><span class="key">Allowed Tier</span><span class="val">${tierPreview?.license?.allowed_tier || 'basic'}</span></div>
|
||||
<div class="summary-row"><span class="key">Hardware Tier</span><span class="val">${tierPreview?.hardware?.recommended_tier || 'starter'}</span></div>
|
||||
<div class="summary-row"><span class="key">Tier</span><span class="val">${selectedTier.charAt(0).toUpperCase()+selectedTier.slice(1)}</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>` : ''}
|
||||
@@ -591,6 +714,14 @@ function startInstall() {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ tier: selectedTier, skip_tools: skip, license: collectLicense() })
|
||||
}).then(async r => {
|
||||
const data = await r.json();
|
||||
if (!r.ok || !data.ok) throw new Error(data.error || 'Install start failed');
|
||||
}).catch(err => {
|
||||
alert(err.message);
|
||||
document.getElementById('install-btn-row').classList.remove('hidden');
|
||||
document.getElementById('summary-card').classList.remove('hidden');
|
||||
document.getElementById('progress-wrap').classList.remove('show');
|
||||
});
|
||||
|
||||
streamLog();
|
||||
@@ -780,13 +911,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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", {})
|
||||
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
|
||||
if not install_status["running"]:
|
||||
t = threading.Thread(target=run_install, args=(tier, skip, license_data), daemon=True)
|
||||
t = threading.Thread(target=run_install, args=(tier, skip, license_data, preview), daemon=True)
|
||||
t.start()
|
||||
self.send_json({"ok": True})
|
||||
else:
|
||||
|
||||
Reference in new issue
Block a user