Add signed licensing enforcement and ISO notes

This commit is contained in:
Jino Jose committed 2026-07-08 10:47:49 +05:30
1 parent dee4486826
commit d645249a13
14 files changed
+1526 -103

No files matched your search

+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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: