diff --git a/ansible/roles/cezen-backend/files/main.py b/ansible/roles/cezen-backend/files/main.py index c238201..c768c1a 100644 --- a/ansible/roles/cezen-backend/files/main.py +++ b/ansible/roles/cezen-backend/files/main.py @@ -34,6 +34,7 @@ DATA_DIR = Path(os.environ.get("CEZEN_DATA", "/opt/cezen/data")) DB_PATH = DATA_DIR / "cezen.db" SECRET_FILE = DATA_DIR / ".jwt_secret" BACKUP_DIR = Path(os.environ.get("CEZEN_BACKUP_DIR", str(DATA_DIR.parent / "backups"))) +LICENSE_FILE = Path(os.environ.get("CEZEN_LICENSE_JSON", "/opt/cezen/license.json")) DATA_DIR.mkdir(parents=True, exist_ok=True) BACKUP_DIR.mkdir(parents=True, exist_ok=True) @@ -825,22 +826,46 @@ def _normalize_tier(value: str) -> str: return raw return TIER_ALIASES.get(raw, "basic") +def _license_record() -> dict: + try: + if LICENSE_FILE.exists(): + data = json.loads(LICENSE_FILE.read_text()) + if isinstance(data, dict): + return data + except Exception: + pass + return {} + +def _public_license_record(record: dict) -> dict: + public = {k: v for k, v in (record or {}).items() if k != "license_key"} + if record.get("license_key"): + public["license_key_prefix"] = record["license_key"][:12] + public["license_key_present"] = True + else: + public["license_key_present"] = False + return public + def _current_tier() -> str: if CEZEN_TIER: return _normalize_tier(CEZEN_TIER) + license_tier = (_license_record().get("tier") or "").strip() + if license_tier: + return _normalize_tier(license_tier) return _normalize_tier(_setting_value("tier_label", "Basic")) def _tier_payload() -> dict: tier = _current_tier() info = TIER_MATRIX[tier] + license_record = _license_record() return { "tier": tier, "label": info["label"], - "locked": bool(CEZEN_TIER), + "locked": bool(CEZEN_TIER or license_record.get("tier")), "positioning": info["positioning"], "max_users": info["max_users"], "features": info["features"], "tiers": TIER_MATRIX, + "license_record": _public_license_record(license_record), } def _readiness_score(feasibility: dict, license_info: dict) -> dict: @@ -1777,12 +1802,20 @@ async def get_branding(): rows = db.execute("SELECT key, value FROM settings").fetchall() db.close() result = {r["key"]: r["value"] for r in rows} + license_record = _license_record() # If Cezen has locked the tier via env var, override whatever is in DB - if CEZEN_TIER: - result["tier_label"] = CEZEN_TIER + if CEZEN_TIER or license_record.get("tier"): + tier = _current_tier() + result["tier_label"] = TIER_MATRIX[tier]["label"] + result["tier_slug"] = tier result["tier_locked"] = "true" else: result["tier_locked"] = "false" + if license_record: + result["license_customer"] = license_record.get("customer_name", "") + result["license_customer_id"] = license_record.get("customer_id", "") + result["license_key_prefix"] = (license_record.get("license_key", "") or "")[:12] + result["license_support_until"] = license_record.get("support_until", "") return result @app.put("/api/settings/branding") diff --git a/ansible/roles/cezen-backend/tasks/main.yml b/ansible/roles/cezen-backend/tasks/main.yml index 265b689..6b46029 100644 --- a/ansible/roles/cezen-backend/tasks/main.yml +++ b/ansible/roles/cezen-backend/tasks/main.yml @@ -30,6 +30,19 @@ group: "{{ cezen_user }}" mode: "0750" +- name: Check for captured license file + stat: + path: /opt/cezen/license.json + register: cezen_license_file + +- name: Allow backend service to read captured license + file: + path: /opt/cezen/license.json + owner: root + group: "{{ cezen_user }}" + mode: "0640" + when: cezen_license_file.stat.exists + - name: Copy FastAPI application copy: src: main.py diff --git a/autoinstall/firstboot-setup.sh b/autoinstall/firstboot-setup.sh index d4582b7..98bec2e 100644 --- a/autoinstall/firstboot-setup.sh +++ b/autoinstall/firstboot-setup.sh @@ -42,7 +42,7 @@ W=70 # ── Welcome ──────────────────────────────────────────────── whiptail --title "$TITLE" \ - --msgbox "\nWelcome to the Nexus One AI installer.\n\nThis wizard will configure your network and install the AI stack.\n\nMake sure this server is connected to the internet before continuing." \ + --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 # ════════════════════════════════════════════════════════════ @@ -50,7 +50,7 @@ whiptail --title "$TITLE" \ # ════════════════════════════════════════════════════════════ NET_MODE=$(whiptail --title "$TITLE" \ - --menu "\nStep 1 of 3: Network Configuration\n\nHow should this server get its IP address?" \ + --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)" \ @@ -131,11 +131,35 @@ EOF fi # ════════════════════════════════════════════════════════════ -# STEP 2: SELECT TIER +# 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_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." \ + $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." \ + $H $W "" 3>&1 1>&2 2>&3) + +# ════════════════════════════════════════════════════════════ +# STEP 3: SELECT TIER # ════════════════════════════════════════════════════════════ TIER=$(whiptail --title "$TITLE" \ - --menu "\nStep 2 of 3: 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 that matches your 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" \ @@ -144,11 +168,11 @@ TIER=$(whiptail --title "$TITLE" \ 3>&1 1>&2 2>&3) # ════════════════════════════════════════════════════════════ -# STEP 3: SELECT AI TOOLS +# STEP 4: SELECT AI TOOLS # ════════════════════════════════════════════════════════════ TOOLS=$(whiptail --title "$TITLE" \ - --checklist "\nStep 3 of 3: Select AI Tools to Install\n\nSpace to toggle, Enter to confirm:" \ + --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 \ @@ -167,9 +191,13 @@ TOOLS=$(whiptail --title "$TITLE" \ # 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_KEY" ]; then + LICENSE_DISPLAY="Provided" +fi whiptail --title "$TITLE" \ - --yesno "\nReady to install. Please confirm:\n\nNetwork: ${NET_MODE} (${MY_IP})\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}\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 # ════════════════════════════════════════════════════════════ @@ -202,6 +230,30 @@ TIER=${TIER} SKIP_ROLES=${SKIP_ROLES} EOF +export CUSTOMER_NAME CUSTOMER_ID CONTACT_EMAIL LICENSE_KEY SUPPORT_UNTIL TIER +python3 - <<'PY' +import json, os, time + +payload = { + "schema": "cezen.license.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(), + "support_until": os.environ.get("SUPPORT_UNTIL", "").strip(), + "install_type": "licensed" if os.environ.get("LICENSE_KEY", "").strip() else "field-staging", + "issued_by": "Cezen", + "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: + json.dump(payload, f, indent=2) +PY +chown root:cezen /opt/cezen/license.json 2>/dev/null || true +chmod 0640 /opt/cezen/license.json + # Mark as configured so this wizard doesn't run again touch /opt/cezen/.setup-done diff --git a/autoinstall/websetup/server.py b/autoinstall/websetup/server.py index ceb0598..3ad3816 100644 --- a/autoinstall/websetup/server.py +++ b/autoinstall/websetup/server.py @@ -91,13 +91,35 @@ def apply_static_ip(iface, ip, prefix, gateway, dns): subprocess.run(["netplan", "apply"], capture_output=True) time.sleep(3) -def run_install(tier, skip_tools): +def write_license_file(license_data, tier): + os.makedirs("/opt/cezen", exist_ok=True) + payload = { + "schema": "cezen.license.v1", + "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", + } + 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 + +def run_install(tier, skip_tools, license_data=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) with open("/opt/cezen/install.conf", "w") as f: f.write(f"TIER={tier}\nSKIP_ROLES={skip_str}\n") @@ -258,9 +280,10 @@ HTML = r"""
1 · Network
-
2 · Select Tier
-
3 · AI Tools
-
4 · Install
+
2 · License
+
3 · Select Tier
+
4 · AI Tools
+
5 · Install
@@ -311,8 +334,40 @@ HTML = r""" - + + + +
- - + +
- - - -