Add signed licensing enforcement and ISO notes
This commit is contained in:
parent
dee4486826
commit
d645249a13
56
README.md
56
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.
|
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.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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:
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxOGixLLE9iwPNngpX2gr
|
||||||
|
pUiVPHN0//LcBUovRsKr1qq5xXRxhMmE1Fgux8yMHBlTLRCKdd4wH1N3E7EGMg9T
|
||||||
|
vHeLQFZJ8uGkK2U7X9nY6h9prAe9VVNvz6OwdYQqxPbttW723w2cy2p2/Jdxry5x
|
||||||
|
9iqJ3Q084cDyT30QHdkhqGTiYFAJ7+K95acktuKUs/A2WuHgEmhbK8aCWW2kQzLS
|
||||||
|
x50aFjxGwXbHydXG7D0WJYCXKPjTXusjTNuPopjUH3Yp9xqieGqqxSFXFkIXdrgc
|
||||||
|
wkpMmgJEz/RQlG+fFkGd3VdoxD3taRMamuVky+9Kf6MbMDZKoxte4gf2FjXav9j8
|
||||||
|
mQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
321
ansible/roles/cezen-backend/files/cezen_license.py
Normal file
321
ansible/roles/cezen-backend/files/cezen_license.py
Normal 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",
|
||||||
|
}
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 — 4–8× 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 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
|
$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" \
|
||||||
|
|||||||
9
autoinstall/keys/cezen-license-public.pem
Normal file
9
autoinstall/keys/cezen-license-public.pem
Normal 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-----
|
||||||
@ -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">4–8× H100/H200/A100 class</div>
|
<div class="tier-gpu">4–8× 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:
|
||||||
|
|||||||
@ -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…');
|
||||||
|
|||||||
128
install.sh
128
install.sh
@ -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
|
||||||
|
|||||||
60
scripts/cezen-license-check.py
Normal file
60
scripts/cezen-license-check.py
Normal 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
321
scripts/cezen_license.py
Normal 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
54
scripts/sign-license.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user