69 lines
7.3 KiB
Python
69 lines
7.3 KiB
Python
from __future__ import annotations
|
|
import hashlib,json,re,tarfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
OID_RE=re.compile(r"^[0-9a-f]{40}$"); SHA_RE=re.compile(r"^[0-9a-f]{64}$"); VERSION_RE=re.compile(r"^V2R1C[0-9]+$")
|
|
REQUIRED_SOURCE={"schema","version","repository","base_ref","base_oid","binding_mode","release_readiness","production_deployed","tag_created","baseline_changed","production_service","production_port","rollback_metadata","controller_schema","required_checks"}
|
|
REQUEST_KEYS={"schema","version","repository","base_oid","target_oid","artifact_uri","artifact_sha256","artifact_size","evidence_closure_oid","approval_plan_id","execute","production_deployed","production_service","production_port","rollback_metadata","current_tag_oid","source_manifest_sha256"}
|
|
class ManifestValidationError(ValueError):
|
|
def __init__(self,code:str,message:str): super().__init__(message); self.code=code
|
|
def _canonical(value:Any)->bytes: return (json.dumps(value,sort_keys=True,separators=(",",":"))+"\n").encode()
|
|
def sha256_bytes(value:bytes)->str: return hashlib.sha256(value).hexdigest()
|
|
def sha256_file(path:Path)->str: return sha256_bytes(path.read_bytes())
|
|
def _json_object(path:Path)->dict[str,Any]:
|
|
try: value=json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError,ValueError) as exc: raise ManifestValidationError("INVALID_JSON",str(exc)) from exc
|
|
if not isinstance(value,dict): raise ManifestValidationError("INVALID_JSON","expected object")
|
|
return value
|
|
def _oid(value:Any,name:str)->str:
|
|
if not isinstance(value,str) or not OID_RE.fullmatch(value): raise ManifestValidationError("INVALID_OID",name)
|
|
return value
|
|
def _sha(value:Any,name:str)->str:
|
|
if not isinstance(value,str) or not SHA_RE.fullmatch(value): raise ManifestValidationError("INVALID_SHA256",name)
|
|
return value
|
|
def validate_deployment_state(state:str,previous:str|None=None)->str:
|
|
if state not in {"STAGED","ACTIVE","ROLLED_BACK"}: raise ManifestValidationError("DEPLOYMENT_STATE","unsupported state")
|
|
if previous=="ACTIVE" and state=="STAGED": raise ManifestValidationError("DEPLOYMENT_STATE_TRANSITION","active cannot return to staged")
|
|
return state
|
|
def validate_source_manifest(path:Path,expected_base_oid:str)->dict[str,Any]:
|
|
data=_json_object(path)
|
|
if not REQUIRED_SOURCE <= set(data): raise ManifestValidationError("SOURCE_SCHEMA_KEYS","required manifest keys missing")
|
|
if data["schema"]!="aios.release-source.v2": raise ManifestValidationError("SOURCE_SCHEMA","unsupported source schema")
|
|
if not VERSION_RE.fullmatch(str(data["version"])) or data["repository"]!="aios/aios": raise ManifestValidationError("SOURCE_IDENTITY","unsupported version/repository")
|
|
if data["base_ref"]!="refs/heads/main": raise ManifestValidationError("SOURCE_BASE_REF","base ref mismatch")
|
|
if _oid(data["base_oid"],"base_oid")!=expected_base_oid: raise ManifestValidationError("BASE_OID_MISMATCH","authorized base mismatch")
|
|
if data["binding_mode"]!="external_exact_oid": raise ManifestValidationError("BINDING_MODE","exact OID required")
|
|
if data["release_readiness"] not in {"READY","ENGINEERING_READY"}: raise ManifestValidationError("SOURCE_NOT_READY","source not ready")
|
|
if any(data[k] is not False for k in ("production_deployed","tag_created","baseline_changed")): raise ManifestValidationError("PRODUCTION_FLAG_SET","production mutation flag set")
|
|
if not isinstance(data["production_service"],str) or not data["production_service"] or not isinstance(data["production_port"],int) or not 1024<=data["production_port"]<=65535: raise ManifestValidationError("SERVICE_IDENTITY","service binding invalid")
|
|
if not isinstance(data["required_checks"],list) or not {"source_manifest","deterministic_artifact","health_probe","rollback"} <= set(data["required_checks"]): raise ManifestValidationError("REQUIRED_CHECKS","required checks incomplete")
|
|
return data
|
|
def _safe_member(name:str)->bool:
|
|
p=Path(name); return not p.is_absolute() and ".." not in p.parts and not name.startswith("/")
|
|
def verify_deployable_artifact(path:Path,required_members:set[str])->list[str]:
|
|
if not path.is_file(): raise ManifestValidationError("ARTIFACT_MISSING",str(path))
|
|
try:
|
|
with tarfile.open(path,mode="r:*") as archive:
|
|
members=archive.getmembers()
|
|
if any(not _safe_member(m.name) or m.issym() or m.islnk() or not (m.isfile() or m.isdir()) for m in members): raise ManifestValidationError("ARTIFACT_MEMBER","unsafe member")
|
|
names={m.name for m in members}
|
|
except (OSError,tarfile.TarError) as exc: raise ManifestValidationError("ARTIFACT_FORMAT",str(exc)) from exc
|
|
missing=sorted(required_members-names)
|
|
if missing: raise ManifestValidationError("ARTIFACT_CONTENT",",".join(missing))
|
|
return sorted(names)
|
|
def validate_release_request(source_manifest:dict[str,Any],request_path:Path,expected_main_oid:str|None=None)->dict[str,Any]:
|
|
request=_json_object(request_path)
|
|
if set(request)!=REQUEST_KEYS: raise ManifestValidationError("REQUEST_SCHEMA_KEYS","request keys mismatch")
|
|
if request["schema"]!="aios.release-request.v2" or request["version"]!=source_manifest["version"] or request["repository"]!="aios/aios": raise ManifestValidationError("REQUEST_IDENTITY","request identity mismatch")
|
|
if request["base_oid"]!=source_manifest["base_oid"]: raise ManifestValidationError("REQUEST_BASE_OID","base differs")
|
|
_oid(request["target_oid"],"target_oid")
|
|
if expected_main_oid is not None and request["target_oid"]!=expected_main_oid: raise ManifestValidationError("TARGET_OID_MISMATCH","target differs")
|
|
_sha(request["artifact_sha256"],"artifact_sha256"); _oid(request["evidence_closure_oid"],"evidence_closure_oid")
|
|
if not isinstance(request["approval_plan_id"],str) or not request["approval_plan_id"].startswith("sha256:"): raise ManifestValidationError("APPROVAL_PLAN","invalid approval plan")
|
|
if request["execute"] is not False or request["production_deployed"] is not False: raise ManifestValidationError("EXECUTION_NOT_ALLOWED","must be non-executing")
|
|
if request["production_service"]!=source_manifest["production_service"] or request["production_port"]!=source_manifest["production_port"]: raise ManifestValidationError("SERVICE_IDENTITY","request service mismatch")
|
|
if not isinstance(request["current_tag_oid"],str) or request["current_tag_oid"] not in {"V2R1C6","9e65bd59baea392294c0f253046d3ab116f63145"}: raise ManifestValidationError("PROTECTED_TAG","previous tag binding changed")
|
|
_sha(request["source_manifest_sha256"],"source_manifest_sha256"); return request
|
|
def build_approval_request(*,source_manifest_path:Path,target_oid:str,artifact_uri:str,artifact_sha256:str,artifact_size:int,evidence_closure_oid:str,rollback_metadata:str,current_tag_oid:str)->dict[str,Any]:
|
|
source=_json_object(source_manifest_path); _oid(target_oid,"target_oid"); _sha(artifact_sha256,"artifact_sha256"); _oid(evidence_closure_oid,"evidence_closure_oid"); request={"schema":"aios.release-request.v2","version":source["version"],"repository":source["repository"],"base_oid":source["base_oid"],"target_oid":target_oid,"artifact_uri":artifact_uri,"artifact_sha256":artifact_sha256,"artifact_size":artifact_size,"evidence_closure_oid":evidence_closure_oid,"execute":False,"production_deployed":False,"production_service":source["production_service"],"production_port":source["production_port"],"rollback_metadata":rollback_metadata,"current_tag_oid":current_tag_oid,"source_manifest_sha256":sha256_file(source_manifest_path)}; request["approval_plan_id"]="sha256:"+sha256_bytes(_canonical(request)); return request
|