Add signed licensing enforcement and ISO notes

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

View File

@ -1,8 +1,12 @@
# Nexus One AI Installer # Nexus One AI Platform Package
This repository is the source of truth for Nexus One AI ISO and server installs. This repository is the source of truth for the Nexus One AI platform package,
The ISO keeps itself small by pulling this package from cgit during setup, then including installer flows, deployment roles, portal assets, backend services,
the installer deploys the selected tier on the target server. licensing enforcement, and the bootable ISO build path.
The ISO is only one delivery surface. It stays small by pulling this package
from cgit during setup, then the installer deploys the selected tier and
feature set on the target server.
## Product Collateral ## Product Collateral
@ -45,7 +49,7 @@ evaluation, partner enablement, and internal technical review.
![Nexus One AI tier comparison](docs/nexus-one-ai-tier-comparison.jpg) ![Nexus One AI tier comparison](docs/nexus-one-ai-tier-comparison.jpg)
## 1. Choose The Install Path ## 1. Choose The Deployment Path
| Scenario | Use This Path | | Scenario | Use This Path |
|---|---| |---|---|
@ -54,7 +58,31 @@ evaluation, partner enablement, and internal technical review.
| Existing Ubuntu server | Clone this repo and run the feasibility check before installing. | | Existing Ubuntu server | Clone this repo and run the feasibility check before installing. |
| Lab test without GPU | Use Multipass/VM and expect GPU services to be limited. | | Lab test without GPU | Use Multipass/VM and expect GPU services to be limited. |
## 2. New ISO Install ## 2. Bootable ISO Deployment
The bootable ISO filename is:
```text
cezen-ai-ubuntu2204.iso
```
The canonical repo location for that artifact is:
```text
cgit/autoinstall/cezen-ai-ubuntu2204.iso
```
If you build on a Linux server from inside the repo, the full output path will be:
```text
<repo-root>/autoinstall/cezen-ai-ubuntu2204.iso
```
Example from the current Ubuntu build host:
```text
/home/cezen/aipackage/cgit/autoinstall/cezen-ai-ubuntu2204.iso
```
1. Flash the Nexus One AI ISO to a USB drive or attach it to the VM/server. 1. Flash the Nexus One AI ISO to a USB drive or attach it to the VM/server.
2. Boot the server from the ISO. 2. Boot the server from the ISO.
@ -88,7 +116,7 @@ sudo tail -f /var/log/cezen-install.log
http://<server-ip>/ http://<server-ip>/
``` ```
## 3. PSU / Pendrive Field Install ## 3. Field Deployment From USB
Use this when a team physically visits the site and installs from a USB drive. Use this when a team physically visits the site and installs from a USB drive.
@ -115,7 +143,13 @@ Installer selections are stored at:
/opt/cezen/install.conf /opt/cezen/install.conf
``` ```
## 4. Existing Server Feasibility Check Whenever a new ISO is rebuilt, check this file path first:
```text
autoinstall/cezen-ai-ubuntu2204.iso
```
## 4. Hardware Feasibility And Pre-Sales Check
Run this before quoting, committing a tier, or installing on customer-owned hardware. Run this before quoting, committing a tier, or installing on customer-owned hardware.
@ -149,7 +183,7 @@ Recommended interpretation:
| `gpu-pro` | Pro tier candidate. | | `gpu-pro` | Pro tier candidate. |
| `gpu-max` | Max tier candidate. | | `gpu-max` | Max tier candidate. |
## 5. Existing Server Install ## 5. Existing Server Installation
After feasibility check, install on an existing Ubuntu server: After feasibility check, install on an existing Ubuntu server:
@ -175,7 +209,7 @@ sudo bash install.sh --software-only --tier=max
The installer warns if selected tier and hardware recommendation do not match. The installer warns if selected tier and hardware recommendation do not match.
The selected tier still wins, because the sale/license decision is commercial. The selected tier still wins, because the sale/license decision is commercial.
## 6. Tier Guide ## 6. Tier And Packaging Guide
| Tier | Target Hardware | Typical Use | Default Models | | Tier | Target Hardware | Typical Use | Default Models |
|---|---|---|---| |---|---|---|---|
@ -193,7 +227,7 @@ bash models/pull-models.sh --tier=pro
bash models/pull-models.sh --tier=max bash models/pull-models.sh --tier=max
``` ```
## 7. Product Features ## 7. Platform Features
Nexus One AI includes these application features through the portal and backend: Nexus One AI includes these application features through the portal and backend:

View 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-----

View File

