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