diff --git a/README.md b/README.md index 62d6e48..20093e4 100644 --- a/README.md +++ b/README.md @@ -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. -The ISO keeps itself small by pulling this package from cgit during setup, then -the installer deploys the selected tier on the target server. +This repository is the source of truth for the Nexus One AI platform package, +including installer flows, deployment roles, portal assets, backend services, +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 @@ -45,7 +49,7 @@ evaluation, partner enablement, and internal technical review. ![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 | |---|---| @@ -54,7 +58,31 @@ evaluation, partner enablement, and internal technical review. | 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. | -## 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 +/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. 2. Boot the server from the ISO. @@ -88,7 +116,7 @@ sudo tail -f /var/log/cezen-install.log http:/// ``` -## 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. @@ -115,7 +143,13 @@ Installer selections are stored at: /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. @@ -149,7 +183,7 @@ Recommended interpretation: | `gpu-pro` | Pro tier candidate. | | `gpu-max` | Max tier candidate. | -## 5. Existing Server Install +## 5. Existing Server Installation 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 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 | |---|---|---|---| @@ -193,7 +227,7 @@ bash models/pull-models.sh --tier=pro 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: diff --git a/ansible/roles/cezen-backend/files/cezen-license-public.pem b/ansible/roles/cezen-backend/files/cezen-license-public.pem new file mode 100644 index 0000000..9e6b083 --- /dev/null +++ b/ansible/roles/cezen-backend/files/cezen-license-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxOGixLLE9iwPNngpX2gr +pUiVPHN0//LcBUovRsKr1qq5xXRxhMmE1Fgux8yMHBlTLRCKdd4wH1N3E7EGMg9T +vHeLQFZJ8uGkK2U7X9nY6h9prAe9VVNvz6OwdYQqxPbttW723w2cy2p2/Jdxry5x +9iqJ3Q084cDyT30QHdkhqGTiYFAJ7+K95acktuKUs/A2WuHgEmhbK8aCWW2kQzLS +x50aFjxGwXbHydXG7D0WJYCXKPjTXusjTNuPopjUH3Yp9xqieGqqxSFXFkIXdrgc +wkpMmgJEz/RQlG+fFkGd3VdoxD3taRMamuVky+9Kf6MbMDZKoxte4gf2FjXav9j8 +mQIDAQAB +-----END PUBLIC KEY----- diff --git a/ansible/roles/cezen-backend/files/cezen_license.py b/ansible/roles/cezen-backend/files/cezen_license.py new file mode 100644 index 0000000..86fb1aa --- /dev/null +++ b/ansible/roles/cezen-backend/files/cezen_license.py @@ -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", + } diff --git a/ansible/roles/cezen-backend/files/main.py b/ansible/roles/cezen-backend/files/main.py index 5a8b9c4..21c4e29 100644 --- a/ansible/roles/cezen-backend/files/main.py +++ b/ansible/roles/cezen-backend/files/main.py @@ -27,6 +27,15 @@ import psutil from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger 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 ──────────────────────────────────────────────────────────────────── @@ -35,6 +44,8 @@ DB_PATH = DATA_DIR / "cezen.db" SECRET_FILE = DATA_DIR / ".jwt_secret" BACKUP_DIR = Path(os.environ.get("CEZEN_BACKUP_DIR", str(DATA_DIR.parent / "backups"))) LICENSE_FILE = Path(os.environ.get("CEZEN_LICENSE_JSON", "/opt/cezen/license.json")) +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) BACKUP_DIR.mkdir(parents=True, exist_ok=True) @@ -821,23 +832,16 @@ def _setting_value(key: str, default: str = "") -> str: db.close() def _normalize_tier(value: str) -> str: - raw = (value or "").strip().lower() - if raw in TIER_MATRIX: - return raw - return TIER_ALIASES.get(raw, "basic") + return license_normalize_tier(value, "basic") def _license_record() -> dict: - try: - if LICENSE_FILE.exists(): - data = json.loads(LICENSE_FILE.read_text()) - if isinstance(data, dict): - return data - except Exception: - pass - return {} + return read_json_file(LICENSE_FILE) + +def _install_record() -> dict: + return read_json_file(INSTALL_RECORD_FILE) 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"): public["license_key_prefix"] = record["license_key"][:12] public["license_key_present"] = True @@ -848,26 +852,60 @@ def _public_license_record(record: dict) -> dict: def _current_tier() -> str: if CEZEN_TIER: return _normalize_tier(CEZEN_TIER) - license_tier = (_license_record().get("tier") or "").strip() - if license_tier: - return _normalize_tier(license_tier) + entitlement = _entitlement_summary() + licensed_tier = (entitlement.get("licensed_tier") or "").strip() + if licensed_tier: + return _normalize_tier(licensed_tier) 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: - tier = _current_tier() + entitlement = _entitlement_summary() + tier = entitlement.get("licensed_tier") or _current_tier() info = TIER_MATRIX[tier] license_record = _license_record() return { "tier": tier, "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"], "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, "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: features = feasibility.get("features") 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") 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") async def readiness_report(admin: dict = Depends(admin_only)): feasibility = await system_feasibility(admin) license_payload = _tier_payload() + entitlement = _entitlement_summary(feasibility) readiness = _readiness_score(feasibility, license_payload) 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)) commercial_fit = "matched" if current_rank >= recommended_rank else "license_upgrade_recommended" return { @@ -1735,11 +1897,13 @@ async def readiness_report(admin: dict = Depends(admin_only)): "generated_at": utcnow(), "customer_mode": "software_only" if (feasibility.get("features") or {}).get("software_only", True) else "appliance", "license": license_payload, + "entitlement": entitlement, "feasibility": feasibility, "readiness": readiness, "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), "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() result = {r["key"]: r["value"] for r in rows} license_record = _license_record() + entitlement = _entitlement_summary() # 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() result["tier_label"] = TIER_MATRIX[tier]["label"] result["tier_slug"] = tier @@ -1842,13 +2007,15 @@ async def get_branding(): result["license_customer_id"] = license_record.get("customer_id", "") result["license_key_prefix"] = (license_record.get("license_key", "") or "")[:12] result["license_support_until"] = license_record.get("support_until", "") + result["license_status"] = entitlement.get("license_status", "missing") + result["provisioned_tier"] = entitlement.get("provisioned_tier", "") return result @app.put("/api/settings/branding") async def update_branding(body: dict, admin: dict = Depends(admin_only)): # tier_label is always excluded from customer-editable fields when locked 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) allowed.add("tier_label") db = get_db() @@ -2149,7 +2316,7 @@ def _count_rows(path: Path, orig_name: str) -> int: return 0 @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.""" allowed_ext = {".jsonl", ".json", ".csv"} 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} @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() rows = db.execute( "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]} @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() row = db.execute("SELECT * FROM training_datasets WHERE id=?", (dataset_id,)).fetchone() if not row: @@ -2212,7 +2379,7 @@ async def delete_dataset(dataset_id: int, admin: dict = Depends(admin_only)): return {"ok": True} @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.""" required = {"name", "base_model", "dataset_id"} 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"} @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() rows = db.execute( """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} @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() row = db.execute( """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 @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() row = db.execute("SELECT * FROM training_jobs WHERE id=?", (job_id,)).fetchone() if not row: @@ -2723,7 +2890,7 @@ class BenchmarkRun(BaseModel): max_tokens: int = 256 @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. 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 @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() rows = db.execute("SELECT * FROM connectors ORDER BY updated_at DESC").fetchall() db.close() return [_connector_from_row(r) for r in rows] @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() db = get_db() seen = [] @@ -5284,7 +5451,7 @@ def _connector_db_stats(cfg: dict) -> tuple[int, int]: raise ValueError(f"Unsupported database connector type: {db_type}") @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() row = db.execute("SELECT * FROM connectors WHERE id=?", (connector_id,)).fetchone() 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} @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() rows = db.execute("SELECT level,msg,ts FROM connector_log ORDER BY id DESC LIMIT ?", (limit,)).fetchall() db.close() return {"lines": [dict(r) for r in reversed(rows)]} @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() rows = db.execute("SELECT * FROM router_rules ORDER BY priority, id").fetchall() db.close() @@ -5348,7 +5515,7 @@ async def get_router_rules(user: dict = Depends(current_user)): return {"routes": routes} @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 [] db = get_db() 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)} @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() for key, value in body.items(): if key == "cloud_key" and value: @@ -5449,7 +5616,7 @@ async def rag_quality_warnings(admin: dict = Depends(admin_only)): return {"warnings": warnings} @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() if not transcript: raise HTTPException(400, "transcript required") @@ -5466,7 +5633,7 @@ async def analyse_meeting(body: dict, user: dict = Depends(current_user)): return result @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: meta_obj = json.loads(meta) if isinstance(meta, str) else {} except Exception: diff --git a/ansible/roles/cezen-backend/tasks/main.yml b/ansible/roles/cezen-backend/tasks/main.yml index 6b46029..0a47e78 100644 --- a/ansible/roles/cezen-backend/tasks/main.yml +++ b/ansible/roles/cezen-backend/tasks/main.yml @@ -52,6 +52,24 @@ mode: "0644" 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 copy: src: train_qlora.py diff --git a/autoinstall/build-iso.sh b/autoinstall/build-iso.sh index ce48fe7..12d328a 100644 --- a/autoinstall/build-iso.sh +++ b/autoinstall/build-iso.sh @@ -185,10 +185,13 @@ echo "╔═══════════════════════ echo "║ Done! ║" echo "╚══════════════════════════════════════════════════════╝" echo "" +echo "→ ISO path:" +echo " $OUTPUT_ISO" +echo "" ls -lh "$OUTPUT_ISO" echo "" echo "→ Copy to your MacBook:" -echo " scp user@172.16.10.180:~/aipackage/autoinstall/cezen-ai-ubuntu2204.iso ." +echo " scp user@:$(dirname "$OUTPUT_ISO")/$(basename "$OUTPUT_ISO") ." echo "" echo "→ Flash to USB on MacBook:" echo " diskutil list # find USB e.g. /dev/disk4" diff --git a/autoinstall/firstboot-setup.sh b/autoinstall/firstboot-setup.sh index 355fdbc..da389ca 100644 --- a/autoinstall/firstboot-setup.sh +++ b/autoinstall/firstboot-setup.sh @@ -7,6 +7,9 @@ set -e AIPACKAGE_DIR="/opt/aipackage" +FEASIBILITY_SCRIPT="$AIPACKAGE_DIR/scripts/cezen-feasibility.sh" +LICENSE_CHECK_SCRIPT="$AIPACKAGE_DIR/scripts/cezen-license-check.py" +PUBLIC_KEY_PATH="${CEZEN_LICENSE_PUBLIC_KEY:-$AIPACKAGE_DIR/autoinstall/keys/cezen-license-public.pem}" LOG_FILE="/var/log/cezen-setup.log" INSTALL_LOG_FILE="/var/log/cezen-install.log" export TERM="${TERM:-linux}" @@ -187,25 +190,106 @@ CONTACT_EMAIL=$(whiptail --title "$TITLE" \ --inputbox "\nStep 2 of 4: License & Customer Details\n\nCustomer/admin contact email:\n\nLeave blank if unavailable on site." \ $H $W "" 3>&1 1>&2 2>&3) -LICENSE_KEY=$(whiptail --title "$TITLE" \ - --passwordbox "\nStep 2 of 4: License & Customer Details\n\nLicense key / activation code:\n\nLeave blank for offline field staging. Models and keys can be added later." \ +LICENSE_PATH=$(whiptail --title "$TITLE" \ + --inputbox "\nStep 2 of 4: License & Customer Details\n\nPath to signed license JSON:\n\nLeave blank for offline field staging.\nExample: /media/usb/customer-license.json" \ + $H $W "" 3>&1 1>&2 2>&3) + +OVERRIDE_PATH=$(whiptail --title "$TITLE" \ + --inputbox "\nOptional: Cezen hardware override JSON path\n\nLeave blank unless support explicitly provided one." \ $H $W "" 3>&1 1>&2 2>&3) SUPPORT_UNTIL=$(whiptail --title "$TITLE" \ - --inputbox "\nStep 2 of 4: License & Customer Details\n\nSupport valid until (YYYY-MM-DD):\n\nLeave blank if not issued yet." \ + --inputbox "\nStep 2 of 4: License & Customer Details\n\nSupport valid until (YYYY-MM-DD):\n\nOptional local record field for field installs." \ $H $W "" 3>&1 1>&2 2>&3) +mkdir -p /opt/cezen +rm -f /opt/cezen/license.json /opt/cezen/license.override.json + +if [ -n "$LICENSE_PATH" ]; then + if [ ! -f "$LICENSE_PATH" ]; then + whiptail --title "$TITLE" \ + --msgbox "\nLicense file not found:\n $LICENSE_PATH" \ + $H $W + exit 1 + fi + cp "$LICENSE_PATH" /opt/cezen/license.json + chmod 0640 /opt/cezen/license.json + chown root:cezen /opt/cezen/license.json 2>/dev/null || true +fi + +if [ -n "$OVERRIDE_PATH" ]; then + if [ ! -f "$OVERRIDE_PATH" ]; then + whiptail --title "$TITLE" \ + --msgbox "\nOverride file not found:\n $OVERRIDE_PATH" \ + $H $W + exit 1 + fi + cp "$OVERRIDE_PATH" /opt/cezen/license.override.json + chmod 0640 /opt/cezen/license.override.json + chown root:cezen /opt/cezen/license.override.json 2>/dev/null || true +fi + +bash "$FEASIBILITY_SCRIPT" /opt/cezen/feasibility.json >> "$LOG_FILE" 2>&1 +python3 "$LICENSE_CHECK_SCRIPT" \ + --license /opt/cezen/license.json \ + --override /opt/cezen/license.override.json \ + --feasibility /opt/cezen/feasibility.json \ + --public-key "$PUBLIC_KEY_PATH" > /tmp/cezen-license-check.json + +LICENSE_STATUS=$(python3 - <<'PY' +import json +d=json.load(open("/tmp/cezen-license-check.json")) +print((d.get("license") or {}).get("status","missing")) +PY +) +LICENSE_ALLOWED_TIER=$(python3 - <<'PY' +import json +d=json.load(open("/tmp/cezen-license-check.json")) +print((d.get("license") or {}).get("allowed_tier","basic")) +PY +) +HARDWARE_TIER=$(python3 - <<'PY' +import json +d=json.load(open("/tmp/cezen-license-check.json")) +print((d.get("hardware") or {}).get("recommended_tier","starter")) +PY +) + # ════════════════════════════════════════════════════════════ # STEP 3: SELECT TIER # ════════════════════════════════════════════════════════════ +mapfile -t TIER_MENU < <(python3 - <<'PY' +import json +d=json.load(open("/tmp/cezen-license-check.json")) +labels = { + "starter": "Starter — 1x RTX 5090 / 32GB VRAM · Small team", + "basic": "Entry — 1x NVIDIA RTX Pro 6000 (96GB) · Up to 20 users", + "pro": "Pro — 2x RTX 5090 / RTX Pro class · Up to 100 users", + "max": "Max — 4-8x H100/H200/A100 class · 100+ users", +} +for opt in d.get("tier_options", []): + if opt.get("selectable"): + print(opt["tier"]) + print(labels.get(opt["tier"], opt["tier"])) +PY +) + +if [ "${#TIER_MENU[@]}" -eq 0 ]; then + whiptail --title "$TITLE" \ + --msgbox "\nNo installable tiers are available.\n\nLicense status: ${LICENSE_STATUS}\nHardware recommendation: ${HARDWARE_TIER}\n\nCheck the signed license or contact Cezen support." \ + $H $W + exit 1 +fi + +whiptail --title "$TITLE" \ + --msgbox "\nLicense status: ${LICENSE_STATUS}\nAllowed tier: ${LICENSE_ALLOWED_TIER}\nHardware recommendation: ${HARDWARE_TIER}\n\nOnly valid tiers will be shown next." \ + $H $W + TIER=$(whiptail --title "$TITLE" \ - --menu "\nStep 3 of 4: Select AI Package Tier\n\nChoose the tier that matches your hardware:" \ + --menu "\nStep 3 of 4: Select AI Package Tier\n\nChoose the tier allowed by your license and hardware:" \ $H $W 4 \ - "starter" "Starter — 1× RTX 5090 / 32GB VRAM · Small team" \ - "basic" "Entry — 1× NVIDIA RTX Pro 6000 (96GB) · Up to 20 users" \ - "pro" "Pro — 2× RTX 5090 / RTX Pro class · Up to 100 users" \ - "max" "Max — 4–8× H100/H200/A100 class · 100+ users" \ + "${TIER_MENU[@]}" \ 3>&1 1>&2 2>&3) # ════════════════════════════════════════════════════════════ @@ -233,12 +317,12 @@ TOOLS=$(whiptail --title "$TITLE" \ TOOLS_DISPLAY=$(echo "$TOOLS" | tr -d '"' | tr ' ' '\n' | sed 's/^/ · /' | tr '\n' '\n') MY_IP=$(hostname -I | awk '{print $1}') LICENSE_DISPLAY="Field staging / evaluation" -if [ -n "$LICENSE_KEY" ]; then - LICENSE_DISPLAY="Provided" +if [ -n "$LICENSE_PATH" ]; then + LICENSE_DISPLAY="Signed file (${LICENSE_STATUS})" fi whiptail --title "$TITLE" \ - --yesno "\nReady to install. Please confirm:\n\nNetwork: ${NET_MODE} (${MY_IP})\nCustomer: ${CUSTOMER_NAME:-Not entered}\nLicense: ${LICENSE_DISPLAY}\nTier: ${TIER}\n\nTools:\n${TOOLS_DISPLAY}\n\nThis will take 20–40 minutes.\nThe server will reboot once during install (NVIDIA drivers).\n\nContinue?" \ + --yesno "\nReady to install. Please confirm:\n\nNetwork: ${NET_MODE} (${MY_IP})\nCustomer: ${CUSTOMER_NAME:-Not entered}\nLicense: ${LICENSE_DISPLAY}\nAllowed: ${LICENSE_ALLOWED_TIER}\nHardware: ${HARDWARE_TIER}\nTier: ${TIER}\n\nTools:\n${TOOLS_DISPLAY}\n\nThis will take 20–40 minutes.\nThe server will reboot once during install (NVIDIA drivers).\n\nContinue?" \ $H $W # ════════════════════════════════════════════════════════════ @@ -271,29 +355,30 @@ TIER=${TIER} SKIP_ROLES=${SKIP_ROLES} EOF -export CUSTOMER_NAME CUSTOMER_ID CONTACT_EMAIL LICENSE_KEY SUPPORT_UNTIL TIER +export CUSTOMER_NAME CUSTOMER_ID CONTACT_EMAIL SUPPORT_UNTIL TIER LICENSE_STATUS LICENSE_ALLOWED_TIER HARDWARE_TIER python3 - <<'PY' import json, os, time payload = { - "schema": "cezen.license.v1", + "schema": "cezen.install_record.v1", "customer_name": os.environ.get("CUSTOMER_NAME", "").strip(), "customer_id": os.environ.get("CUSTOMER_ID", "").strip(), "contact_email": os.environ.get("CONTACT_EMAIL", "").strip(), - "license_key": os.environ.get("LICENSE_KEY", "").strip(), - "tier": os.environ.get("TIER", "basic").strip(), + "selected_tier": os.environ.get("TIER", "basic").strip(), + "provisioned_tier": os.environ.get("TIER", "basic").strip(), "support_until": os.environ.get("SUPPORT_UNTIL", "").strip(), - "install_type": "licensed" if os.environ.get("LICENSE_KEY", "").strip() else "field-staging", - "issued_by": "Cezen", + "install_type": "licensed" if os.path.exists("/opt/cezen/license.json") else "field-staging", + "license_status": os.environ.get("LICENSE_STATUS", "missing").strip(), + "licensed_tier": os.environ.get("LICENSE_ALLOWED_TIER", "basic").strip(), + "hardware_recommended_tier": os.environ.get("HARDWARE_TIER", "starter").strip(), "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "signature_status": "unsigned-field-install", } -with open("/opt/cezen/license.json", "w") as f: +with open("/opt/cezen/install-record.json", "w") as f: json.dump(payload, f, indent=2) PY -chown root:cezen /opt/cezen/license.json 2>/dev/null || true -chmod 0640 /opt/cezen/license.json +chown root:cezen /opt/cezen/install-record.json 2>/dev/null || true +chmod 0640 /opt/cezen/install-record.json whiptail --title "$TITLE" \ --infobox "\nInstalling Nexus One AI stack...\n\nThis can take several minutes.\n\nLogs are being written to:\n $INSTALL_LOG_FILE" \ diff --git a/autoinstall/keys/cezen-license-public.pem b/autoinstall/keys/cezen-license-public.pem new file mode 100644 index 0000000..9e6b083 --- /dev/null +++ b/autoinstall/keys/cezen-license-public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxOGixLLE9iwPNngpX2gr +pUiVPHN0//LcBUovRsKr1qq5xXRxhMmE1Fgux8yMHBlTLRCKdd4wH1N3E7EGMg9T +vHeLQFZJ8uGkK2U7X9nY6h9prAe9VVNvz6OwdYQqxPbttW723w2cy2p2/Jdxry5x +9iqJ3Q084cDyT30QHdkhqGTiYFAJ7+K95acktuKUs/A2WuHgEmhbK8aCWW2kQzLS +x50aFjxGwXbHydXG7D0WJYCXKPjTXusjTNuPopjUH3Yp9xqieGqqxSFXFkIXdrgc +wkpMmgJEz/RQlG+fFkGd3VdoxD3taRMamuVky+9Kf6MbMDZKoxte4gf2FjXav9j8 +mQIDAQAB +-----END PUBLIC KEY----- diff --git a/autoinstall/websetup/server.py b/autoinstall/websetup/server.py index 3ad3816..c2924a6 100644 --- a/autoinstall/websetup/server.py +++ b/autoinstall/websetup/server.py @@ -3,15 +3,28 @@ Nexus One AI — First Boot Web Setup Server Serves on port 80. Access from any browser on the same network. """ -import os, json, subprocess, threading, time, socket, ipaddress +import os, json, subprocess, threading, time, socket, ipaddress, sys from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path from urllib.parse import parse_qs, urlparse SETUP_DONE_FILE = "/opt/cezen/.setup-done" INSTALL_LOG = "/var/log/cezen-install.log" AIPACKAGE_DIR = "/opt/aipackage" +SCRIPT_ROOT = Path(__file__).resolve().parents[2] +SCRIPTS_DIR = Path(AIPACKAGE_DIR) / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR if SCRIPTS_DIR.exists() else SCRIPT_ROOT / "scripts")) +from cezen_license import build_tier_options, evaluate_license, evaluate_override, normalize_tier, read_json_file, write_json_file + install_proc = None install_status = {"running": False, "done": False, "error": None} +FEASIBILITY_SCRIPT = f"{AIPACKAGE_DIR}/scripts/cezen-feasibility.sh" +FEASIBILITY_JSON = "/opt/cezen/feasibility.json" +LICENSE_JSON = "/opt/cezen/license.json" +OVERRIDE_JSON = "/opt/cezen/license.override.json" +INSTALL_RECORD_JSON = "/opt/cezen/install-record.json" +PUBLIC_KEY_PATH = os.environ.get("CEZEN_LICENSE_PUBLIC_KEY", f"{AIPACKAGE_DIR}/autoinstall/keys/cezen-license-public.pem") # ─── Helpers ────────────────────────────────────────────── def get_ip(): @@ -91,35 +104,84 @@ def apply_static_ip(iface, ip, prefix, gateway, dns): subprocess.run(["netplan", "apply"], capture_output=True) time.sleep(3) -def write_license_file(license_data, tier): +def save_license_artifacts(license_data): os.makedirs("/opt/cezen", exist_ok=True) + for target in (LICENSE_JSON, OVERRIDE_JSON): + try: + os.remove(target) + except FileNotFoundError: + pass + raw_license = (license_data.get("license_json") or "").strip() + raw_override = (license_data.get("override_json") or "").strip() + if raw_license: + payload = json.loads(raw_license) + write_json_file(LICENSE_JSON, payload) + subprocess.run(["chown", "root:cezen", LICENSE_JSON], check=False) + os.chmod(LICENSE_JSON, 0o640) + if raw_override: + payload = json.loads(raw_override) + write_json_file(OVERRIDE_JSON, payload) + subprocess.run(["chown", "root:cezen", OVERRIDE_JSON], check=False) + os.chmod(OVERRIDE_JSON, 0o640) + +def ensure_feasibility(): + os.makedirs("/opt/cezen", exist_ok=True) + if os.path.exists(FEASIBILITY_SCRIPT): + subprocess.run(["bash", FEASIBILITY_SCRIPT, FEASIBILITY_JSON], check=False) + return read_json_file(FEASIBILITY_JSON) + +def evaluate_install_constraints(license_data): + save_license_artifacts(license_data or {}) + feasibility = ensure_feasibility() + hardware_tier = normalize_tier(((feasibility.get("recommendation") or {}).get("recommended_tier")), "basic") + license_record = read_json_file(LICENSE_JSON) + override_record = read_json_file(OVERRIDE_JSON) + license_eval = evaluate_license(license_record, PUBLIC_KEY_PATH) + override_eval = evaluate_override(override_record, PUBLIC_KEY_PATH) + return { + "license": license_eval, + "override": override_eval, + "hardware": { + "recommended_tier": hardware_tier, + "recommended_profile": (feasibility.get("recommendation") or {}).get("recommended_profile", ""), + "estimated_concurrent_users": (feasibility.get("recommendation") or {}).get("estimated_concurrent_users", ""), + "notes": (feasibility.get("recommendation") or {}).get("notes", []), + }, + "tier_options": build_tier_options(license_eval, hardware_tier, override_eval), + } + +def write_install_record(license_data, tier, skip_tools, preview): payload = { - "schema": "cezen.license.v1", + "schema": "cezen.install_record.v1", + "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "customer_name": (license_data.get("customer_name") or "").strip(), "customer_id": (license_data.get("customer_id") or "").strip(), "contact_email": (license_data.get("contact_email") or "").strip(), - "license_key": (license_data.get("license_key") or "").strip(), - "tier": tier, "support_until": (license_data.get("support_until") or "").strip(), - "install_type": (license_data.get("install_type") or "customer").strip(), - "issued_by": "Cezen", - "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "signature_status": "unsigned-field-install", + "selected_tier": tier, + "provisioned_tier": tier, + "licensed_tier": ((preview.get("license") or {}).get("allowed_tier") or "basic"), + "license_status": ((preview.get("license") or {}).get("status") or "missing"), + "hardware_recommended_tier": ((preview.get("hardware") or {}).get("recommended_tier") or "starter"), + "hardware_recommended_profile": ((preview.get("hardware") or {}).get("recommended_profile") or "core"), + "install_type": ((preview.get("license") or {}).get("install_type") or "field-staging"), + "override_active": bool((preview.get("override") or {}).get("allow_hardware_mismatch")), + "skip_roles": skip_tools or [], + "components": {k: k not in set(skip_tools or []) for k in ["ollama", "jupyterlab", "chromadb", "vllm", "mlflow", "minio", "monitoring", "k3s"]}, } - with open("/opt/cezen/license.json", "w") as f: - json.dump(payload, f, indent=2) - subprocess.run(["chown", "root:cezen", "/opt/cezen/license.json"], check=False) - os.chmod("/opt/cezen/license.json", 0o640) - return payload + write_json_file(INSTALL_RECORD_JSON, payload) + subprocess.run(["chown", "root:cezen", INSTALL_RECORD_JSON], check=False) + os.chmod(INSTALL_RECORD_JSON, 0o640) -def run_install(tier, skip_tools, license_data=None): +def run_install(tier, skip_tools, license_data=None, preview=None): global install_status install_status = {"running": True, "done": False, "error": None} try: # Write config so phase 2 (post-reboot) knows what to skip os.makedirs("/opt/cezen", exist_ok=True) skip_str = ",".join(skip_tools) if skip_tools else "" - write_license_file(license_data or {}, tier) + save_license_artifacts(license_data or {}) + write_install_record(license_data or {}, tier, skip_tools or [], preview or {}) with open("/opt/cezen/install.conf", "w") as f: f.write(f"TIER={tier}\nSKIP_ROLES={skip_str}\n") @@ -187,9 +249,13 @@ HTML = r""" transition: all .2s; text-align: center; } .tier-card:hover { border-color: var(--teal2); background: #F0FDFA; } .tier-card.selected { border-color: var(--teal); background: #CCFBF1; } + .tier-card.disabled { cursor: not-allowed; opacity: .58; background: #F8FAFC; } + .tier-card.disabled:hover { border-color: #E2E8F0; background: #F8FAFC; } + .tier-card.override { border-style: dashed; } .tier-card .tier-name { font-size: 16px; font-weight: 700; color: var(--navy); margin-bottom: 4px; } .tier-card .tier-gpu { font-size: 12px; color: var(--teal); font-weight: 600; margin-bottom: 8px; } .tier-card .tier-users { font-size: 12px; color: var(--muted); } + .tier-card .tier-note { font-size: 11px; color: var(--red); margin-top: 8px; min-height: 28px; } /* Tool toggles */ .tool-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } @@ -217,9 +283,10 @@ HTML = r""" .form-group { margin-bottom: 16px; } .form-group label { display: block; font-size: 13px; font-weight: 600; color: var(--navy); margin-bottom: 6px; } - .form-group input { width: 100%; padding: 10px 14px; border: 1.5px solid #CBD5E1; border-radius: 8px; + .form-group input, .form-group textarea { width: 100%; padding: 10px 14px; border: 1.5px solid #CBD5E1; border-radius: 8px; font-size: 14px; outline: none; transition: border .15s; } - .form-group input:focus { border-color: var(--teal); } + .form-group textarea { min-height: 110px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; } + .form-group input:focus, .form-group textarea:focus { border-color: var(--teal); } .static-fields { display: none; } .static-fields.show { display: block; } .ip-row { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; } @@ -338,7 +405,7 @@ HTML = r""" @@ -376,21 +451,25 @@ HTML = r"""
Starter
1× RTX 5090 / 32GB VRAM
Small team deployment
+
Entry
1× NVIDIA RTX Pro 6000 (96GB)
Up to 20 concurrent users
+
Pro
2× RTX 5090 / RTX Pro class
Up to 100 concurrent users
+
Max
4–8× H100/H200/A100 class
200+ concurrent users
+
@@ -461,6 +540,7 @@ HTML = r""" // ── State ────────────────────────────────────────────────── let netMode = 'dhcp'; let selectedTier = 'basic'; +let tierPreview = null; let tools = { ollama: { name: 'Ollama + Open WebUI', desc: 'LLM inference & chat', icon: '🤖', on: true }, jupyterlab: { name: 'JupyterLab', desc: 'Notebook environment', icon: '📓', on: true }, @@ -478,7 +558,7 @@ window.onload = () => { document.getElementById('current-ip').textContent = d.ip || 'unknown'; }); renderTools(); - selectTier('basic'); + renderTierAvailability(null); }; // ── Navigation ───────────────────────────────────────────── @@ -525,7 +605,28 @@ function applyStaticIP() { } // ── Tier ─────────────────────────────────────────────────── +function renderTierAvailability(preview) { + tierPreview = preview; + const options = {}; + (preview?.tier_options || []).forEach(opt => options[opt.tier] = opt); + ['starter','basic','pro','max'].forEach(tier => { + const card = document.getElementById('tier-' + tier); + const note = document.getElementById('tier-note-' + tier); + const meta = options[tier] || { state: 'disabled_by_license', reason: 'Preview license to continue.', selectable: false }; + card.classList.toggle('disabled', !meta.selectable); + card.classList.toggle('override', meta.state === 'override_required'); + if (!meta.selectable && selectedTier === tier) selectedTier = ''; + note.textContent = meta.reason || (meta.state === 'override_required' ? 'Override active for hardware mismatch.' : ''); + }); + const fallback = (preview?.tier_options || []).find(opt => opt.selectable); + if (fallback && (!selectedTier || !(options[selectedTier] || {}).selectable)) { + selectTier(fallback.tier); + } +} + function selectTier(t) { + const opt = (tierPreview?.tier_options || []).find(x => x.tier === t); + if (opt && !opt.selectable) return; selectedTier = t; ['starter','basic','pro','max'].forEach(x => document.getElementById('tier-'+x).classList.toggle('selected', x===t)); @@ -556,12 +657,32 @@ function collectLicense() { customer_name: document.getElementById('lic-customer-name').value.trim(), customer_id: document.getElementById('lic-customer-id').value.trim(), contact_email: document.getElementById('lic-contact-email').value.trim(), - license_key: document.getElementById('lic-license-key').value.trim(), + license_json: document.getElementById('lic-license-json').value.trim(), + override_json: document.getElementById('lic-override-json').value.trim(), support_until: document.getElementById('lic-support-until').value.trim(), - install_type: document.getElementById('lic-license-key').value.trim() ? 'licensed' : 'field-staging' + install_type: document.getElementById('lic-license-json').value.trim() ? 'licensed' : 'field-staging' }; } +async function prepareTierStep() { + try { + const res = await api('/api/license-preview', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ license: collectLicense() }) + }); + renderTierAvailability(res); + document.getElementById('license-preview').innerHTML = ` + Status: ${esc(res.license?.status || 'missing')}
+ Allowed tier: ${esc(res.license?.allowed_tier || 'basic')}
+ Hardware recommendation: ${esc(res.hardware?.recommended_tier || 'starter')} + `; + goStep(3); + } catch (err) { + alert('License preview failed: ' + err.message); + } +} + // ── Summary ──────────────────────────────────────────────── function renderSummary() { const ip = netMode === 'dhcp' @@ -573,7 +694,9 @@ function renderSummary() { document.getElementById('summary-rows').innerHTML = `
Network${ip}
Customer${license.customer_name || 'Not entered'}
-
License${license.license_key ? 'Provided' : 'Field staging / evaluation'}
+
License${license.license_json ? (tierPreview?.license?.status || 'Provided') : 'Field staging / evaluation'}
+
Allowed Tier${tierPreview?.license?.allowed_tier || 'basic'}
+
Hardware Tier${tierPreview?.hardware?.recommended_tier || 'starter'}
Tier${selectedTier.charAt(0).toUpperCase()+selectedTier.slice(1)}
Tools${onTools}
${offTools.length ? `
Skipped${offTools.map(([,v])=>v.name).join(', ')}
` : ''} @@ -591,6 +714,14 @@ function startInstall() { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ tier: selectedTier, skip_tools: skip, license: collectLicense() }) + }).then(async r => { + const data = await r.json(); + if (!r.ok || !data.ok) throw new Error(data.error || 'Install start failed'); + }).catch(err => { + alert(err.message); + document.getElementById('install-btn-row').classList.remove('hidden'); + document.getElementById('summary-card').classList.remove('hidden'); + document.getElementById('progress-wrap').classList.remove('show'); }); streamLog(); @@ -780,13 +911,29 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: self.send_json({"ok": False, "error": str(e)}, 500) + elif path == "/api/license-preview": + try: + preview = evaluate_install_constraints(body.get("license", {}) or {}) + self.send_json(preview) + except Exception as e: + self.send_json({"ok": False, "error": str(e)}, 400) + elif path == "/api/install": global install_proc tier = body.get("tier", "basic") skip = body.get("skip_tools", []) license_data = body.get("license", {}) + try: + preview = evaluate_install_constraints(license_data or {}) + except Exception as e: + self.send_json({"ok": False, "error": f"License validation failed: {e}"}, 400) + return + selected = next((opt for opt in preview.get("tier_options", []) if opt.get("tier") == tier), None) + if not selected or not selected.get("selectable"): + self.send_json({"ok": False, "error": f"Tier '{tier}' is not allowed for this license/hardware."}, 400) + return if not install_status["running"]: - t = threading.Thread(target=run_install, args=(tier, skip, license_data), daemon=True) + t = threading.Thread(target=run_install, args=(tier, skip, license_data, preview), daemon=True) t.start() self.send_json({"ok": True}) else: diff --git a/cezen-portal/appliance.html b/cezen-portal/appliance.html index 47ea2f6..54f63f2 100644 --- a/cezen-portal/appliance.html +++ b/cezen-portal/appliance.html @@ -170,7 +170,7 @@ - 🔔 + Basic Tier @@ -186,6 +186,9 @@ + + +
@@ -217,6 +220,16 @@
Loading…
+
+

Entitlement

+
Loading…
+
+ +
+

Feature Access

+
Loading…
+
+

Readiness Checks

Loading…
@@ -271,12 +284,13 @@ function renderReadiness(d) { readinessData = d; const score = d.readiness?.score ?? 0; const status = d.readiness?.status || 'unknown'; + const entitlement = d.entitlement || d.license?.entitlement || {}; document.getElementById('readiness-score').textContent = score + '%'; const statusEl = document.getElementById('readiness-status'); statusEl.textContent = status.replace(/_/g, ' '); statusEl.className = 'pill ' + (status === 'ready' ? 'ok' : status === 'limited' ? 'warn' : 'bad'); 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 rec = d.feasibility?.recommendation || {}; @@ -299,6 +313,22 @@ function renderReadiness(d) { ['GPU', gpu ? `${gpu.name || 'GPU'} · ${gpu.vram_gb || 0} GB` : 'None detected'], ].map(row => `
${esc(row[0])}
${esc(row[1])}
`).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 => `
${esc(row[0])}
${esc(row[1])}
`).join(''); + + const featureEntries = Object.entries(entitlement.effective_features || {}); + document.getElementById('features-list').innerHTML = featureEntries.length ? featureEntries.map(([k,v]) => + `
${esc(k.replace(/_/g,' '))}
${esc(v)}
` + ).join('') : '
No feature details available.
'; + document.getElementById('checks-list').innerHTML = (d.readiness?.checks || []).map(c => `
@@ -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() { try { toast('Creating backup…'); diff --git a/install.sh b/install.sh index f3458ad..5d5274d 100644 --- a/install.sh +++ b/install.sh @@ -31,7 +31,12 @@ PROFILE="auto" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ANSIBLE_DIR="$SCRIPT_DIR/ansible" FEASIBILITY_SCRIPT="$SCRIPT_DIR/scripts/cezen-feasibility.sh" +LICENSE_CHECK_SCRIPT="$SCRIPT_DIR/scripts/cezen-license-check.py" 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) [ -f /opt/cezen/install.conf ] && source /opt/cezen/install.conf @@ -119,6 +124,126 @@ except Exception: 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() { [ -f "$FEASIBILITY_JSON" ] || return 0 local detected_profile @@ -263,6 +388,7 @@ run_phase2() { echo "→ Tier: $TIER | Skip: ${SKIP_ROLES:-none}" echo "→ GPU available: $GPU_AVAILABLE" echo "→ Skip model pull: $SKIP_MODEL_PULL" + write_install_record # Select Ansible playbook by tier case "$TIER" in @@ -311,6 +437,8 @@ if [ "$SOFTWARE_ONLY" = true ]; then PHASE="2" fi +enforce_tier_constraints + install_ansible if [ "$PHASE" = "1" ]; then diff --git a/scripts/cezen-license-check.py b/scripts/cezen-license-check.py new file mode 100644 index 0000000..7b48ebf --- /dev/null +++ b/scripts/cezen-license-check.py @@ -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() diff --git a/scripts/cezen_license.py b/scripts/cezen_license.py new file mode 100644 index 0000000..86fb1aa --- /dev/null +++ b/scripts/cezen_license.py @@ -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", + } diff --git a/scripts/sign-license.py b/scripts/sign-license.py new file mode 100644 index 0000000..5bb4f90 --- /dev/null +++ b/scripts/sign-license.py @@ -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()