@ -0,0 +1,321 @@
import base64
import copy
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
TIER_ORDER = ("starter", "basic", "pro", "max")
TIER_ALIASES = {
"entry": "basic",
"entry tier": "basic",
"mid": "pro",
"mid tier": "pro",
"advanced": "max",
"advanced tier": "max",
"starter tier": "starter",
"basic tier": "basic",
"pro tier": "pro",
"max tier": "max",
}
STAGING_MAX_TIER = "basic"
SUPPORTED_SIGNATURE_ALGS = {"rsa-sha256", "sha256-rsa"}
def utcnow_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_time(value):
text = (value or "").strip()
if not text:
return None
if len(text) == 10:
text += "T00:00:00Z"
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text)
except Exception:
return None
def normalize_tier(value, default="basic"):
raw = (value or "").strip().lower()
if raw in TIER_ORDER:
return raw
return TIER_ALIASES.get(raw, default)
def tier_rank(tier):
return TIER_ORDER.index(normalize_tier(tier))
def tier_lte(left, right):
return tier_rank(left) <= tier_rank(right)
def min_tier(*tiers):
items = [normalize_tier(t) for t in tiers if t]
if not items:
return STAGING_MAX_TIER
return sorted(items, key=tier_rank)[0]
def read_json_file(path):
try:
data = json.loads(Path(path).read_text())
return data if isinstance(data, dict) else {}
except Exception:
return {}
def write_json_file(path, payload):
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(payload, indent=2))
def _canonical_payload(payload):
clean = copy.deepcopy(payload or {})
for key in ("signature", "signature_status", "verification_status"):
clean.pop(key, None)
return json.dumps(clean, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def verify_signature(payload, public_key_path):
if not payload:
return False, "missing"
signature = (payload.get("signature") or "").strip()
if not signature:
return False, "missing_signature"
alg = (payload.get("signature_alg") or "").strip().lower()
if alg not in SUPPORTED_SIGNATURE_ALGS:
return False, "unsupported_signature_alg"
key_path = Path(public_key_path)
if not key_path.exists():
return False, "missing_public_key"
canonical = _canonical_payload(payload).encode()
try:
sig_bytes = base64.b64decode(signature, validate=True)
except Exception:
return False, "bad_signature_encoding"
with tempfile.NamedTemporaryFile(delete=False) as data_file, tempfile.NamedTemporaryFile(delete=False) as sig_file:
data_file.write(canonical)
sig_file.write(sig_bytes)
data_file.flush()
sig_file.flush()
data_path = data_file.name
sig_path = sig_file.name
try:
result = subprocess.run(
["openssl", "dgst", "-sha256", "-verify", str(key_path), "-signature", sig_path, data_path],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0, "valid" if result.returncode == 0 else "invalid_signature"
finally:
for tmp in (data_path, sig_path):
try:
os.unlink(tmp)
except OSError:
pass
def public_license_record(record):
public = {k: v for k, v in (record or {}).items() if k != "signature"}
if record.get("license_key"):
public["license_key_prefix"] = str(record.get("license_key"))[:12]
public["license_key_present"] = True
else:
public["license_key_present"] = False
return public
def evaluate_license(payload, public_key_path, machine_id=None, now=None):
now_dt = parse_time(now) if isinstance(now, str) else now or datetime.now(timezone.utc)
base = {
"schema": "cezen.license.v2",
"status": "missing",
"valid": False,
"allowed_tier": STAGING_MAX_TIER,
"feature_overrides": {},
"install_type": "field-staging",
"needs_activation": True,
"license_record": {},
"notes": [],
}
if not payload:
return base
record = payload if isinstance(payload, dict) else {}
base["license_record"] = public_license_record(record)
base["install_type"] = (record.get("install_type") or "licensed").strip() or "licensed"
base["allowed_tier"] = normalize_tier(record.get("allowed_tier") or record.get("tier"), STAGING_MAX_TIER)
overrides = record.get("feature_overrides") or {}
base["feature_overrides"] = overrides if isinstance(overrides, dict) else {}
ok, sig_status = verify_signature(record, public_key_path)
if not ok:
base["status"] = "invalid_signature" if sig_status != "missing_signature" else "missing"
base["notes"].append(sig_status)
return base
issued_at = parse_time(record.get("issued_at"))
if issued_at and issued_at > now_dt:
base["status"] = "not_yet_valid"
base["notes"].append("issued_at_in_future")
return base
expires_at = parse_time(record.get("expires_at"))
if expires_at and expires_at < now_dt:
base["status"] = "expired"
base["notes"].append("license_expired")
return base
machine_binding = record.get("machine_binding") or {}
if machine_id and isinstance(machine_binding, dict):
bound_id = (machine_binding.get("machine_id") or "").strip()
if bound_id and bound_id != machine_id:
base["status"] = "machine_mismatch"
base["notes"].append("machine_binding_mismatch")
return base
base["status"] = "valid"
base["valid"] = True
base["needs_activation"] = False
return base
def evaluate_override(payload, public_key_path, now=None):
if not payload:
return {"status": "missing", "valid": False, "allow_hardware_mismatch": False, "max_override_tier": None}
record = payload if isinstance(payload, dict) else {}
ok, sig_status = verify_signature(record, public_key_path)
if not ok:
return {
"status": "invalid_signature" if sig_status != "missing_signature" else "missing",
"valid": False,
"allow_hardware_mismatch": False,
"max_override_tier": None,
}
expires_at = parse_time(record.get("expires_at"))
now_dt = parse_time(now) if isinstance(now, str) else now or datetime.now(timezone.utc)
if expires_at and expires_at < now_dt:
return {"status": "expired", "valid": False, "allow_hardware_mismatch": False, "max_override_tier": None}
return {
"status": "valid",
"valid": True,
"allow_hardware_mismatch": bool(record.get("allow_hardware_mismatch")),
"max_override_tier": normalize_tier(record.get("max_override_tier"), "max"),
"override_record": public_license_record(record),
}
def build_tier_options(license_eval, hardware_tier, override_eval=None):
override_eval = override_eval or {}
licensed_cap = normalize_tier(license_eval.get("allowed_tier"), STAGING_MAX_TIER)
hardware_cap = normalize_tier(hardware_tier, STAGING_MAX_TIER)
override_cap = normalize_tier(override_eval.get("max_override_tier"), "max") if override_eval.get("allow_hardware_mismatch") else None
options = []
for tier in TIER_ORDER:
state = "enabled"
reason = ""
if not tier_lte(tier, licensed_cap):
state = "disabled_by_license"
reason = f"Not included in current license ({licensed_cap})."
elif not tier_lte(tier, hardware_cap):
if override_cap and tier_lte(tier, override_cap):
state = "override_required"
reason = f"Requires Cezen override; hardware recommendation is {hardware_cap}."
else:
state = "disabled_by_hardware"
reason = f"Requires larger hardware; recommendation is {hardware_cap}."
options.append({"tier": tier, "state": state, "reason": reason, "selectable": state in {"enabled", "override_required"}})
return options
def provisioned_components_from_record(record):
record = record or {}
components = record.get("components") or {}
if isinstance(components, dict):
return components
skip_roles = set(record.get("skip_roles") or [])
return {
"ollama": "ollama" not in skip_roles,
"jupyterlab": "jupyterlab" not in skip_roles,
"chromadb": "chromadb" not in skip_roles,
"vllm": "vllm" not in skip_roles,
"mlflow": "mlflow" not in skip_roles,
"minio": "minio" not in skip_roles,
"monitoring": "monitoring" not in skip_roles,
"k3s": "k3s" not in skip_roles,
}
def resolve_effective_features(base_features, feature_overrides, provisioned_components, hardware_features):
features = copy.deepcopy(base_features or {})
for key, value in (feature_overrides or {}).items():
features[key] = value
provisioned_components = provisioned_components or {}
hardware_features = hardware_features or {}
if features.get("rag") and not provisioned_components.get("chromadb", True):
features["rag"] = False
if features.get("gpu_inference") and not (
hardware_features.get("ollama_gpu") or hardware_features.get("vllm")
):
features["gpu_inference"] = False
if features.get("fine_tuning") and not (
provisioned_components.get("jupyterlab", False) and hardware_features.get("fine_tuning_qlora")
):
features["fine_tuning"] = False
if features.get("deepspeed") and not hardware_features.get("distributed_training"):
features["deepspeed"] = False
return features
def collect_entitlement(tier_matrix, license_record, install_record, feasibility, public_key_path, machine_id=None):
license_eval = evaluate_license(license_record, public_key_path, machine_id=machine_id)
override_eval = evaluate_override((install_record or {}).get("override_record") or read_json_file((install_record or {}).get("override_path", "")), public_key_path)
licensed_tier = normalize_tier(license_eval.get("allowed_tier"), STAGING_MAX_TIER)
provisioned_tier = normalize_tier((install_record or {}).get("provisioned_tier") or (install_record or {}).get("selected_tier"), STAGING_MAX_TIER)
hardware_tier = normalize_tier(((feasibility or {}).get("recommendation") or {}).get("recommended_tier"), STAGING_MAX_TIER)
components = provisioned_components_from_record(install_record)
tier_defaults = copy.deepcopy((tier_matrix or {}).get(licensed_tier, {}))
effective_features = resolve_effective_features(
tier_defaults.get("features", {}),
license_eval.get("feature_overrides", {}),
components,
(feasibility or {}).get("features", {}),
)
states = []
if tier_rank(licensed_tier) > tier_rank(provisioned_tier):
states.append("licensed_not_provisioned")
if tier_rank(provisioned_tier) > tier_rank(licensed_tier):
states.append("provisioned_above_license")
if tier_rank(provisioned_tier) > tier_rank(hardware_tier) and not override_eval.get("allow_hardware_mismatch"):
states.append("hardware_below_provisioned")
if license_eval.get("status") != "valid":
states.append(license_eval.get("status"))
return {
"license_status": license_eval.get("status"),
"license_valid": license_eval.get("valid"),
"license_record": public_license_record(license_record),
"licensed_tier": licensed_tier,
"provisioned_tier": provisioned_tier,
"hardware_recommended_tier": hardware_tier,
"effective_features": effective_features,
"feature_overrides": license_eval.get("feature_overrides", {}),
"provisioned_components": components,
"compliance_state": states or ["licensed_and_provisioned"],
"override": override_eval,
"install_type": license_eval.get("install_type") or (install_record or {}).get("install_type") or "field-staging",
}

View File

@ -27,6 +27,15 @@ import psutil
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from cezen_license import (
STAGING_MAX_TIER,
collect_entitlement,
evaluate_license,
normalize_tier as license_normalize_tier,
read_json_file,
tier_lte,
write_json_file,
)
# ── Config ──────────────────────────────────────────────────────────────────── # ── Config ────────────────────────────────────────────────────────────────────
@ -35,6 +44,8 @@ DB_PATH = DATA_DIR / "cezen.db"
SECRET_FILE = DATA_DIR / ".jwt_secret" SECRET_FILE = DATA_DIR / ".jwt_secret"
BACKUP_DIR = Path(os.environ.get("CEZEN_BACKUP_DIR", str(DATA_DIR.parent / "backups"))) 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")) LICENSE_FILE = Path(os.environ.get("CEZEN_LICENSE_JSON", "/opt/cezen/license.json"))
INSTALL_RECORD_FILE = Path(os.environ.get("CEZEN_INSTALL_RECORD_JSON", "/opt/cezen/install-record.json"))
LICENSE_PUBLIC_KEY_FILE = Path(os.environ.get("CEZEN_LICENSE_PUBLIC_KEY", str(Path(__file__).with_name("cezen-license-public.pem"))))
DATA_DIR.mkdir(parents=True, exist_ok=True) DATA_DIR.mkdir(parents=True, exist_ok=True)
BACKUP_DIR.mkdir(parents=True, exist_ok=True) BACKUP_DIR.mkdir(parents=True, exist_ok=True)
@ -821,23 +832,16 @@ def _setting_value(key: str, default: str = "") -> str:
db.close() db.close()
def _normalize_tier(value: str) -> str: def _normalize_tier(value: str) -> str:
raw = (value or "").strip().lower() return license_normalize_tier(value, "basic")
if raw in TIER_MATRIX:
return raw
return TIER_ALIASES.get(raw, "basic")
def _license_record() -> dict: def _license_record() -> dict:
try: return read_json_file(LICENSE_FILE)
if LICENSE_FILE.exists():
data = json.loads(LICENSE_FILE.read_text()) def _install_record() -> dict:
if isinstance(data, dict): return read_json_file(INSTALL_RECORD_FILE)
return data
except Exception:
pass
return {}
def _public_license_record(record: dict) -> dict: def _public_license_record(record: dict) -> dict:
public = {k: v for k, v in (record or {}).items() if k != "license_key"} public = {k: v for k, v in (record or {}).items() if k not in {"license_key", "signature"}}
if record.get("license_key"): if record.get("license_key"):
public["license_key_prefix"] = record["license_key"][:12] public["license_key_prefix"] = record["license_key"][:12]
public["license_key_present"] = True public["license_key_present"] = True
@ -848,26 +852,60 @@ def _public_license_record(record: dict) -> dict:
def _current_tier() -> str: def _current_tier() -> str:
if CEZEN_TIER: if CEZEN_TIER:
return _normalize_tier(CEZEN_TIER) return _normalize_tier(CEZEN_TIER)
license_tier = (_license_record().get("tier") or "").strip() entitlement = _entitlement_summary()
if license_tier: licensed_tier = (entitlement.get("licensed_tier") or "").strip()
return _normalize_tier(license_tier) if licensed_tier:
return _normalize_tier(licensed_tier)
return _normalize_tier(_setting_value("tier_label", "Basic")) return _normalize_tier(_setting_value("tier_label", "Basic"))
def _entitlement_summary(feasibility: Optional[dict] = None) -> dict:
feasibility = feasibility or _cached_feasibility()
return collect_entitlement(
TIER_MATRIX,
_license_record(),
_install_record(),
feasibility,
str(LICENSE_PUBLIC_KEY_FILE),
)
def _tier_payload() -> dict: def _tier_payload() -> dict:
tier = _current_tier() entitlement = _entitlement_summary()
tier = entitlement.get("licensed_tier") or _current_tier()
info = TIER_MATRIX[tier] info = TIER_MATRIX[tier]
license_record = _license_record() license_record = _license_record()
return { return {
"tier": tier, "tier": tier,
"label": info["label"], "label": info["label"],
"locked": bool(CEZEN_TIER or license_record.get("tier")), "locked": bool(CEZEN_TIER or license_record.get("allowed_tier") or license_record.get("tier")),
"positioning": info["positioning"], "positioning": info["positioning"],
"max_users": info["max_users"], "max_users": info["max_users"],
"features": info["features"], "features": entitlement.get("effective_features", info["features"]),
"feature_overrides": entitlement.get("feature_overrides", {}),
"provisioned_tier": entitlement.get("provisioned_tier"),
"hardware_recommended_tier": entitlement.get("hardware_recommended_tier"),
"compliance_state": entitlement.get("compliance_state", []),
"license_status": entitlement.get("license_status"),
"override": entitlement.get("override", {}),
"tiers": TIER_MATRIX, "tiers": TIER_MATRIX,
"license_record": _public_license_record(license_record), "license_record": _public_license_record(license_record),
} }
def _feature_enabled(feature_key: str, feasibility: Optional[dict] = None) -> bool:
entitlement = _entitlement_summary(feasibility)
value = (entitlement.get("effective_features") or {}).get(feature_key)
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.lower() not in {"", "false", "no", "none", "disabled", "optional"}
return bool(value)
def require_feature(feature_key: str):
def dependency(user: dict = Depends(current_user)):
if not _feature_enabled(feature_key):
raise HTTPException(status_code=403, detail=f"Feature '{feature_key}' is not licensed or provisioned")
return user
return dependency
def _readiness_score(feasibility: dict, license_info: dict) -> dict: def _readiness_score(feasibility: dict, license_info: dict) -> dict:
features = feasibility.get("features") or {} features = feasibility.get("features") or {}
recommendation = feasibility.get("recommendation") or {} recommendation = feasibility.get("recommendation") or {}
@ -1717,17 +1755,141 @@ async def system_feasibility(admin: dict = Depends(admin_only)):
}, },
} }
def _cached_feasibility() -> dict:
paths = [
Path(os.environ.get("CEZEN_FEASIBILITY_JSON", "")),
DATA_DIR / "feasibility.json",
Path("/opt/cezen/feasibility.json"),
]
for p in paths:
if p and str(p) != "." and p.exists():
try:
return json.loads(p.read_text())
except Exception:
pass
metrics_now = collect_metrics()
gpu_vram = metrics_now.get("gpu_mem_total_gb") or 0
return {
"schema": "cezen.feasibility.cached-fallback.v1",
"generated_at": utcnow(),
"recommendation": {
"recommended_tier": STAGING_MAX_TIER,
"recommended_profile": "core",
"estimated_concurrent_users": "1-2",
"notes": [],
},
"features": {
"ollama_gpu": gpu_vram >= 8,
"vllm": gpu_vram >= 24,
"fine_tuning_qlora": gpu_vram >= 24,
"distributed_training": False,
},
}
@app.get("/api/license") @app.get("/api/license")
async def license_info(user: dict = Depends(current_user)): async def license_info(user: dict = Depends(current_user)):
return _tier_payload() payload = _tier_payload()
payload["entitlement"] = _entitlement_summary()
return payload
@app.post("/api/license/upload")
async def upload_license(body: dict, request: Request, admin: dict = Depends(admin_only)):
raw_license = (body.get("license_json") or "").strip()
raw_override = (body.get("override_json") or "").strip()
if not raw_license:
raise HTTPException(400, "license_json required")
try:
license_record = json.loads(raw_license)
if not isinstance(license_record, dict):
raise ValueError("license_json must be a JSON object")
except Exception as exc:
raise HTTPException(400, f"Invalid license_json: {exc}")
if raw_override:
try:
override_record = json.loads(raw_override)
if not isinstance(override_record, dict):
raise ValueError("override_json must be a JSON object")
except Exception as exc:
raise HTTPException(400, f"Invalid override_json: {exc}")
write_json_file(Path("/opt/cezen/license.override.json"), override_record)
else:
Path("/opt/cezen/license.override.json").unlink(missing_ok=True)
write_json_file(LICENSE_FILE, license_record)
entitlement = _entitlement_summary()
db = get_db()
audit(
db,
admin["sub"],
admin["username"],
"license_upload",
f"status={entitlement.get('license_status')} licensed_tier={entitlement.get('licensed_tier')}",
request.client.host if request.client else "",
"success" if entitlement.get("license_valid") else "failure",
)
db.commit()
db.close()
return {"ok": True, "entitlement": entitlement}
@app.post("/api/license/reverify")
async def reverify_license(request: Request, admin: dict = Depends(admin_only)):
entitlement = _entitlement_summary()
db = get_db()
audit(
db,
admin["sub"],
admin["username"],
"license_reverify",
f"status={entitlement.get('license_status')}",
request.client.host if request.client else "",
"success" if entitlement.get("license_valid") else "failure",
)
db.commit()
db.close()
return {"ok": True, "entitlement": entitlement}
@app.post("/api/license/reconcile")
async def reconcile_license(request: Request, admin: dict = Depends(admin_only)):
entitlement = _entitlement_summary()
record = _install_record()
requested_tier = entitlement.get("licensed_tier") or record.get("provisioned_tier") or STAGING_MAX_TIER
pending_roles = []
if not tier_lte(requested_tier, record.get("provisioned_tier") or STAGING_MAX_TIER):
if requested_tier in {"pro", "max"} and not (record.get("components") or {}).get("vllm", False):
pending_roles.append("vllm")
if requested_tier == "max":
for role in ("mlflow", "minio", "k3s"):
if not (record.get("components") or {}).get(role, False):
pending_roles.append(role)
reconcile_record = {
"schema": "cezen.reconcile_request.v1",
"requested_at": utcnow(),
"requested_by": admin["username"],
"target_tier": requested_tier,
"pending_roles": pending_roles,
"status": "pending" if pending_roles else "noop",
}
write_json_file(Path("/opt/cezen/reconcile-request.json"), reconcile_record)
db = get_db()
audit(
db,
admin["sub"],
admin["username"],
"license_reconcile",
f"target_tier={requested_tier} pending_roles={','.join(pending_roles) or 'none'}",
request.client.host if request.client else "",
)
db.commit()
db.close()
return {"ok": True, "reconcile": reconcile_record, "entitlement": entitlement}
@app.get("/api/system/readiness-report") @app.get("/api/system/readiness-report")
async def readiness_report(admin: dict = Depends(admin_only)): async def readiness_report(admin: dict = Depends(admin_only)):
feasibility = await system_feasibility(admin) feasibility = await system_feasibility(admin)
license_payload = _tier_payload() license_payload = _tier_payload()
entitlement = _entitlement_summary(feasibility)
readiness = _readiness_score(feasibility, license_payload) readiness = _readiness_score(feasibility, license_payload)
recommended_tier = (feasibility.get("recommendation") or {}).get("recommended_tier", "starter") recommended_tier = (feasibility.get("recommendation") or {}).get("recommended_tier", "starter")
current_rank = list(TIER_MATRIX.keys()).index(license_payload["tier"]) current_rank = list(TIER_MATRIX.keys()).index(entitlement.get("licensed_tier") or license_payload["tier"])
recommended_rank = list(TIER_MATRIX.keys()).index(_normalize_tier(recommended_tier)) recommended_rank = list(TIER_MATRIX.keys()).index(_normalize_tier(recommended_tier))
commercial_fit = "matched" if current_rank >= recommended_rank else "license_upgrade_recommended" commercial_fit = "matched" if current_rank >= recommended_rank else "license_upgrade_recommended"
return { return {
@ -1735,11 +1897,13 @@ async def readiness_report(admin: dict = Depends(admin_only)):
"generated_at": utcnow(), "generated_at": utcnow(),
"customer_mode": "software_only" if (feasibility.get("features") or {}).get("software_only", True) else "appliance", "customer_mode": "software_only" if (feasibility.get("features") or {}).get("software_only", True) else "appliance",
"license": license_payload, "license": license_payload,
"entitlement": entitlement,
"feasibility": feasibility, "feasibility": feasibility,
"readiness": readiness, "readiness": readiness,
"commercial_fit": { "commercial_fit": {
"status": commercial_fit, "status": commercial_fit,
"current_tier": license_payload["tier"], "current_tier": entitlement.get("licensed_tier") or license_payload["tier"],
"provisioned_tier": entitlement.get("provisioned_tier"),
"recommended_tier": _normalize_tier(recommended_tier), "recommended_tier": _normalize_tier(recommended_tier),
"note": "Current license covers the recommended deployment." if commercial_fit == "matched" else "Quote a higher tier or reduce enabled features for this hardware.", "note": "Current license covers the recommended deployment." if commercial_fit == "matched" else "Quote a higher tier or reduce enabled features for this hardware.",
}, },
@ -1829,8 +1993,9 @@ async def get_branding():
db.close() db.close()
result = {r["key"]: r["value"] for r in rows} result = {r["key"]: r["value"] for r in rows}
license_record = _license_record() license_record = _license_record()
entitlement = _entitlement_summary()
# If Cezen has locked the tier via env var, override whatever is in DB # If Cezen has locked the tier via env var, override whatever is in DB
if CEZEN_TIER or license_record.get("tier"): if CEZEN_TIER or license_record.get("allowed_tier") or license_record.get("tier"):
tier = _current_tier() tier = _current_tier()
result["tier_label"] = TIER_MATRIX[tier]["label"] result["tier_label"] = TIER_MATRIX[tier]["label"]
result["tier_slug"] = tier result["tier_slug"] = tier
@ -1842,13 +2007,15 @@ async def get_branding():
result["license_customer_id"] = license_record.get("customer_id", "") result["license_customer_id"] = license_record.get("customer_id", "")
result["license_key_prefix"] = (license_record.get("license_key", "") or "")[:12] result["license_key_prefix"] = (license_record.get("license_key", "") or "")[:12]
result["license_support_until"] = license_record.get("support_until", "") result["license_support_until"] = license_record.get("support_until", "")
result["license_status"] = entitlement.get("license_status", "missing")
result["provisioned_tier"] = entitlement.get("provisioned_tier", "")
return result return result
@app.put("/api/settings/branding") @app.put("/api/settings/branding")
async def update_branding(body: dict, admin: dict = Depends(admin_only)): async def update_branding(body: dict, admin: dict = Depends(admin_only)):
# tier_label is always excluded from customer-editable fields when locked # tier_label is always excluded from customer-editable fields when locked
allowed = {"org_name","stack_name","logo_url","accent_color","footer_text","support_email"} allowed = {"org_name","stack_name","logo_url","accent_color","footer_text","support_email"}
if not CEZEN_TIER: if not (CEZEN_TIER or _license_record().get("allowed_tier") or _license_record().get("tier")):
# Only allow tier changes when NOT locked by env var (dev/demo mode) # Only allow tier changes when NOT locked by env var (dev/demo mode)
allowed.add("tier_label") allowed.add("tier_label")
db = get_db() db = get_db()
@ -2149,7 +2316,7 @@ def _count_rows(path: Path, orig_name: str) -> int:
return 0 return 0
@app.post("/api/training/datasets") @app.post("/api/training/datasets")
async def upload_dataset(file: UploadFile = File(...), admin: dict = Depends(admin_only)): async def upload_dataset(file: UploadFile = File(...), _licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only)):
"""Upload a JSONL or CSV dataset for fine-tuning.""" """Upload a JSONL or CSV dataset for fine-tuning."""
allowed_ext = {".jsonl", ".json", ".csv"} allowed_ext = {".jsonl", ".json", ".csv"}
orig = file.filename or "dataset" orig = file.filename or "dataset"
@ -2180,7 +2347,7 @@ async def upload_dataset(file: UploadFile = File(...), admin: dict = Depends(adm
return {"id": row_id, "filename": orig, "size_bytes": len(data), "row_count": row_count} return {"id": row_id, "filename": orig, "size_bytes": len(data), "row_count": row_count}
@app.get("/api/training/datasets") @app.get("/api/training/datasets")
async def list_datasets(admin: dict = Depends(admin_only)): async def list_datasets(_licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only)):
db = get_db() db = get_db()
rows = db.execute( rows = db.execute(
"SELECT id, orig_name, size_bytes, row_count, uploaded_at FROM training_datasets ORDER BY uploaded_at DESC" "SELECT id, orig_name, size_bytes, row_count, uploaded_at FROM training_datasets ORDER BY uploaded_at DESC"
@ -2189,7 +2356,7 @@ async def list_datasets(admin: dict = Depends(admin_only)):
return {"datasets": [dict(r) for r in rows]} return {"datasets": [dict(r) for r in rows]}
@app.delete("/api/training/datasets/{dataset_id}") @app.delete("/api/training/datasets/{dataset_id}")
async def delete_dataset(dataset_id: int, admin: dict = Depends(admin_only)): async def delete_dataset(dataset_id: int, _licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only)):
db = get_db() db = get_db()
row = db.execute("SELECT * FROM training_datasets WHERE id=?", (dataset_id,)).fetchone() row = db.execute("SELECT * FROM training_datasets WHERE id=?", (dataset_id,)).fetchone()
if not row: if not row:
@ -2212,7 +2379,7 @@ async def delete_dataset(dataset_id: int, admin: dict = Depends(admin_only)):
return {"ok": True} return {"ok": True}
@app.post("/api/training/jobs") @app.post("/api/training/jobs")
async def launch_job(body: dict, admin: dict = Depends(admin_only)): async def launch_job(body: dict, _licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only)):
"""Launch a QLoRA fine-tuning job as a background subprocess.""" """Launch a QLoRA fine-tuning job as a background subprocess."""
required = {"name", "base_model", "dataset_id"} required = {"name", "base_model", "dataset_id"}
if not required.issubset(body): if not required.issubset(body):
@ -2284,7 +2451,7 @@ async def launch_job(body: dict, admin: dict = Depends(admin_only)):
return {"job_id": job_id, "status": "running"} return {"job_id": job_id, "status": "running"}
@app.get("/api/training/jobs") @app.get("/api/training/jobs")
async def list_jobs(admin: dict = Depends(admin_only)): async def list_jobs(_licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only)):
db = get_db() db = get_db()
rows = db.execute( rows = db.execute(
"""SELECT j.id, j.name, j.base_model, j.status, j.config_json, """SELECT j.id, j.name, j.base_model, j.status, j.config_json,
@ -2303,7 +2470,7 @@ async def list_jobs(admin: dict = Depends(admin_only)):
return {"jobs": result} return {"jobs": result}
@app.get("/api/training/jobs/{job_id}") @app.get("/api/training/jobs/{job_id}")
async def get_job(job_id: int, admin: dict = Depends(admin_only), tail: int = 200): async def get_job(job_id: int, _licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only), tail: int = 200):
db = get_db() db = get_db()
row = db.execute( row = db.execute(
"""SELECT j.*, d.orig_name as dataset_name """SELECT j.*, d.orig_name as dataset_name
@ -2338,7 +2505,7 @@ async def get_job(job_id: int, admin: dict = Depends(admin_only), tail: int = 20
return item return item
@app.delete("/api/training/jobs/{job_id}") @app.delete("/api/training/jobs/{job_id}")
async def cancel_job(job_id: int, admin: dict = Depends(admin_only)): async def cancel_job(job_id: int, _licensed: dict = Depends(require_feature("fine_tuning")), admin: dict = Depends(admin_only)):
db = get_db() db = get_db()
row = db.execute("SELECT * FROM training_jobs WHERE id=?", (job_id,)).fetchone() row = db.execute("SELECT * FROM training_jobs WHERE id=?", (job_id,)).fetchone()
if not row: if not row:
@ -2723,7 +2890,7 @@ class BenchmarkRun(BaseModel):
max_tokens: int = 256 max_tokens: int = 256
@app.post("/api/benchmark/run") @app.post("/api/benchmark/run")
async def run_benchmark(body: BenchmarkRun, admin: dict = Depends(admin_only)): async def run_benchmark(body: BenchmarkRun, _licensed: dict = Depends(require_feature("gpu_inference")), admin: dict = Depends(admin_only)):
""" """
Runs each prompt against each model and returns timing + response. Runs each prompt against each model and returns timing + response.
Calls Ollama /api/generate for each (model, prompt) pair sequentially. Calls Ollama /api/generate for each (model, prompt) pair sequentially.
@ -5171,14 +5338,14 @@ async def run_workflow_api(workflow_id: str, body: dict, user: dict = Depends(cu
return result return result
@app.get("/api/connectors") @app.get("/api/connectors")
async def list_connectors(user: dict = Depends(current_user)): async def list_connectors(user: dict = Depends(require_feature("connectors"))):
db = get_db() db = get_db()
rows = db.execute("SELECT * FROM connectors ORDER BY updated_at DESC").fetchall() rows = db.execute("SELECT * FROM connectors ORDER BY updated_at DESC").fetchall()
db.close() db.close()
return [_connector_from_row(r) for r in rows] return [_connector_from_row(r) for r in rows]
@app.put("/api/connectors") @app.put("/api/connectors")
async def save_connectors(body: list[dict], user: dict = Depends(current_user)): async def save_connectors(body: list[dict], user: dict = Depends(require_feature("connectors"))):
now = utcnow() now = utcnow()
db = get_db() db = get_db()
seen = [] seen = []
@ -5284,7 +5451,7 @@ def _connector_db_stats(cfg: dict) -> tuple[int, int]:
raise ValueError(f"Unsupported database connector type: {db_type}") raise ValueError(f"Unsupported database connector type: {db_type}")
@app.post("/api/connectors/{connector_id}/sync") @app.post("/api/connectors/{connector_id}/sync")
async def sync_connector(connector_id: str, user: dict = Depends(current_user)): async def sync_connector(connector_id: str, user: dict = Depends(require_feature("connectors"))):
db = get_db() db = get_db()
row = db.execute("SELECT * FROM connectors WHERE id=?", (connector_id,)).fetchone() row = db.execute("SELECT * FROM connectors WHERE id=?", (connector_id,)).fetchone()
if not row: if not row:
@ -5327,14 +5494,14 @@ async def sync_connector(connector_id: str, user: dict = Depends(current_user)):
return {"ok": True, "files": stats.get("files", 0), "rowsRead": stats.get("rowsRead", 0), "status": status} return {"ok": True, "files": stats.get("files", 0), "rowsRead": stats.get("rowsRead", 0), "status": status}
@app.get("/api/connectors/log") @app.get("/api/connectors/log")
async def connector_log(limit: int = 30, user: dict = Depends(current_user)): async def connector_log(limit: int = 30, user: dict = Depends(require_feature("connectors"))):
db = get_db() db = get_db()
rows = db.execute("SELECT level,msg,ts FROM connector_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall() rows = db.execute("SELECT level,msg,ts FROM connector_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
db.close() db.close()
return {"lines": [dict(r) for r in reversed(rows)]} return {"lines": [dict(r) for r in reversed(rows)]}
@app.get("/api/router/rules") @app.get("/api/router/rules")
async def get_router_rules(user: dict = Depends(current_user)): async def get_router_rules(user: dict = Depends(require_feature("model_router"))):
db = get_db() db = get_db()
rows = db.execute("SELECT * FROM router_rules ORDER BY priority, id").fetchall() rows = db.execute("SELECT * FROM router_rules ORDER BY priority, id").fetchall()
db.close() db.close()
@ -5348,7 +5515,7 @@ async def get_router_rules(user: dict = Depends(current_user)):
return {"routes": routes} return {"routes": routes}
@app.put("/api/router/rules") @app.put("/api/router/rules")
async def save_router_rules(body: dict, admin: dict = Depends(admin_only)): async def save_router_rules(body: dict, _licensed: dict = Depends(require_feature("model_router")), admin: dict = Depends(admin_only)):
routes = body.get("routes") or [] routes = body.get("routes") or []
db = get_db() db = get_db()
db.execute("DELETE FROM router_rules") db.execute("DELETE FROM router_rules")
@ -5364,7 +5531,7 @@ async def save_router_rules(body: dict, admin: dict = Depends(admin_only)):
return {"ok": True, "routes": len(routes)} return {"ok": True, "routes": len(routes)}
@app.put("/api/router/fallback") @app.put("/api/router/fallback")
async def save_router_fallback(body: dict, admin: dict = Depends(admin_only)): async def save_router_fallback(body: dict, _licensed: dict = Depends(require_feature("model_router")), admin: dict = Depends(admin_only)):
db = get_db() db = get_db()
for key, value in body.items(): for key, value in body.items():
if key == "cloud_key" and value: if key == "cloud_key" and value:
@ -5449,7 +5616,7 @@ async def rag_quality_warnings(admin: dict = Depends(admin_only)):
return {"warnings": warnings} return {"warnings": warnings}
@app.post("/api/meeting/analyse") @app.post("/api/meeting/analyse")
async def analyse_meeting(body: dict, user: dict = Depends(current_user)): async def analyse_meeting(body: dict, user: dict = Depends(require_feature("meeting_assistant"))):
transcript = (body.get("transcript") or "").strip() transcript = (body.get("transcript") or "").strip()
if not transcript: if not transcript:
raise HTTPException(400, "transcript required") raise HTTPException(400, "transcript required")
@ -5466,7 +5633,7 @@ async def analyse_meeting(body: dict, user: dict = Depends(current_user)):
return result return result
@app.post("/api/meeting/process") @app.post("/api/meeting/process")
async def process_meeting(file: UploadFile = File(...), meta: str = Form("{}"), user: dict = Depends(current_user)): async def process_meeting(file: UploadFile = File(...), meta: str = Form("{}"), user: dict = Depends(require_feature("meeting_assistant"))):
try: try:
meta_obj = json.loads(meta) if isinstance(meta, str) else {} meta_obj = json.loads(meta) if isinstance(meta, str) else {}
except Exception: except Exception:

View File

@ -52,6 +52,24 @@
mode: "0644" mode: "0644"
notify: Restart cezen-api notify: Restart cezen-api
- name: Copy license entitlement helper
copy:
src: cezen_license.py
dest: /opt/cezen/backend/cezen_license.py
owner: "{{ cezen_user }}"
group: "{{ cezen_user }}"
mode: "0644"
notify: Restart cezen-api
- name: Copy Cezen license public key
copy:
src: cezen-license-public.pem
dest: /opt/cezen/backend/cezen-license-public.pem
owner: "{{ cezen_user }}"
group: "{{ cezen_user }}"
mode: "0644"
notify: Restart cezen-api
- name: Copy QLoRA training runner - name: Copy QLoRA training runner
copy: copy:
src: train_qlora.py src: train_qlora.py

View File

@ -185,10 +185,13 @@ echo "╔═══════════════════════
echo "║ Done! ║" echo "║ Done! ║"
echo "╚══════════════════════════════════════════════════════╝" echo "╚══════════════════════════════════════════════════════╝"
echo "" echo ""
echo "→ ISO path:"
echo " $OUTPUT_ISO"
echo ""
ls -lh "$OUTPUT_ISO" ls -lh "$OUTPUT_ISO"
echo "" echo ""
echo "→ Copy to your MacBook:" 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 ""
echo "→ Flash to USB on MacBook:" echo "→ Flash to USB on MacBook:"
echo " diskutil list # find USB e.g. /dev/disk4" echo " diskutil list # find USB e.g. /dev/disk4"

View File

@ -7,6 +7,9 @@
set -e set -e
AIPACKAGE_DIR="/opt/aipackage" 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" LOG_FILE="/var/log/cezen-setup.log"
INSTALL_LOG_FILE="/var/log/cezen-install.log" INSTALL_LOG_FILE="/var/log/cezen-install.log"
export TERM="${TERM:-linux}" 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." \ --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) $H $W "" 3>&1 1>&2 2>&3)
LICENSE_KEY=$(whiptail --title "$TITLE" \ LICENSE_PATH=$(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." \ --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) $H $W "" 3>&1 1>&2 2>&3)
SUPPORT_UNTIL=$(whiptail --title "$TITLE" \ 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) $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 # 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" \ 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 \ $H $W 4 \
"starter" "Starter — 1× RTX 5090 / 32GB VRAM · Small team" \ "${TIER_MENU[@]}" \
"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 — 48× H100/H200/A100 class · 100+ users" \
3>&1 1>&2 2>&3) 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') TOOLS_DISPLAY=$(echo "$TOOLS" | tr -d '"' | tr ' ' '\n' | sed 's/^/ · /' | tr '\n' '\n')
MY_IP=$(hostname -I | awk '{print $1}') MY_IP=$(hostname -I | awk '{print $1}')
LICENSE_DISPLAY="Field staging / evaluation" LICENSE_DISPLAY="Field staging / evaluation"
if [ -n "$LICENSE_KEY" ]; then if [ -n "$LICENSE_PATH" ]; then
LICENSE_DISPLAY="Provided" LICENSE_DISPLAY="Signed file (${LICENSE_STATUS})"
fi fi
whiptail --title "$TITLE" \ 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 2040 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 2040 minutes.\nThe server will reboot once during install (NVIDIA drivers).\n\nContinue?" \
$H $W $H $W
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@ -271,29 +355,30 @@ TIER=${TIER}
SKIP_ROLES=${SKIP_ROLES} SKIP_ROLES=${SKIP_ROLES}
EOF 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' python3 - <<'PY'
import json, os, time import json, os, time
payload = { payload = {
"schema": "cezen.license.v1", "schema": "cezen.install_record.v1",
"customer_name": os.environ.get("CUSTOMER_NAME", "").strip(), "customer_name": os.environ.get("CUSTOMER_NAME", "").strip(),
"customer_id": os.environ.get("CUSTOMER_ID", "").strip(), "customer_id": os.environ.get("CUSTOMER_ID", "").strip(),
"contact_email": os.environ.get("CONTACT_EMAIL", "").strip(), "contact_email": os.environ.get("CONTACT_EMAIL", "").strip(),
"license_key": os.environ.get("LICENSE_KEY", "").strip(), "selected_tier": os.environ.get("TIER", "basic").strip(),
"tier": os.environ.get("TIER", "basic").strip(), "provisioned_tier": os.environ.get("TIER", "basic").strip(),
"support_until": os.environ.get("SUPPORT_UNTIL", "").strip(), "support_until": os.environ.get("SUPPORT_UNTIL", "").strip(),
"install_type": "licensed" if os.environ.get("LICENSE_KEY", "").strip() else "field-staging", "install_type": "licensed" if os.path.exists("/opt/cezen/license.json") else "field-staging",
"issued_by": "Cezen", "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()), "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) json.dump(payload, f, indent=2)
PY PY
chown root:cezen /opt/cezen/license.json 2>/dev/null || true chown root:cezen /opt/cezen/install-record.json 2>/dev/null || true
chmod 0640 /opt/cezen/license.json chmod 0640 /opt/cezen/install-record.json
whiptail --title "$TITLE" \ 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" \ --infobox "\nInstalling Nexus One AI stack...\n\nThis can take several minutes.\n\nLogs are being written to:\n $INSTALL_LOG_FILE" \

View 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-----

View File

@ -3,15 +3,28 @@
Nexus One AI First Boot Web Setup Server Nexus One AI First Boot Web Setup Server
Serves on port 80. Access from any browser on the same network. 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 http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
SETUP_DONE_FILE = "/opt/cezen/.setup-done" SETUP_DONE_FILE = "/opt/cezen/.setup-done"
INSTALL_LOG = "/var/log/cezen-install.log" INSTALL_LOG = "/var/log/cezen-install.log"
AIPACKAGE_DIR = "/opt/aipackage" 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_proc = None
install_status = {"running": False, "done": False, "error": 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 ────────────────────────────────────────────── # ─── Helpers ──────────────────────────────────────────────
def get_ip(): def get_ip():
@ -91,35 +104,84 @@ def apply_static_ip(iface, ip, prefix, gateway, dns):
subprocess.run(["netplan", "apply"], capture_output=True) subprocess.run(["netplan", "apply"], capture_output=True)
time.sleep(3) time.sleep(3)
def write_license_file(license_data, tier): def save_license_artifacts(license_data):
os.makedirs("/opt/cezen", exist_ok=True) 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 = { 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_name": (license_data.get("customer_name") or "").strip(),
"customer_id": (license_data.get("customer_id") or "").strip(), "customer_id": (license_data.get("customer_id") or "").strip(),
"contact_email": (license_data.get("contact_email") 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(), "support_until": (license_data.get("support_until") or "").strip(),
"install_type": (license_data.get("install_type") or "customer").strip(), "selected_tier": tier,
"issued_by": "Cezen", "provisioned_tier": tier,
"captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "licensed_tier": ((preview.get("license") or {}).get("allowed_tier") or "basic"),
"signature_status": "unsigned-field-install", "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: write_json_file(INSTALL_RECORD_JSON, payload)
json.dump(payload, f, indent=2) subprocess.run(["chown", "root:cezen", INSTALL_RECORD_JSON], check=False)
subprocess.run(["chown", "root:cezen", "/opt/cezen/license.json"], check=False) os.chmod(INSTALL_RECORD_JSON, 0o640)
os.chmod("/opt/cezen/license.json", 0o640)
return payload
def run_install(tier, skip_tools, license_data=None): def run_install(tier, skip_tools, license_data=None, preview=None):
global install_status global install_status
install_status = {"running": True, "done": False, "error": None} install_status = {"running": True, "done": False, "error": None}
try: try:
# Write config so phase 2 (post-reboot) knows what to skip # Write config so phase 2 (post-reboot) knows what to skip
os.makedirs("/opt/cezen", exist_ok=True) os.makedirs("/opt/cezen", exist_ok=True)
skip_str = ",".join(skip_tools) if skip_tools else "" 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: with open("/opt/cezen/install.conf", "w") as f:
f.write(f"TIER={tier}\nSKIP_ROLES={skip_str}\n") f.write(f"TIER={tier}\nSKIP_ROLES={skip_str}\n")
@ -187,9 +249,13 @@ HTML = r"""<!DOCTYPE html>
transition: all .2s; text-align: center; } transition: all .2s; text-align: center; }
.tier-card:hover { border-color: var(--teal2); background: #F0FDFA; } .tier-card:hover { border-color: var(--teal2); background: #F0FDFA; }
.tier-card.selected { border-color: var(--teal); background: #CCFBF1; } .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-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-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-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 toggles */
.tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .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 { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 600; color: var(--navy); margin-bottom: 6px; } .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; } 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 { display: none; }
.static-fields.show { display: block; } .static-fields.show { display: block; }
.ip-row { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; } .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 id="step-2" class="hidden">
<div class="card"> <div class="card">
<h2>License & Customer Details</h2> <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"> <div class="form-group">
<label>Customer / Organisation Name</label> <label>Customer / Organisation Name</label>
<input type="text" id="lic-customer-name" placeholder="e.g. Bharat Electronics Limited"> <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"> <input type="email" id="lic-contact-email" placeholder="admin@example.gov.in">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>License Key / Activation Code</label> <label>Signed License JSON</label>
<input type="text" id="lic-license-key" placeholder="Optional for offline field install"> <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>
<div class="form-group"> <div class="form-group">
<label>Support Until</label> <label>Support Until</label>
<input type="date" id="lic-support-until"> <input type="date" id="lic-support-until">
</div> </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>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-secondary" onclick="goStep(1)"> Back</button> <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>
</div> </div>
@ -376,21 +451,25 @@ HTML = r"""<!DOCTYPE html>
<div class="tier-name">Starter</div> <div class="tier-name">Starter</div>
<div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div> <div class="tier-gpu">1× RTX 5090 / 32GB VRAM</div>
<div class="tier-users">Small team deployment</div> <div class="tier-users">Small team deployment</div>
<div class="tier-note" id="tier-note-starter"></div>
</div> </div>
<div class="tier-card" id="tier-basic" onclick="selectTier('basic')"> <div class="tier-card" id="tier-basic" onclick="selectTier('basic')">
<div class="tier-name">Entry</div> <div class="tier-name">Entry</div>
<div class="tier-gpu">1× NVIDIA RTX Pro 6000 (96GB)</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-users">Up to 20 concurrent users</div>
<div class="tier-note" id="tier-note-basic"></div>
</div> </div>
<div class="tier-card" id="tier-pro" onclick="selectTier('pro')"> <div class="tier-card" id="tier-pro" onclick="selectTier('pro')">
<div class="tier-name">Pro</div> <div class="tier-name">Pro</div>
<div class="tier-gpu">2× RTX 5090 / RTX Pro class</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-users">Up to 100 concurrent users</div>
<div class="tier-note" id="tier-note-pro"></div>
</div> </div>
<div class="tier-card" id="tier-max" onclick="selectTier('max')"> <div class="tier-card" id="tier-max" onclick="selectTier('max')">
<div class="tier-name">Max</div> <div class="tier-name">Max</div>
<div class="tier-gpu">48× H100/H200/A100 class</div> <div class="tier-gpu">48× H100/H200/A100 class</div>
<div class="tier-users">200+ concurrent users</div> <div class="tier-users">200+ concurrent users</div>
<div class="tier-note" id="tier-note-max"></div>
</div> </div>
</div> </div>
</div> </div>
@ -461,6 +540,7 @@ HTML = r"""<!DOCTYPE html>
// State // State
let netMode = 'dhcp'; let netMode = 'dhcp';
let selectedTier = 'basic'; let selectedTier = 'basic';
let tierPreview = null;
let tools = { let tools = {
ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true }, ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true },
jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', 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'; document.getElementById('current-ip').textContent = d.ip || 'unknown';
}); });
renderTools(); renderTools();
selectTier('basic'); renderTierAvailability(null);
}; };
// Navigation // Navigation
@ -525,7 +605,28 @@ function applyStaticIP() {
} }
// Tier // 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) { function selectTier(t) {
const opt = (tierPreview?.tier_options || []).find(x => x.tier === t);
if (opt && !opt.selectable) return;
selectedTier = t; selectedTier = t;
['starter','basic','pro','max'].forEach(x => ['starter','basic','pro','max'].forEach(x =>
document.getElementById('tier-'+x).classList.toggle('selected', x===t)); 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_name: document.getElementById('lic-customer-name').value.trim(),
customer_id: document.getElementById('lic-customer-id').value.trim(), customer_id: document.getElementById('lic-customer-id').value.trim(),
contact_email: document.getElementById('lic-contact-email').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(), 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 // Summary
function renderSummary() { function renderSummary() {
const ip = netMode === 'dhcp' const ip = netMode === 'dhcp'
@ -573,7 +694,9 @@ function renderSummary() {
document.getElementById('summary-rows').innerHTML = ` 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">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">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">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> <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>` : ''} ${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', method: 'POST',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify({ tier: selectedTier, skip_tools: skip, license: collectLicense() }) 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(); streamLog();
@ -780,13 +911,29 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e: except Exception as e:
self.send_json({"ok": False, "error": str(e)}, 500) 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": elif path == "/api/install":
global install_proc global install_proc
tier = body.get("tier", "basic") tier = body.get("tier", "basic")
skip = body.get("skip_tools", []) skip = body.get("skip_tools", [])
license_data = body.get("license", {}) 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"]: 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() t.start()
self.send_json({"ok": True}) self.send_json({"ok": True})
else: else:

View File

@ -170,7 +170,7 @@
</div> </div>
</div> </div>
</nav> </nav>
<a href="notifications.html" style="position:relative">🔔</a> <a href="notifications.html" class="nav-icon-btn" style="position:relative" aria-label="Notifications"><span class="icon-svg icon-sm" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M15 17h5l-1.4-1.4A2 2 0 0 1 18 14.2V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"/><path d="M10 17a2 2 0 0 0 4 0"/></svg></span></a>
<span class="badge" data-brand="tier">Basic Tier</span> <span class="badge" data-brand="tier">Basic Tier</span>
<div id="nav-org-logo" class="nav-org-logo"></div> <div id="nav-org-logo" class="nav-org-logo"></div>
</header> </header>
@ -186,6 +186,9 @@
<button class="ops-btn primary" onclick="loadAll()">Refresh</button> <button class="ops-btn primary" onclick="loadAll()">Refresh</button>
<button class="ops-btn" onclick="createBackup()">Create Backup</button> <button class="ops-btn" onclick="createBackup()">Create Backup</button>
<button class="ops-btn" onclick="exportReadiness()">Export Readiness JSON</button> <button class="ops-btn" onclick="exportReadiness()">Export Readiness JSON</button>
<button class="ops-btn" onclick="uploadLicense()">Upload License</button>
<button class="ops-btn" onclick="reverifyLicense()">Reverify License</button>
<button class="ops-btn" onclick="reconcileLicense()">Reconcile Provisioning</button>
</div> </div>
<div class="ops-grid"> <div class="ops-grid">
@ -217,6 +220,16 @@
<div class="ops-list" id="hardware-list"><div class="empty">Loading…</div></div> <div class="ops-list" id="hardware-list"><div class="empty">Loading…</div></div>
</section> </section>
<section class="ops-card span-6">
<h2>Entitlement</h2>
<div class="ops-list" id="entitlement-list"><div class="empty">Loading…</div></div>
</section>
<section class="ops-card span-6">
<h2>Feature Access</h2>
<div class="ops-list" id="features-list"><div class="empty">Loading…</div></div>
</section>
<section class="ops-card span-8"> <section class="ops-card span-8">
<h2>Readiness Checks</h2> <h2>Readiness Checks</h2>
<div id="checks-list"><div class="empty">Loading…</div></div> <div id="checks-list"><div class="empty">Loading…</div></div>
@ -271,12 +284,13 @@ function renderReadiness(d) {
readinessData = d; readinessData = d;
const score = d.readiness?.score ?? 0; const score = d.readiness?.score ?? 0;
const status = d.readiness?.status || 'unknown'; const status = d.readiness?.status || 'unknown';
const entitlement = d.entitlement || d.license?.entitlement || {};
document.getElementById('readiness-score').textContent = score + '%'; document.getElementById('readiness-score').textContent = score + '%';
const statusEl = document.getElementById('readiness-status'); const statusEl = document.getElementById('readiness-status');
statusEl.textContent = status.replace(/_/g, ' '); statusEl.textContent = status.replace(/_/g, ' ');
statusEl.className = 'pill ' + (status === 'ready' ? 'ok' : status === 'limited' ? 'warn' : 'bad'); statusEl.className = 'pill ' + (status === 'ready' ? 'ok' : status === 'limited' ? 'warn' : 'bad');
document.getElementById('license-tier').textContent = d.license?.label || '—'; document.getElementById('license-tier').textContent = d.license?.label || '—';
document.getElementById('license-position').textContent = d.license?.positioning || '—'; document.getElementById('license-position').textContent = `${d.license?.positioning || '—'} · status: ${entitlement.license_status || d.license?.license_status || 'unknown'}`;
const auditFit = d.commercial_fit || {}; const auditFit = d.commercial_fit || {};
const rec = d.feasibility?.recommendation || {}; const rec = d.feasibility?.recommendation || {};
@ -299,6 +313,22 @@ function renderReadiness(d) {
['GPU', gpu ? `${gpu.name || 'GPU'} · ${gpu.vram_gb || 0} GB` : 'None detected'], ['GPU', gpu ? `${gpu.name || 'GPU'} · ${gpu.vram_gb || 0} GB` : 'None detected'],
].map(row => `<div class="ops-row"><div class="ops-label">${esc(row[0])}</div><div class="ops-value">${esc(row[1])}</div></div>`).join(''); ].map(row => `<div class="ops-row"><div class="ops-label">${esc(row[0])}</div><div class="ops-value">${esc(row[1])}</div></div>`).join('');
document.getElementById('entitlement-list').innerHTML = [
['License status', entitlement.license_status || '—'],
['Customer', entitlement.license_record?.customer_name || '—'],
['Licensed tier', entitlement.licensed_tier || '—'],
['Provisioned tier', entitlement.provisioned_tier || '—'],
['Hardware tier', entitlement.hardware_recommended_tier || '—'],
['Install type', entitlement.install_type || '—'],
['Support until', entitlement.license_record?.support_until || '—'],
['Compliance', (entitlement.compliance_state || []).join(', ') || '—'],
].map(row => `<div class="ops-row"><div class="ops-label">${esc(row[0])}</div><div class="ops-value">${esc(row[1])}</div></div>`).join('');
const featureEntries = Object.entries(entitlement.effective_features || {});
document.getElementById('features-list').innerHTML = featureEntries.length ? featureEntries.map(([k,v]) =>
`<div class="ops-row"><div class="ops-label">${esc(k.replace(/_/g,' '))}</div><div class="ops-value">${esc(v)}</div></div>`
).join('') : '<div class="empty">No feature details available.</div>';
document.getElementById('checks-list').innerHTML = (d.readiness?.checks || []).map(c => ` document.getElementById('checks-list').innerHTML = (d.readiness?.checks || []).map(c => `
<div class="check-row"> <div class="check-row">
<span class="check-dot ${c.passed ? 'ok' : ''}"></span> <span class="check-dot ${c.passed ? 'ok' : ''}"></span>
@ -343,6 +373,43 @@ async function loadAll() {
} }
} }
async function uploadLicense() {
const license = prompt('Paste signed cezen.license.v2 JSON');
if (!license) return;
const override = prompt('Optional: paste signed cezen.override.v1 JSON');
try {
await api('/api/license/upload', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ license_json: license, override_json: override || '' })
});
toast('License uploaded');
loadAll();
} catch (err) {
toast('License upload failed: ' + err.message, false);
}
}
async function reverifyLicense() {
try {
await api('/api/license/reverify', { method: 'POST' });
toast('License reverified');
loadAll();
} catch (err) {
toast('Reverify failed: ' + err.message, false);
}
}
async function reconcileLicense() {
try {
const d = await api('/api/license/reconcile', { method: 'POST' });
toast(`Reconcile ${d.reconcile?.status || 'queued'}`);
loadAll();
} catch (err) {
toast('Reconcile failed: ' + err.message, false);
}
}
async function createBackup() { async function createBackup() {
try { try {
toast('Creating backup…'); toast('Creating backup…');

View File

@ -31,7 +31,12 @@ PROFILE="auto"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ANSIBLE_DIR="$SCRIPT_DIR/ansible" ANSIBLE_DIR="$SCRIPT_DIR/ansible"
FEASIBILITY_SCRIPT="$SCRIPT_DIR/scripts/cezen-feasibility.sh" FEASIBILITY_SCRIPT="$SCRIPT_DIR/scripts/cezen-feasibility.sh"
LICENSE_CHECK_SCRIPT="$SCRIPT_DIR/scripts/cezen-license-check.py"
FEASIBILITY_JSON="/opt/cezen/feasibility.json" 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="${CEZEN_LICENSE_PUBLIC_KEY:-$SCRIPT_DIR/autoinstall/keys/cezen-license-public.pem}"
# Load saved config (written by web setup UI before phase 1) # Load saved config (written by web setup UI before phase 1)
[ -f /opt/cezen/install.conf ] && source /opt/cezen/install.conf [ -f /opt/cezen/install.conf ] && source /opt/cezen/install.conf
@ -119,6 +124,126 @@ except Exception:
PY PY
} }
license_eval_field() {
local expr="$1"
python3 - "$LICENSE_EVAL_JSON" "$expr" <<'PY'
import json, sys
try:
d=json.load(open(sys.argv[1]))
cur=d
for part in sys.argv[2].split("."):
cur=cur[part]
print(cur)
except Exception:
print("")
PY
}
run_license_evaluation() {
LICENSE_EVAL_JSON="/tmp/cezen-license-eval.json"
if [ -f "$LICENSE_CHECK_SCRIPT" ]; then
python3 "$LICENSE_CHECK_SCRIPT" \
--license "$LICENSE_JSON" \
--override "$OVERRIDE_JSON" \
--feasibility "$FEASIBILITY_JSON" \
--public-key "$PUBLIC_KEY_PATH" > "$LICENSE_EVAL_JSON"
else
echo "ERROR: License checker not found: $LICENSE_CHECK_SCRIPT"
exit 1
fi
}
enforce_tier_constraints() {
run_license_evaluation
local license_status allowed_tier hardware_tier selected_state
license_status="$(license_eval_field license.status)"
allowed_tier="$(license_eval_field license.allowed_tier)"
hardware_tier="$(license_eval_field hardware.recommended_tier)"
selected_state="$(python3 - "$LICENSE_EVAL_JSON" "$TIER" <<'PY'
import json, sys
d=json.load(open(sys.argv[1]))
tier=sys.argv[2]
for opt in d.get("tier_options", []):
if opt.get("tier") == tier:
print(opt.get("state", "disabled_by_license"))
break
else:
print("disabled_by_license")
PY
)"
echo "→ License status: ${license_status:-missing} | Allowed tier: ${allowed_tier:-basic} | Hardware tier: ${hardware_tier:-starter}"
case "$selected_state" in
enabled|override_required)
;;
disabled_by_license)
echo "ERROR: Selected tier '$TIER' exceeds the current license allowance (${allowed_tier:-basic})."
exit 1
;;
disabled_by_hardware)
echo "ERROR: Selected tier '$TIER' exceeds hardware feasibility (${hardware_tier:-starter})."
exit 1
;;
*)
echo "ERROR: Selected tier '$TIER' is not allowed (state: $selected_state)."
exit 1
;;
esac
}
write_install_record() {
python3 - "$INSTALL_RECORD_JSON" "$TIER" "$PROFILE" "$SKIP_ROLES" "$GPU_AVAILABLE" "$FEASIBILITY_JSON" "$LICENSE_EVAL_JSON" <<'PY'
import json, sys
from datetime import datetime, timezone
from pathlib import Path
out = Path(sys.argv[1])
tier = sys.argv[2]
profile = sys.argv[3]
skip_roles = [r for r in sys.argv[4].split(",") if r]
gpu_available = sys.argv[5].lower() == "true"
feasibility = {}
license_eval = {}
for src, dest in ((sys.argv[6], "feasibility"), (sys.argv[7], "license_eval")):
try:
with open(src) as fh:
data = json.load(fh)
if dest == "feasibility":
feasibility = data
else:
license_eval = data
except Exception:
pass
components = {
"ollama": "ollama" not in skip_roles,
"jupyterlab": "jupyterlab" not in skip_roles,
"chromadb": "chromadb" not in skip_roles,
"vllm": "vllm" not in skip_roles,
"mlflow": "mlflow" not in skip_roles,
"minio": "minio" not in skip_roles,
"monitoring": "monitoring" not in skip_roles,
"k3s": "k3s" not in skip_roles,
}
payload = {
"schema": "cezen.install_record.v1",
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"selected_tier": tier,
"provisioned_tier": tier,
"provisioned_profile": profile,
"skip_roles": skip_roles,
"components": components,
"gpu_available": gpu_available,
"hardware_recommended_tier": ((feasibility.get("recommendation") or {}).get("recommended_tier") or "starter"),
"hardware_recommended_profile": ((feasibility.get("recommendation") or {}).get("recommended_profile") or "core"),
"licensed_tier": (((license_eval.get("license") or {}).get("allowed_tier")) or "basic"),
"license_status": ((license_eval.get("license") or {}).get("status") or "missing"),
"install_type": ((license_eval.get("license") or {}).get("install_type") or "field-staging"),
"override_active": bool(((license_eval.get("override") or {}).get("allow_hardware_mismatch"))),
}
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(payload, indent=2))
PY
}
apply_profile_from_feasibility() { apply_profile_from_feasibility() {
[ -f "$FEASIBILITY_JSON" ] || return 0 [ -f "$FEASIBILITY_JSON" ] || return 0
local detected_profile local detected_profile
@ -263,6 +388,7 @@ run_phase2() {
echo "→ Tier: $TIER | Skip: ${SKIP_ROLES:-none}" echo "→ Tier: $TIER | Skip: ${SKIP_ROLES:-none}"
echo "→ GPU available: $GPU_AVAILABLE" echo "→ GPU available: $GPU_AVAILABLE"
echo "→ Skip model pull: $SKIP_MODEL_PULL" echo "→ Skip model pull: $SKIP_MODEL_PULL"
write_install_record
# Select Ansible playbook by tier # Select Ansible playbook by tier
case "$TIER" in case "$TIER" in
@ -311,6 +437,8 @@ if [ "$SOFTWARE_ONLY" = true ]; then
PHASE="2" PHASE="2"
fi fi
enforce_tier_constraints
install_ansible install_ansible
if [ "$PHASE" = "1" ]; then if [ "$PHASE" = "1" ]; then

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
from cezen_license import (
STAGING_MAX_TIER,
build_tier_options,
evaluate_license,
evaluate_override,
normalize_tier,
read_json_file,
)
def main():
parser = argparse.ArgumentParser(description="Evaluate Nexus One AI license and tier constraints")
parser.add_argument("--license", dest="license_path", default="/opt/cezen/license.json")
parser.add_argument("--override", dest="override_path", default="/opt/cezen/license.override.json")
parser.add_argument("--feasibility", dest="feasibility_path", default="/opt/cezen/feasibility.json")
parser.add_argument("--public-key", dest="public_key_path", default="")
parser.add_argument("--machine-id", dest="machine_id", default="")
args = parser.parse_args()
script_dir = Path(__file__).resolve().parent
default_pub = script_dir.parent / "autoinstall" / "keys" / "cezen-license-public.pem"
public_key_path = args.public_key_path or str(default_pub)
license_record = read_json_file(args.license_path)
override_record = read_json_file(args.override_path)
feasibility = read_json_file(args.feasibility_path)
hardware_tier = normalize_tier(((feasibility.get("recommendation") or {}).get("recommended_tier")), STAGING_MAX_TIER)
license_eval = evaluate_license(license_record, public_key_path, machine_id=args.machine_id or None)
override_eval = evaluate_override(override_record, public_key_path)
tier_options = build_tier_options(license_eval, hardware_tier, override_eval)
response = {
"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", []),
"features": feasibility.get("features", {}),
},
"tier_options": tier_options,
"effective_max_tier": max(
[opt["tier"] for opt in tier_options if opt["selectable"]],
default=STAGING_MAX_TIER,
key=lambda tier: ("starter", "basic", "pro", "max").index(tier),
),
"staging_only": license_eval.get("status") != "valid",
}
print(json.dumps(response, indent=2))
if __name__ == "__main__":
main()

321
scripts/cezen_license.py Normal file
View File

@ -0,0 +1,321 @@
import base64
import copy
import json
import os
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
TIER_ORDER = ("starter", "basic", "pro", "max")
TIER_ALIASES = {
"entry": "basic",
"entry tier": "basic",
"mid": "pro",
"mid tier": "pro",
"advanced": "max",
"advanced tier": "max",
"starter tier": "starter",
"basic tier": "basic",
"pro tier": "pro",
"max tier": "max",
}
STAGING_MAX_TIER = "basic"
SUPPORTED_SIGNATURE_ALGS = {"rsa-sha256", "sha256-rsa"}
def utcnow_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_time(value):
text = (value or "").strip()
if not text:
return None
if len(text) == 10:
text += "T00:00:00Z"
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
return datetime.fromisoformat(text)
except Exception:
return None
def normalize_tier(value, default="basic"):
raw = (value or "").strip().lower()
if raw in TIER_ORDER:
return raw
return TIER_ALIASES.get(raw, default)
def tier_rank(tier):
return TIER_ORDER.index(normalize_tier(tier))
def tier_lte(left, right):
return tier_rank(left) <= tier_rank(right)
def min_tier(*tiers):
items = [normalize_tier(t) for t in tiers if t]
if not items:
return STAGING_MAX_TIER
return sorted(items, key=tier_rank)[0]
def read_json_file(path):
try:
data = json.loads(Path(path).read_text())
return data if isinstance(data, dict) else {}
except Exception:
return {}
def write_json_file(path, payload):
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(payload, indent=2))
def _canonical_payload(payload):
clean = copy.deepcopy(payload or {})
for key in ("signature", "signature_status", "verification_status"):
clean.pop(key, None)
return json.dumps(clean, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
def verify_signature(payload, public_key_path):
if not payload:
return False, "missing"
signature = (payload.get("signature") or "").strip()
if not signature:
return False, "missing_signature"
alg = (payload.get("signature_alg") or "").strip().lower()
if alg not in SUPPORTED_SIGNATURE_ALGS:
return False, "unsupported_signature_alg"
key_path = Path(public_key_path)
if not key_path.exists():
return False, "missing_public_key"
canonical = _canonical_payload(payload).encode()
try:
sig_bytes = base64.b64decode(signature, validate=True)
except Exception:
return False, "bad_signature_encoding"
with tempfile.NamedTemporaryFile(delete=False) as data_file, tempfile.NamedTemporaryFile(delete=False) as sig_file:
data_file.write(canonical)
sig_file.write(sig_bytes)
data_file.flush()
sig_file.flush()
data_path = data_file.name
sig_path = sig_file.name
try:
result = subprocess.run(
["openssl", "dgst", "-sha256", "-verify", str(key_path), "-signature", sig_path, data_path],
capture_output=True,
text=True,
check=False,
)
return result.returncode == 0, "valid" if result.returncode == 0 else "invalid_signature"
finally:
for tmp in (data_path, sig_path):
try:
os.unlink(tmp)
except OSError:
pass
def public_license_record(record):
public = {k: v for k, v in (record or {}).items() if k != "signature"}
if record.get("license_key"):
public["license_key_prefix"] = str(record.get("license_key"))[:12]
public["license_key_present"] = True
else:
public["license_key_present"] = False
return public
def evaluate_license(payload, public_key_path, machine_id=None, now=None):
now_dt = parse_time(now) if isinstance(now, str) else now or datetime.now(timezone.utc)
base = {
"schema": "cezen.license.v2",
"status": "missing",
"valid": False,
"allowed_tier": STAGING_MAX_TIER,
"feature_overrides": {},
"install_type": "field-staging",
"needs_activation": True,
"license_record": {},
"notes": [],
}
if not payload:
return base
record = payload if isinstance(payload, dict) else {}
base["license_record"] = public_license_record(record)
base["install_type"] = (record.get("install_type") or "licensed").strip() or "licensed"
base["allowed_tier"] = normalize_tier(record.get("allowed_tier") or record.get("tier"), STAGING_MAX_TIER)
overrides = record.get("feature_overrides") or {}
base["feature_overrides"] = overrides if isinstance(overrides, dict) else {}
ok, sig_status = verify_signature(record, public_key_path)
if not ok:
base["status"] = "invalid_signature" if sig_status != "missing_signature" else "missing"
base["notes"].append(sig_status)
return base
issued_at = parse_time(record.get("issued_at"))
if issued_at and issued_at > now_dt:
base["status"] = "not_yet_valid"
base["notes"].append("issued_at_in_future")
return base
expires_at = parse_time(record.get("expires_at"))
if expires_at and expires_at < now_dt:
base["status"] = "expired"
base["notes"].append("license_expired")
return base
machine_binding = record.get("machine_binding") or {}
if machine_id and isinstance(machine_binding, dict):
bound_id = (machine_binding.get("machine_id") or "").strip()
if bound_id and bound_id != machine_id:
base["status"] = "machine_mismatch"
base["notes"].append("machine_binding_mismatch")
return base
base["status"] = "valid"
base["valid"] = True
base["needs_activation"] = False
return base
def evaluate_override(payload, public_key_path, now=None):
if not payload:
return {"status": "missing", "valid": False, "allow_hardware_mismatch": False, "max_override_tier": None}
record = payload if isinstance(payload, dict) else {}
ok, sig_status = verify_signature(record, public_key_path)
if not ok:
return {
"status": "invalid_signature" if sig_status != "missing_signature" else "missing",
"valid": False,
"allow_hardware_mismatch": False,
"max_override_tier": None,
}
expires_at = parse_time(record.get("expires_at"))
now_dt = parse_time(now) if isinstance(now, str) else now or datetime.now(timezone.utc)
if expires_at and expires_at < now_dt:
return {"status": "expired", "valid": False, "allow_hardware_mismatch": False, "max_override_tier": None}
return {
"status": "valid",
"valid": True,
"allow_hardware_mismatch": bool(record.get("allow_hardware_mismatch")),
"max_override_tier": normalize_tier(record.get("max_override_tier"), "max"),
"override_record": public_license_record(record),
}
def build_tier_options(license_eval, hardware_tier, override_eval=None):
override_eval = override_eval or {}
licensed_cap = normalize_tier(license_eval.get("allowed_tier"), STAGING_MAX_TIER)
hardware_cap = normalize_tier(hardware_tier, STAGING_MAX_TIER)
override_cap = normalize_tier(override_eval.get("max_override_tier"), "max") if override_eval.get("allow_hardware_mismatch") else None
options = []
for tier in TIER_ORDER:
state = "enabled"
reason = ""
if not tier_lte(tier, licensed_cap):
state = "disabled_by_license"
reason = f"Not included in current license ({licensed_cap})."
elif not tier_lte(tier, hardware_cap):
if override_cap and tier_lte(tier, override_cap):
state = "override_required"
reason = f"Requires Cezen override; hardware recommendation is {hardware_cap}."
else:
state = "disabled_by_hardware"
reason = f"Requires larger hardware; recommendation is {hardware_cap}."
options.append({"tier": tier, "state": state, "reason": reason, "selectable": state in {"enabled", "override_required"}})
return options
def provisioned_components_from_record(record):
record = record or {}
components = record.get("components") or {}
if isinstance(components, dict):
return components
skip_roles = set(record.get("skip_roles") or [])
return {
"ollama": "ollama" not in skip_roles,
"jupyterlab": "jupyterlab" not in skip_roles,
"chromadb": "chromadb" not in skip_roles,
"vllm": "vllm" not in skip_roles,
"mlflow": "mlflow" not in skip_roles,
"minio": "minio" not in skip_roles,
"monitoring": "monitoring" not in skip_roles,
"k3s": "k3s" not in skip_roles,
}
def resolve_effective_features(base_features, feature_overrides, provisioned_components, hardware_features):
features = copy.deepcopy(base_features or {})
for key, value in (feature_overrides or {}).items():
features[key] = value
provisioned_components = provisioned_components or {}
hardware_features = hardware_features or {}
if features.get("rag") and not provisioned_components.get("chromadb", True):
features["rag"] = False
if features.get("gpu_inference") and not (
hardware_features.get("ollama_gpu") or hardware_features.get("vllm")
):
features["gpu_inference"] = False
if features.get("fine_tuning") and not (
provisioned_components.get("jupyterlab", False) and hardware_features.get("fine_tuning_qlora")
):
features["fine_tuning"] = False
if features.get("deepspeed") and not hardware_features.get("distributed_training"):
features["deepspeed"] = False
return features
def collect_entitlement(tier_matrix, license_record, install_record, feasibility, public_key_path, machine_id=None):
license_eval = evaluate_license(license_record, public_key_path, machine_id=machine_id)
override_eval = evaluate_override((install_record or {}).get("override_record") or read_json_file((install_record or {}).get("override_path", "")), public_key_path)
licensed_tier = normalize_tier(license_eval.get("allowed_tier"), STAGING_MAX_TIER)
provisioned_tier = normalize_tier((install_record or {}).get("provisioned_tier") or (install_record or {}).get("selected_tier"), STAGING_MAX_TIER)
hardware_tier = normalize_tier(((feasibility or {}).get("recommendation") or {}).get("recommended_tier"), STAGING_MAX_TIER)
components = provisioned_components_from_record(install_record)
tier_defaults = copy.deepcopy((tier_matrix or {}).get(licensed_tier, {}))
effective_features = resolve_effective_features(
tier_defaults.get("features", {}),
license_eval.get("feature_overrides", {}),
components,
(feasibility or {}).get("features", {}),
)
states = []
if tier_rank(licensed_tier) > tier_rank(provisioned_tier):
states.append("licensed_not_provisioned")
if tier_rank(provisioned_tier) > tier_rank(licensed_tier):
states.append("provisioned_above_license")
if tier_rank(provisioned_tier) > tier_rank(hardware_tier) and not override_eval.get("allow_hardware_mismatch"):
states.append("hardware_below_provisioned")
if license_eval.get("status") != "valid":
states.append(license_eval.get("status"))
return {
"license_status": license_eval.get("status"),
"license_valid": license_eval.get("valid"),
"license_record": public_license_record(license_record),
"licensed_tier": licensed_tier,
"provisioned_tier": provisioned_tier,
"hardware_recommended_tier": hardware_tier,
"effective_features": effective_features,
"feature_overrides": license_eval.get("feature_overrides", {}),
"provisioned_components": components,
"compliance_state": states or ["licensed_and_provisioned"],
"override": override_eval,
"install_type": license_eval.get("install_type") or (install_record or {}).get("install_type") or "field-staging",
}

54
scripts/sign-license.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
import argparse
import base64
import copy
import json
import subprocess
import tempfile
from pathlib import Path
from cezen_license import utcnow_iso
def canonical_payload(payload):
clean = copy.deepcopy(payload)
clean.pop("signature", None)
clean.pop("signature_status", None)
return json.dumps(clean, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
def main():
parser = argparse.ArgumentParser(description="Sign a Nexus One AI license or override artifact")
parser.add_argument("--input", required=True, help="Unsigned JSON artifact")
parser.add_argument("--private-key", required=True, help="PEM private key path")
parser.add_argument("--output", required=True, help="Signed JSON output path")
parser.add_argument("--alg", default="rsa-sha256")
args = parser.parse_args()
payload = json.loads(Path(args.input).read_text())
payload["issued_at"] = payload.get("issued_at") or utcnow_iso()
payload["signature_alg"] = args.alg
body = canonical_payload(payload)
with tempfile.NamedTemporaryFile(delete=False) as data_file, tempfile.NamedTemporaryFile(delete=False) as sig_file:
data_file.write(body)
data_file.flush()
data_path = data_file.name
sig_path = sig_file.name
try:
subprocess.run(
["openssl", "dgst", "-sha256", "-sign", args.private_key, "-out", sig_path, data_path],
check=True,
)
signature = base64.b64encode(Path(sig_path).read_bytes()).decode()
finally:
Path(data_path).unlink(missing_ok=True)
Path(sig_path).unlink(missing_ok=True)
payload["signature"] = signature
payload["signature_status"] = "signed"
Path(args.output).write_text(json.dumps(payload, indent=2))
if __name__ == "__main__":
main()