954 lines
56 KiB
Python
954 lines
56 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
try:
|
|
from aios_gitops_write_guard import evaluate_git_write
|
|
except ModuleNotFoundError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from aios_gitops_write_guard import evaluate_git_write
|
|
|
|
from gitea_tag_publication import (
|
|
GiteaTagClient,
|
|
GitTagBackend,
|
|
PublicationConflict,
|
|
PublicationPending,
|
|
PublicationManualOwnerReviewRequired,
|
|
PublicationReconciliationRequired,
|
|
PublicationRequest,
|
|
TagPublisher,
|
|
redact,
|
|
validate_version,
|
|
)
|
|
from orchestrator.action_monitor import ActionMonitor
|
|
from silent_release_hardening import (
|
|
ApprovalKeyRing,
|
|
ApprovalRevocationStore,
|
|
CapabilityAuthorizationError,
|
|
SagaConflict,
|
|
SignedApprovalVerifier,
|
|
SignedReleasePlanApproval,
|
|
SilentReleaseActionService,
|
|
)
|
|
try:
|
|
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
|
except ModuleNotFoundError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
|
try:
|
|
from authoritative_git import (
|
|
AuthoritativeGitError,
|
|
AuthoritativeGitNotFound,
|
|
AuthoritativeGitOutputTooLarge,
|
|
authoritative_branches,
|
|
authoritative_diff,
|
|
authoritative_commit,
|
|
authoritative_tags,
|
|
repository_snapshot,
|
|
run_authoritative_git,
|
|
)
|
|
except ModuleNotFoundError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from authoritative_git import (
|
|
AuthoritativeGitError,
|
|
AuthoritativeGitNotFound,
|
|
AuthoritativeGitOutputTooLarge,
|
|
authoritative_branches,
|
|
authoritative_diff,
|
|
authoritative_commit,
|
|
authoritative_tags,
|
|
repository_snapshot,
|
|
run_authoritative_git,
|
|
)
|
|
|
|
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
|
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
|
SECRETS = Path(os.getenv('AIOS_GITOPS_SECRETS_DIR', str(ROOT / 'runtime' / 'secrets')))
|
|
TOKEN_FILE = Path(os.getenv('AIOS_GITOPS_TOKEN_FILE', str(SECRETS / 'gitops.tokens')))
|
|
HOST = os.getenv('AIOS_GITOPS_HOST', '127.0.0.1')
|
|
PORT = int(os.getenv('AIOS_GITOPS_PORT', '18890'))
|
|
OPENAPI = WS / 'docs' / 'gitops' / 'openapi-chatgpt-actions.yaml'
|
|
BARE_REPO = Path(os.getenv('AIOS_GITOPS_BARE_REPO', str(ROOT / 'runtime' / 'gitea-bin' / 'data' / 'repositories' / 'aios' / 'aios.git')))
|
|
AUTHORITATIVE_GIT_DIR = Path(os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR')) if os.getenv('AIOS_GITOPS_AUTHORITATIVE_GIT_DIR') else None
|
|
REPOSITORY_IDENTITY = os.getenv('AIOS_GITOPS_REPOSITORY_IDENTITY', '')
|
|
SILENT_RELEASE_STATE_ROOT = Path(os.getenv(
|
|
'AIOS_SILENT_RELEASE_STATE_ROOT',
|
|
'/var/lib/aios-gitops/silent-release',
|
|
))
|
|
SILENT_RELEASE_EXECUTION_ENABLED = str(os.getenv(
|
|
'AIOS_SILENT_RELEASE_EXECUTION_ENABLED', 'false'
|
|
)).lower() in {'1', 'true', 'yes', 'on'}
|
|
SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
|
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
|
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
|
))
|
|
monitor = ActionMonitor()
|
|
|
|
BASE_MODELS = [
|
|
{'id':'base.deepseekv4-flash','provider':'one-api','model':'deepseekv4-flash','display_name':'DeepSeek V4 Flash','scope':'global'},
|
|
{'id':'base.qwen3.6-35b','provider':'one-api','model':'qwen3.6-35b','display_name':'Qwen 3.6 35B','scope':'global'},
|
|
{'id':'base.gpt-5.5','provider':'openai','model':'gpt-5.5','display_name':'GPT-5.5','scope':'global'},
|
|
]
|
|
SECRET_RE = re.compile(r'(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)\s*[:=]\s*["\']?([A-Za-z0-9_./+=:-]{16,})')
|
|
SECRET_VAL_RE = re.compile(
|
|
r'(?<![A-Za-z0-9_])'
|
|
r'(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,})'
|
|
)
|
|
|
|
READ_ONLY_GET_PATHS = frozenset({
|
|
'/', '/health', '/api/health', '/api/openapi.yaml',
|
|
'/api/openapi-chatgpt-actions.yaml', '/api/schema', '/api', '/api/status',
|
|
'/api/base', '/api/baseline', '/base', '/api/ver', '/api/versions', '/ver',
|
|
'/api/diff', '/diff', '/api/patch', '/api/patches', '/api/branches', '/patch',
|
|
'/api/model', '/model', '/api/run', '/api/runtime-status', '/run',
|
|
'/api/action-monitor', '/action-monitor', '/admin/ai-evolution',
|
|
'/admin/ai-evolution/api/data',
|
|
'/api/release-consistency', '/api/tag-publication-status',
|
|
'/api/release-acceptance',
|
|
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
|
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
|
'/getRecoveryStatus',
|
|
})
|
|
SILENT_RELEASE_POST_PATHS = frozenset({
|
|
'/createReleaseCandidate',
|
|
'/createReleaseIntent',
|
|
'/submitSignedApproval',
|
|
'/submitOwnerApproval',
|
|
'/startDeployment',
|
|
})
|
|
|
|
def reject_non_read_http(handler, method: str, path: str) -> bool:
|
|
if method == 'GET':
|
|
return False
|
|
if path in SILENT_RELEASE_POST_PATHS:
|
|
return False
|
|
if (
|
|
method == 'POST'
|
|
and path in ('/api/file', '/api/commit-file')
|
|
and os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() in {'1', 'true', 'yes', 'on'}
|
|
):
|
|
return False
|
|
decision = evaluate_git_write(WS)
|
|
if decision.allowed:
|
|
return False
|
|
handler.sendj(decision.payload(), decision.status_code)
|
|
return True
|
|
|
|
def now() -> str: return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
|
def uid(p='id') -> str: return f'{p}-{int(time.time()*1000)}-{uuid.uuid4().hex[:8]}'
|
|
def safe(s: str, n=160) -> str: return re.sub(r'[^A-Za-z0-9_./@-]+', '-', str(s or 'unknown')).strip('/-')[:n] or 'unknown'
|
|
def sh(args, check=False):
|
|
p = subprocess.run(['git'] + args, cwd=str(WS), text=True, capture_output=True)
|
|
if check and p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or 'git failed')
|
|
return {'code':p.returncode,'stdout':p.stdout.strip(),'stderr':p.stderr.strip(),'cmd':['git']+args}
|
|
def jload(p: Path, default):
|
|
try: return json.loads(p.read_text(encoding='utf-8'))
|
|
except Exception: return default
|
|
|
|
def _file_sha256(path: Path) -> str:
|
|
digest=hashlib.sha256()
|
|
with path.open('rb') as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b''):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
def _build_silent_release_service() -> SilentReleaseActionService:
|
|
verifier=None
|
|
if SILENT_RELEASE_PUBLIC_KEYS.is_file():
|
|
verifier=SignedApprovalVerifier(
|
|
ApprovalKeyRing.from_file(SILENT_RELEASE_PUBLIC_KEYS),
|
|
ApprovalRevocationStore(
|
|
SILENT_RELEASE_STATE_ROOT / 'revocations'
|
|
),
|
|
)
|
|
policy_path=WS / 'config' / 'gitops' / 'silent-release-policy.json'
|
|
policy_sha=_file_sha256(policy_path) if policy_path.is_file() else ''
|
|
return SilentReleaseActionService(
|
|
SILENT_RELEASE_STATE_ROOT,
|
|
execution_enabled=SILENT_RELEASE_EXECUTION_ENABLED,
|
|
approval_verifier=verifier,
|
|
expected_repository=REPOSITORY_IDENTITY,
|
|
policy_sha256=policy_sha,
|
|
)
|
|
|
|
# Construct once during service startup. If production execution is enabled
|
|
# without every trusted builder/orchestrator dependency, construction fails
|
|
# closed before the HTTP server accepts traffic.
|
|
_SILENT_RELEASE_SERVICE = _build_silent_release_service()
|
|
|
|
|
|
def silent_release_service() -> SilentReleaseActionService:
|
|
return _SILENT_RELEASE_SERVICE
|
|
def relpath(path: str) -> str:
|
|
x = str(path or '').replace('\\','/').strip()
|
|
if not x or x.startswith('/') or '..' in Path(x).parts or Path(x).parts[0] == '.git': raise ValueError('invalid path')
|
|
return str(Path(x))
|
|
def branch_name(name: str | None, prefix='patch/chatgpt') -> str:
|
|
b = safe(name or f'{prefix}-{uid("br")}', 180)
|
|
if b in ('main','master') or b.startswith('main/') or b.startswith('master/'): raise ValueError('direct main/master writes are forbidden')
|
|
return b
|
|
def _json_has_plain_secret(obj: Any) -> bool:
|
|
if isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
lk = str(k).lower()
|
|
if lk == 'secret_ref' or lk.endswith('_secret_ref'):
|
|
continue
|
|
if lk in {'api_key', 'access_key', 'secret_access_key', 'secret_key', 'password', 'token', 'bearer_token'}:
|
|
if isinstance(v, str) and v and not v.startswith('secret://') and v != 'REDACTED':
|
|
return True
|
|
if _json_has_plain_secret(v):
|
|
return True
|
|
return False
|
|
if isinstance(obj, list):
|
|
return any(_json_has_plain_secret(x) for x in obj)
|
|
if isinstance(obj, str):
|
|
return bool(obj and not obj.startswith('secret://') and obj != 'REDACTED' and SECRET_VAL_RE.search(obj))
|
|
return False
|
|
|
|
def content_has_secret(text: str) -> bool:
|
|
text = text or ''
|
|
try:
|
|
return _json_has_plain_secret(json.loads(text))
|
|
except Exception:
|
|
pass
|
|
if SECRET_VAL_RE.search(text):
|
|
return True
|
|
for m in SECRET_RE.finditer(text):
|
|
key = str(m.group(1)).lower()
|
|
value = str(m.group(2))
|
|
if 'secret_ref' in key or value.startswith('secret://') or value == 'REDACTED':
|
|
continue
|
|
return True
|
|
return False
|
|
def token_records():
|
|
if not TOKEN_FILE.exists(): return []
|
|
out=[]
|
|
for raw in TOKEN_FILE.read_text(encoding='utf-8').splitlines():
|
|
line=raw.strip()
|
|
if not line or line.startswith('#'): continue
|
|
if line.startswith('{'):
|
|
try: out.append(json.loads(line))
|
|
except Exception: pass
|
|
else:
|
|
out.append({'account':'legacy-token','role':'owner','scopes':['*'],'token_plain':line})
|
|
return out
|
|
def authenticate(token: str):
|
|
if os.getenv('AIOS_GITOPS_DEV_ALLOW','0') == '1': return {'account':'dev-owner','role':'owner','scopes':['*']}
|
|
if not token: return None
|
|
digest=hashlib.sha256(token.encode()).hexdigest()
|
|
for r in token_records():
|
|
if r.get('disabled'): continue
|
|
if r.get('token_sha256') and hmac.compare_digest(str(r['token_sha256']), digest): return r
|
|
if r.get('token_plain') and hmac.compare_digest(str(r['token_plain']), token): return r
|
|
return None
|
|
def sanitize(obj: Any) -> Any:
|
|
if isinstance(obj, dict):
|
|
return {k: ('REDACTED' if re.search(r'(?i)(api[_-]?key|secret|password|token)', str(k)) else sanitize(v)) for k,v in obj.items()}
|
|
if isinstance(obj, list): return [sanitize(x) for x in obj]
|
|
if isinstance(obj, str): return SECRET_VAL_RE.sub('REDACTED', obj)
|
|
return obj
|
|
def ensure_git():
|
|
if not sh(['config','user.email'])['stdout']: sh(['config','user.email','chatgpt-gitops-bot@cnnetmkt.top'], True)
|
|
if not sh(['config','user.name'])['stdout']: sh(['config','user.name','chatgpt-gitops-bot'], True)
|
|
def checkout(branch, base='main', create=False):
|
|
ensure_git(); b=branch_name(branch)
|
|
exists = sh(['rev-parse','--verify',f'refs/heads/{b}'])['code'] == 0
|
|
if exists:
|
|
sh(['checkout',b], True)
|
|
elif create:
|
|
sh(['checkout',base], True); sh(['checkout','-b',b], True)
|
|
else:
|
|
raise ValueError(f'branch does not exist: {b}')
|
|
return b
|
|
def write_file(path, content):
|
|
if content_has_secret(content): raise ValueError('secret_rejected: use secret_ref; plaintext API keys must not enter Git')
|
|
r=relpath(path); t=WS/r; t.parent.mkdir(parents=True, exist_ok=True); t.write_text(content, encoding='utf-8'); return r
|
|
def write_json(path, data): return write_file(path, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
def sync_branch(branch: str) -> dict:
|
|
if not branch or branch in ('main','master') or branch.startswith('main/') or branch.startswith('master/'):
|
|
return {'status':'skipped','reason':'protected_branch','branch':branch}
|
|
if BARE_REPO.exists():
|
|
common = sh(['rev-parse', '--path-format=absolute', '--git-common-dir'], True)['stdout']
|
|
if Path(common).resolve() == BARE_REPO.resolve():
|
|
head = sh(['rev-parse', 'HEAD'], True)['stdout']
|
|
stored = subprocess.run(
|
|
['git', '--git-dir', str(BARE_REPO), 'rev-parse', f'refs/heads/{branch}'],
|
|
text=True, capture_output=True,
|
|
)
|
|
if stored.returncode != 0 or stored.stdout.strip() != head:
|
|
raise RuntimeError(f'linked-worktree branch verification failed for {branch}')
|
|
return {
|
|
'status':'verified', 'target':'authoritative-bare',
|
|
'branch':branch, 'oid':head,
|
|
}
|
|
ref=f'refs/heads/{branch}:refs/heads/{branch}'
|
|
p=subprocess.run(['git','--git-dir',str(BARE_REPO),'fetch',str(WS),ref], text=True, capture_output=True)
|
|
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git sync failed for {branch}')
|
|
return {'status':'synced','target':'bare','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
|
p=subprocess.run(['git','push','origin',f'HEAD:refs/heads/{branch}'], cwd=str(WS), text=True, capture_output=True)
|
|
if p.returncode != 0: raise RuntimeError(p.stderr or p.stdout or f'git push failed for {branch}')
|
|
return {'status':'synced','target':'origin','branch':branch,'stdout':p.stdout.strip(),'stderr':p.stderr.strip()}
|
|
|
|
def sync_tag(tag: str) -> dict:
|
|
raise RuntimeError(
|
|
'unsafe sync_tag disabled: use TagPublisher with expected tag_oid, '
|
|
'target_oid, Gitea remote verification, and authenticated API verification'
|
|
)
|
|
|
|
def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
|
oid = str(value or '').strip().lower()
|
|
if not re.fullmatch(r'[0-9a-f]{40}', oid):
|
|
raise ValueError(f'{field} must be a full 40-character commit OID')
|
|
try:
|
|
return authoritative_commit(oid, git_dir=AUTHORITATIVE_GIT_DIR)
|
|
except (AuthoritativeGitError, ValueError) as exc:
|
|
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
|
|
|
def existing_tag_target(tag: str) -> str | None:
|
|
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
|
raise ValueError('invalid release version')
|
|
try:
|
|
result = run_authoritative_git(
|
|
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
|
check=False,
|
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
)
|
|
except AuthoritativeGitError as exc:
|
|
raise RuntimeError(str(exc)) from exc
|
|
return result.stdout.strip() if result.returncode == 0 else None
|
|
|
|
def publication_request_from_query(q: dict[str, list[str]]) -> PublicationRequest:
|
|
required = ('version', 'target_oid', 'evidence_closure_oid', 'approval_plan_id')
|
|
missing = [name for name in required if not (q.get(name) or [''])[0]]
|
|
if missing:
|
|
raise ValueError('missing release governance fields: ' + ', '.join(missing))
|
|
return PublicationRequest(
|
|
(q['version'])[0],
|
|
(q['target_oid'])[0].lower(),
|
|
(q['evidence_closure_oid'])[0].lower(),
|
|
(q['approval_plan_id'])[0],
|
|
)
|
|
|
|
def required_authoritative_git_dir() -> Path:
|
|
snapshot = repository_snapshot(
|
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
repository_identity=REPOSITORY_IDENTITY,
|
|
)
|
|
if not snapshot["repository_available"]:
|
|
raise RuntimeError(str(snapshot["query_error"] or "AUTHORITATIVE_REPOSITORY_UNAVAILABLE"))
|
|
return Path(str(snapshot["authoritative_git_dir"]))
|
|
|
|
|
|
def publication_publisher() -> TagPublisher:
|
|
authoritative_repo = required_authoritative_git_dir()
|
|
return TagPublisher(
|
|
WS,
|
|
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
|
GiteaTagClient(
|
|
os.getenv('AIOS_GITOPS_GITEA_API_URL',''),
|
|
os.getenv('AIOS_GITOPS_GITEA_REPOSITORY',''),
|
|
Path(os.getenv('AIOS_GITOPS_GITEA_TOKEN_FILE','')),
|
|
token_line=int(os.getenv('AIOS_GITOPS_GITEA_TOKEN_LINE','1')),
|
|
),
|
|
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
|
)
|
|
|
|
def commit(paths, msg):
|
|
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
|
branch=sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']
|
|
if not sh(['status','--short'])['stdout']:
|
|
return {'status':'no_changes','branch':branch,'sync':sync_branch(branch)}
|
|
out=sh(['commit','-m',msg], True); branch=sh(['rev-parse','--abbrev-ref','HEAD'])['stdout']; sync=sync_branch(branch); return {'status':'committed','branch':branch,'rev':sh(['rev-parse','--short','HEAD'], True)['stdout'],'git':out,'sync':sync}
|
|
def audit(actor, action, target, payload):
|
|
p=ROOT/'artifacts'/'chatgpt-gitops-audit.jsonl'; p.parent.mkdir(parents=True, exist_ok=True)
|
|
with p.open('a', encoding='utf-8') as f: f.write(json.dumps({'at':now(),'actor':actor,'action':action,'target':target,'payload':sanitize(payload)}, ensure_ascii=False)+'\n')
|
|
def model_docs():
|
|
out=[]
|
|
for p in sorted((WS/'config'/'model_registry').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
|
return out
|
|
def patches():
|
|
out=[]
|
|
for p in sorted((WS/'patches').glob('**/*.json')): out.append(jload(p, {'path':str(p.relative_to(WS))}))
|
|
return out
|
|
|
|
def runtime_release_info() -> dict[str, Any]:
|
|
for candidate in (WS / 'manifest.json', WS.parent / 'manifest.json'):
|
|
data = jload(candidate, {})
|
|
if data.get('target_commit'):
|
|
return {
|
|
'runtime_release_commit': data.get('target_commit'),
|
|
'runtime_release_path': str(WS),
|
|
}
|
|
return {'runtime_release_commit': None, 'runtime_release_path': str(WS)}
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
server_version='AIOSChatGPTGitOps/V1R1C0'
|
|
def sendj(self, data, code=200):
|
|
b=json.dumps(data, ensure_ascii=False, indent=2).encode(); self.send_response(code); self.send_header('Content-Type','application/json; charset=utf-8'); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
|
def sendt(self, text, ctype='text/plain; charset=utf-8', code=200):
|
|
b=text.encode(); self.send_response(code); self.send_header('Content-Type',ctype); self.send_header('Content-Length',str(len(b))); self.end_headers(); self.wfile.write(b)
|
|
def body(self):
|
|
n=int(self.headers.get('Content-Length','0') or '0'); raw=self.rfile.read(n).decode() if n else '{}'
|
|
try: return json.loads(raw or '{}')
|
|
except Exception: return {'_raw':raw}
|
|
def actor(self):
|
|
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
|
rec=authenticate(tok)
|
|
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or [])}
|
|
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
|
def need(self, scope):
|
|
a=self.actor()
|
|
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
|
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
|
return a
|
|
def bridge_release(self, path, data):
|
|
route = {
|
|
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
|
'/requestOrCreateTag': '/v2/tag/publish',
|
|
'/getTagPublicationStatus': '/v2/tag/status',
|
|
'/buildProductionArtifact': '/v2/release/artifact',
|
|
'/executeApprovedProductionRelease': '/v2/release/execute',
|
|
'/getReleaseConsistency': '/v2/release/consistency',
|
|
'/runReleaseAcceptance': '/v2/release/acceptance',
|
|
}.get(path)
|
|
if route is None:
|
|
raise ValueError('governed_release_route_invalid')
|
|
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
|
request = urllib.request.Request(
|
|
base + route,
|
|
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
|
headers={'Content-Type': 'application/json'},
|
|
method='POST',
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
result = json.loads(response.read().decode('utf-8'))
|
|
return self.sendj(result, response.status)
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
body = json.loads(exc.read().decode('utf-8'))
|
|
except Exception:
|
|
body = {'error': 'bridge_http_error'}
|
|
return self.sendj(body, exc.code)
|
|
except (urllib.error.URLError, TimeoutError) as exc:
|
|
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
|
def do_POST(self):
|
|
path = urllib.parse.urlparse(self.path).path
|
|
if path in {
|
|
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
|
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
|
'/getReleaseConsistency', '/runReleaseAcceptance',
|
|
}:
|
|
actor = self.need('tag:create')
|
|
if not actor:
|
|
return
|
|
return self.bridge_release(path, self.body())
|
|
if reject_non_read_http(self, 'POST', path):
|
|
return
|
|
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
|
def do_GET(self):
|
|
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
|
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
|
q=urllib.parse.parse_qs(u.query)
|
|
try:
|
|
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
|
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
|
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
|
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
|
if path in ('/api/base','/api/baseline','/base'):
|
|
data=jload(WS/'baseline'/'current.json', {})
|
|
data.setdefault('authoritative_repository', repository_snapshot(
|
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
repository_identity=REPOSITORY_IDENTITY,
|
|
))
|
|
return self.sendj(data)
|
|
if path in ('/api/ver','/api/versions','/ver'):
|
|
snapshot = repository_snapshot(
|
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
repository_identity=REPOSITORY_IDENTITY,
|
|
)
|
|
if not snapshot['repository_available']:
|
|
return self.sendj({
|
|
'repository_available': False,
|
|
'query_error': snapshot['query_error'],
|
|
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
|
'local_authoritative_tags': [],
|
|
}, 503)
|
|
try:
|
|
tags = authoritative_tags(git_dir=AUTHORITATIVE_GIT_DIR)
|
|
except AuthoritativeGitError as exc:
|
|
return self.sendj({
|
|
'repository_available': False,
|
|
'query_error': str(exc),
|
|
'registry_versions': [p.name for p in (WS/'versions').glob('*')],
|
|
'local_authoritative_tags': [],
|
|
}, 503)
|
|
registry = [p.name for p in (WS/'versions').glob('*')]
|
|
return self.sendj({
|
|
'registry_versions': registry,
|
|
'local_authoritative_tags': tags,
|
|
'repository_available': True,
|
|
'query_error': None,
|
|
'tags': [item['name'] for item in tags],
|
|
'versions_dir': registry,
|
|
'head': snapshot['authoritative_head_oid'],
|
|
'authoritative_head_oid': snapshot['authoritative_head_oid'],
|
|
})
|
|
if path in ('/api/diff','/diff'):
|
|
frm=(q.get('from') or [''])[0]
|
|
to=(q.get('to') or [''])[0]
|
|
try:
|
|
diff=authoritative_diff(frm, to, git_dir=required_authoritative_git_dir())
|
|
except ValueError as exc:
|
|
return self.sendj({'error':redact(exc)},400)
|
|
except AuthoritativeGitNotFound as exc:
|
|
return self.sendj({'error':redact(exc)},404)
|
|
except AuthoritativeGitOutputTooLarge as exc:
|
|
return self.sendj({'error':redact(exc)},413)
|
|
except (AuthoritativeGitError, RuntimeError) as exc:
|
|
return self.sendj({'error':redact(exc)},503)
|
|
return self.sendj({'from':frm,'to':to,'diff':diff})
|
|
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
|
snapshot = repository_snapshot(
|
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
repository_identity=REPOSITORY_IDENTITY,
|
|
)
|
|
if not snapshot['repository_available']:
|
|
return self.sendj({
|
|
'repository_available': False,
|
|
'query_error': snapshot['query_error'],
|
|
'metadata_patches': patches(),
|
|
'authoritative_branches': [],
|
|
}, 503)
|
|
try:
|
|
branches = authoritative_branches(git_dir=AUTHORITATIVE_GIT_DIR)
|
|
except AuthoritativeGitError as exc:
|
|
return self.sendj({
|
|
'repository_available': False,
|
|
'query_error': str(exc),
|
|
'metadata_patches': patches(),
|
|
'authoritative_branches': [],
|
|
}, 503)
|
|
metadata = patches()
|
|
branch_names = {item['name'] for item in branches}
|
|
metadata_names = {
|
|
item.get('branch') for item in metadata
|
|
if isinstance(item, dict) and item.get('branch')
|
|
}
|
|
orphan = [
|
|
item for item in metadata
|
|
if item.get('branch') and item.get('branch') not in branch_names
|
|
]
|
|
missing = [
|
|
item for item in branches
|
|
if item['name'] not in metadata_names
|
|
]
|
|
return self.sendj({
|
|
'metadata_patches': metadata,
|
|
'authoritative_branches': branches,
|
|
'orphan_metadata': orphan,
|
|
'branches_without_metadata': missing,
|
|
'repository_available': True,
|
|
'query_error': None,
|
|
'patches': metadata,
|
|
'branches': [item['name'] for item in branches],
|
|
})
|
|
if path in ('/api/model','/model'): return self.sendj({'base':BASE_MODELS,'custom':model_docs(),'priority':[jload(p,{}) for p in sorted((WS/'config'/'model_priority').glob('*.json'))]})
|
|
if path in ('/api/run','/api/runtime-status','/run'):
|
|
svc=subprocess.run(['systemctl','is-active','aios-gitops-model-api'], text=True, capture_output=True)
|
|
snapshot = repository_snapshot(
|
|
git_dir=AUTHORITATIVE_GIT_DIR,
|
|
repository_identity=REPOSITORY_IDENTITY,
|
|
)
|
|
workspace_branch = None
|
|
if (WS / '.git').exists():
|
|
result = sh(['rev-parse','--abbrev-ref','HEAD'])
|
|
workspace_branch = result['stdout'] or None
|
|
payload = {
|
|
'service':'aios-gitops-model-api',
|
|
'status':svc.stdout.strip() or svc.stderr.strip(),
|
|
'workspace_branch':workspace_branch,
|
|
**runtime_release_info(),
|
|
**snapshot,
|
|
'writes_enabled': str(os.getenv('AIOS_GITOPS_WRITES_ENABLED','false')).lower() in {'1','true','yes','on'},
|
|
}
|
|
return self.sendj(payload, 200 if snapshot['repository_available'] else 503)
|
|
if path in ('/api/action-monitor','/action-monitor'):
|
|
latest=monitor.latest()
|
|
ldict={'event_id':latest.event_id,'action':latest.action,'error':latest.error,'context':latest.context,'created_at':latest.created_at} if latest else None
|
|
return self.sendj({'events':[{"event_id":e.event_id,"action":e.action,"error":e.error,"context":e.context,"created_at":e.created_at} for e in monitor.list_events()],'latest':ldict})
|
|
if path == '/api/tag-publication-status':
|
|
version=(q.get('version') or [''])[0]
|
|
try:
|
|
version=validate_version(version)
|
|
status=publication_publisher().get_tag_publication_status(version)
|
|
except ValueError:
|
|
return self.sendj({'error':'invalid release version'},400)
|
|
return self.sendj(status)
|
|
if path == '/api/release-consistency':
|
|
request=publication_request_from_query(q)
|
|
return self.sendj(publication_publisher().get_release_consistency(request))
|
|
if path == '/api/release-acceptance':
|
|
request=publication_request_from_query(q)
|
|
return self.sendj(publication_publisher().run_release_acceptance(request))
|
|
if path in (
|
|
'/getReleaseCandidate',
|
|
'/getReleasePlan',
|
|
'/getReleaseAcceptance',
|
|
'/getApprovalEnvelope',
|
|
'/getReleaseSaga',
|
|
'/getCapabilityStatus',
|
|
):
|
|
candidate_id=(q.get('candidate_id') or [''])[0]
|
|
if not candidate_id:
|
|
return self.sendj({'error':'candidate_id is required'},400)
|
|
service=silent_release_service()
|
|
if path == '/getReleaseCandidate':
|
|
return self.sendj(
|
|
service.get_release_candidate(candidate_id)
|
|
)
|
|
if path == '/getApprovalEnvelope':
|
|
return self.sendj(
|
|
service.get_approval_envelope(candidate_id)
|
|
)
|
|
if path == '/getReleasePlan':
|
|
return self.sendj(service.get_release_plan(candidate_id))
|
|
if path == '/getReleaseAcceptance':
|
|
return self.sendj(service.get_acceptance(candidate_id))
|
|
if path == '/getCapabilityStatus':
|
|
return self.sendj(
|
|
service.get_capability_status(candidate_id)
|
|
)
|
|
return self.sendj(service.get_release_saga(candidate_id))
|
|
if path == '/getRecoveryStatus':
|
|
return self.sendj(
|
|
silent_release_service().get_recovery_status()
|
|
)
|
|
# Admin AI Evolution Dashboard
|
|
if path in ('/admin/ai-evolution','/admin/ai-evolution/'):
|
|
return self.ai_evolution_page()
|
|
if path in ('/admin/ai-evolution/api/data'):
|
|
return self.ai_evolution_data()
|
|
return self.sendj({'error':'not_found','path':path},404)
|
|
except FileNotFoundError:
|
|
return self.sendj({'error':'release candidate not found'},404)
|
|
except ValueError as e:
|
|
error=redact(e)
|
|
monitor.record_failure('do_GET', error, {'path':path})
|
|
return self.sendj({'error':error},400)
|
|
except Exception as e:
|
|
error=redact(e)
|
|
monitor.record_failure('do_GET', error, {'path':path})
|
|
return self.sendj({'error':error},500)
|
|
def do_PUT(self):
|
|
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
|
if reject_non_read_http(self, 'PUT', path): return
|
|
data=self.body()
|
|
if path == '/api/model-priority': return self.model_priority(data)
|
|
return self.sendj({'error':'not_found','path':path},404)
|
|
def do_POST(self):
|
|
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
|
if reject_non_read_http(self, 'POST', path): return
|
|
data=self.body()
|
|
try:
|
|
if path == '/createReleaseCandidate':
|
|
a=self.need('release:candidate')
|
|
if not a: return
|
|
return self.sendj(
|
|
silent_release_service().create_release_candidate(data)
|
|
)
|
|
if path == '/createReleaseIntent':
|
|
a=self.need('release:candidate')
|
|
if not a: return
|
|
return self.sendj(
|
|
silent_release_service().create_release_intent(data)
|
|
)
|
|
if path in ('/submitOwnerApproval','/submitSignedApproval'):
|
|
a=self.need('release:approve')
|
|
if not a: return
|
|
return self.sendj(
|
|
silent_release_service().submit_owner_approval(data)
|
|
)
|
|
if path == '/startDeployment':
|
|
a=self.need('release:deploy')
|
|
if not a: return
|
|
return self.sendj(
|
|
silent_release_service().start_deployment(
|
|
str(data.get('candidate_id') or '')
|
|
)
|
|
)
|
|
if path in ('/api/branch','/api/create-branch'): return self.branch(data)
|
|
if path in ('/api/file','/api/commit-file'): return self.file(data)
|
|
if path == '/api/commit': return self.commit(data)
|
|
if path in ('/api/patch','/api/create-patch'): return self.patch(data)
|
|
if path in ('/api/tag','/api/create-tag'): return self.tag(data)
|
|
if path == '/api/rollback': return self.rollback(data)
|
|
if path == '/api/model': return self.model(data)
|
|
if path == '/api/model-priority': return self.model_priority(data)
|
|
if path == '/api/nl': return self.nl(data)
|
|
return self.sendj({'error':'not_found','path':path},404)
|
|
except (ValueError, FileNotFoundError) as e:
|
|
error=redact(e)
|
|
monitor.record_failure('do_POST', error, {'path':path})
|
|
return self.sendj({'error':error},400)
|
|
except CapabilityAuthorizationError as e:
|
|
error=redact(e)
|
|
monitor.record_failure('do_POST', error, {'path':path})
|
|
return self.sendj({'error':error},403)
|
|
except SagaConflict as e:
|
|
error=redact(e)
|
|
monitor.record_failure('do_POST', error, {'path':path})
|
|
return self.sendj({'error':error},409)
|
|
except Exception as e:
|
|
error=redact(e)
|
|
monitor.record_failure('do_POST', error, {'path':path})
|
|
return self.sendj({'error':error},500)
|
|
def do_PATCH(self):
|
|
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
|
if reject_non_read_http(self, 'PATCH', path): return
|
|
return self.sendj({'error':'not_found','path':path},404)
|
|
def do_DELETE(self):
|
|
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
|
if reject_non_read_http(self, 'DELETE', path): return
|
|
return self.sendj({'error':'not_found','path':path},404)
|
|
def branch(self,data):
|
|
a=self.need('branch:create');
|
|
if not a: return
|
|
b=checkout(data.get('branch'), data.get('base','main'), True); meta={'type':'branch','branch':b,'base':data.get('base','main'),'created_by':a['account'],'created_at':now(),'direct_main_push':False,'owner_approval_required':True}
|
|
r=write_json(f'patches/{safe(b).replace("/","_")}.branch.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create {b}'); audit(a['account'],'branch',b,meta); self.sendj({'status':'ok','branch':b,'commit':c})
|
|
def file(self,data):
|
|
a=self.need('file:write');
|
|
if not a: return
|
|
if os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() not in {'1','true','yes','on'}:
|
|
return self.sendj({'error':'DURABLE_JOB_CONTROLLER_UNAVAILABLE','retryable':True},503)
|
|
try:
|
|
path = relpath(str(data.get('path') or ''))
|
|
branch = str(data.get('branch') or '')
|
|
expected = str(data.get('expected_branch_oid') or '')
|
|
campaign_id = str(data.get('campaign_id') or os.getenv('AIOS_GITOPS_CAMPAIGN_ID') or '')
|
|
supplied_digest = str(data.get('payload_digest') or '')
|
|
content = data.get('content')
|
|
payload_bytes = None
|
|
if content is not None:
|
|
payload_bytes = content.encode('utf-8') if isinstance(content, str) else json.dumps(content, sort_keys=True, ensure_ascii=False).encode('utf-8')
|
|
payload_digest = supplied_digest or hashlib.sha256(payload_bytes or b'').hexdigest()
|
|
if not campaign_id or not branch or not expected:
|
|
return self.sendj({'error':'JOB_BINDING_REQUIRED','retryable':False},409)
|
|
request = {
|
|
'campaign_id': campaign_id,
|
|
'repository': REPOSITORY_IDENTITY or 'aios/aios',
|
|
'operation': 'commit-file',
|
|
'branch': branch,
|
|
'expected_branch_oid': expected,
|
|
'payload_digest': payload_digest,
|
|
# The controller persists only the binding and digest. Raw
|
|
# content is never copied into durable state or logs.
|
|
}
|
|
receipt = GitOpsJobController(
|
|
JobStore(Path(os.getenv('AIOS_GITOPS_JOB_ROOT', '/var/lib/aios-gitops/jobs')))
|
|
).submit(request, payload=payload_bytes)
|
|
return self.sendj({
|
|
'job_id': receipt.job_id,
|
|
'state': receipt.state.value,
|
|
'side_effect_state': receipt.side_effect_state,
|
|
'retryable': receipt.http_status == 503,
|
|
}, receipt.http_status)
|
|
except ServiceUnavailable as exc:
|
|
return self.sendj({'error':str(exc),'layer':'durable_job','retryable':True,'side_effect_state':'UNKNOWN'},503)
|
|
except JobError as exc:
|
|
return self.sendj({'error':str(exc),'retryable':False},409)
|
|
def commit(self,data):
|
|
a=self.need('commit:create');
|
|
if not a: return
|
|
b=checkout(data.get('branch') or sh(['rev-parse','--abbrev-ref','HEAD'])['stdout'], data.get('base','main'), False); paths=data.get('paths') or ['.']; paths=[paths] if isinstance(paths,str) else paths; c=commit(paths, data.get('message') or 'chore(chatgpt): commit changes'); audit(a['account'],'commit',b,{'paths':paths,'commit':c}); self.sendj({'status':c['status'],'branch':b,'commit':c})
|
|
def patch(self,data):
|
|
a=self.need('patch:create');
|
|
if not a: return
|
|
name=data.get('patch_name') or data.get('name') or uid('patch'); b=checkout(data.get('branch') or f'patch/{safe(name)}', data.get('base','main'), True); meta={'id':uid('patch'),'patch_name':name,'branch':b,'risk':data.get('risk','medium'),'description':data.get('description',''),'created_by':a['account'],'created_at':now()}
|
|
r=write_json(f'patches/{safe(name)}.json', meta); c=commit([r], data.get('message') or f'chore(chatgpt): create patch {name}'); audit(a['account'],'patch',name,meta); self.sendj({'status':'patch_branch_created','patch':meta,'commit':c})
|
|
def tag(self,data):
|
|
a=self.need('tag:create');
|
|
if not a: return
|
|
ver=data.get('version') or data.get('tag')
|
|
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
|
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
|
return self.sendj({
|
|
'error':'production_tag_evidence_required',
|
|
'required':['version','target_oid','evidence_closure_oid','approval_plan_id'],
|
|
},400)
|
|
try:
|
|
target_oid=resolve_commit_oid(data.get('target_oid'))
|
|
evidence_oid=resolve_commit_oid(data.get('evidence_closure_oid'), 'evidence_closure_oid')
|
|
except ValueError as exc:
|
|
return self.sendj({'error':'invalid_tag_target','detail':str(exc)},400)
|
|
if a['role'] != 'owner' or not data.get('execute'):
|
|
b=checkout(data.get('branch') or f'request/tag-{safe(ver)}', 'main', True); req={'version':ver,'target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'requested_by':a['account'],'approval_required':True,'created_at':now()}; r=write_json(f'baseline/tag_requests/{safe(ver)}.json', req); c=commit([r], f'chore(chatgpt): request tag {ver}'); return self.sendj({'status':'approval_required','branch':b,'request':req,'commit':c})
|
|
request=PublicationRequest(ver,target_oid,evidence_oid,str(data.get('approval_plan_id')))
|
|
main_before=sh(['rev-parse','refs/heads/main'], True)['stdout']
|
|
publisher=publication_publisher()
|
|
try:
|
|
publication=publisher.publish(request)
|
|
except PublicationManualOwnerReviewRequired as exc:
|
|
audit(a['account'],'tag_manual_owner_review_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':redact(exc)})
|
|
return self.sendj({'status':'MANUAL_OWNER_REVIEW_REQUIRED','error':redact(exc),'main_changed':False,'force_push':False},409)
|
|
except PublicationConflict as exc:
|
|
audit(a['account'],'tag_conflict',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
|
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},409)
|
|
except PublicationPending as exc:
|
|
audit(a['account'],'tag_pending',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
|
return self.sendj({'status':'REMOTE_PUSH_PENDING','error':str(exc),'main_changed':False,'force_push':False},503)
|
|
except PublicationReconciliationRequired as exc:
|
|
audit(a['account'],'tag_reconciliation_required',ver,{'target_oid':target_oid,'approval_plan_id':data.get('approval_plan_id'),'error':str(exc)})
|
|
return self.sendj({'status':'RECONCILIATION_REQUIRED','error':str(exc),'main_changed':False,'force_push':False},502)
|
|
main_after=sh(['rev-parse','refs/heads/main'], True)['stdout']
|
|
if main_after != main_before:
|
|
raise RuntimeError('main changed during tag operation')
|
|
result={'status':publication['state'],'version':ver,'tag_name':ver,'tag_oid':publication['tag_oid'],'target_oid':target_oid,'expected_target_oid':target_oid,'evidence_closure_oid':evidence_oid,'approval_plan_id':data.get('approval_plan_id'),'workspace_head':sh(['rev-parse','HEAD'],True)['stdout'],'main_oid_before':main_before,'main_oid_after':main_after,'main_changed':False,'force_push':False,'publication':publication}
|
|
audit(a['account'],'tag',ver,result)
|
|
self.sendj(result)
|
|
def rollback(self,data):
|
|
a=self.need('rollback:request');
|
|
if not a: return
|
|
target=data.get('target_version') or data.get('version') or data.get('target')
|
|
if not target: return self.sendj({'error':'target_version required'},400)
|
|
b=checkout(data.get('branch') or f'request/rollback-{safe(target)}-{uid("rb")}', 'main', True); req={'id':uid('rollback'),'target_version':target,'reason':data.get('reason',''),'requested_by':a['account'],'approval_required':True,'execute':False,'created_at':now()}; r=write_json(f'baseline/rollback_requests/{safe(req["id"])}.json', req); c=commit([r], f'chore(chatgpt): request rollback {target}'); self.sendj({'status':'rollback_request_committed','branch':b,'request':req,'commit':c})
|
|
def model(self,data):
|
|
a=self.need('model:write');
|
|
if not a: return
|
|
name=data.get('model_name') or data.get('model'); base=(data.get('base_url') or '').rstrip('/'); prov=data.get('provider_name') or 'custom-provider'
|
|
if not name or not base: return self.sendj({'error':'provider_name/base_url/model_name required'},400)
|
|
owner=safe(data.get('username') or a['account']); secret_ref=data.get('api_key_secret_ref') or data.get('secret_ref')
|
|
if data.get('api_key'):
|
|
d=SECRETS/'llm'/owner; d.mkdir(parents=True, exist_ok=True); sid=uid('llmkey'); p=d/f'{sid}.secret'; p.write_text(str(data['api_key']), encoding='utf-8'); os.chmod(p,0o600); secret_ref=f'secret://llm/{owner}/{sid}'
|
|
if not secret_ref: return self.sendj({'error':'api_key_secret_ref or api_key required'},400)
|
|
mid=data.get('model_id') or f'custom.{owner}.{hashlib.sha1((prov+base+name).encode()).hexdigest()[:10]}'; b=checkout(data.get('branch') or f'patch/model-{safe(mid)}','main',True)
|
|
doc=sanitize({k:v for k,v in data.items() if k!='api_key'}); doc.update({'model_id':mid,'provider_name':prov,'base_url':base,'model_name':name,'api_key_secret_ref':secret_ref,'created_by':a['account'],'created_at':now()})
|
|
if data.get('confirm') != True:
|
|
self.sendj({'status':'confirm_required','operation':'model','hint':'This operation registers a new model. Confirm to proceed.','x-openai-isConsequential':True}, 428)
|
|
return
|
|
r=write_json(f'config/model_registry/chatgpt/{safe(mid)}.json', doc); c=commit([r], data.get('message') or f'chore(chatgpt): register model {name}'); audit(a['account'],'model',mid,doc); self.sendj({'status':'model_registered','branch':b,'model_id':mid,'secret_ref':secret_ref,'commit':c})
|
|
def model_priority(self,data):
|
|
a=self.need('model:priority');
|
|
if not a: return
|
|
if data.get('confirm') != True:
|
|
self.sendj({'status':'confirm_required','operation':'model-priority','hint':'This operation updates model priority. Confirm to proceed.','x-openai-isConsequential':True}, 428)
|
|
return
|
|
user=safe(data.get('username') or a['account']); b=checkout(data.get('branch') or f'patch/model-priority-{user}', 'main', True); doc={'username':user,'items':data.get('items') or data.get('priority') or [],'updated_by':a['account'],'updated_at':now()}; r=write_json(f'config/model_priority/{user}.json', doc); c=commit([r], data.get('message') or f'chore(chatgpt): update model priority {user}'); audit(a['account'],'model-priority',user,doc); self.sendj({'status':'updated','branch':b,'priority':doc,'commit':c})
|
|
def nl(self,data):
|
|
a=self.need('nl:plan');
|
|
if not a: return
|
|
text=data.get('text',''); low=text.lower(); intent='unknown'; plan=[]
|
|
if 'rollback' in low or '回滚' in text: intent='rollback_request'; plan=['commit rollback request','wait owner/admin approval']
|
|
elif 'diff' in low or '对比' in text: intent='diff'; plan=['call GET /api/diff']
|
|
elif 'model' in low or '模型' in text: intent='model_change'; plan=['use POST /api/model or PUT /api/model-priority','commit metadata']
|
|
elif 'patch' in low or '补丁' in text: intent='patch_create'; plan=['call POST /api/patch']
|
|
b=checkout(data.get('branch') or f'patch/nl-{uid("nl")}', 'main', True); doc={'id':uid('nl'),'text':text,'intent':intent,'action_plan':plan,'execute':False,'owner_approval_required':True,'created_by':a['account'],'created_at':now()}; r=write_json(f'patches/nl/{safe(doc["id"])}.json', doc); c=commit([r], f'chore(chatgpt): record nl plan {intent}'); self.sendj({'status':'planned','branch':b,'request':doc,'commit':c})
|
|
_EVOLUTION_HTML = '''<!DOCTYPE html>
|
|
<html lang=zh-CN>
|
|
<head>
|
|
<meta charset=UTF-8>
|
|
<meta name=viewport content="width=device-width,initial-scale=1.0">
|
|
<title>AIOS Evolution Dashboard</title>
|
|
<script src=https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js></script>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:#0d1117;color:#c9d1d9;font-size:14px;line-height:1.7;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif}
|
|
.c{max-width:1000px;margin:0 auto;padding:32px 24px}
|
|
h1{font-size:24px;color:#58a6ff;margin-bottom:2px}
|
|
.sub{color:#8b949e;font-size:13px;border-bottom:1px solid #21262d;padding-bottom:16px;margin-bottom:20px;display:flex;justify-content:space-between}
|
|
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:16px;margin-bottom:20px}
|
|
.s{background:#161b22;border:1px solid #21262d;border-radius:8px;padding:18px;text-align:center}
|
|
.s .v{font-size:28px;font-weight:700;color:#f0f6fc}
|
|
.s .l{font-size:11px;color:#8b949e;margin-top:4px}
|
|
.bx{background:#161b22;border:1px solid #21262d;border-radius:8px;margin-bottom:14px}
|
|
.bh{border-bottom:1px solid #21262d;padding:10px 16px;font-weight:600;font-size:14px;display:flex;gap:6px;align-items:center}
|
|
.bd{padding:12px 16px}
|
|
.rw{display:grid;grid-template-columns:1fr 1fr 100px 60px;gap:8px;padding:8px 16px;border-bottom:1px solid #21262d;font-size:13px;align-items:center;cursor:pointer;transition:background .15s}
|
|
.rw:hover{background:#1c2128}.rw:last-child{border-bottom:none}
|
|
.tg{display:inline-block;padding:2px 10px;border-radius:99px;font-size:11px;font-weight:600}
|
|
.ok{background:#3fb95033;color:#3fb950}.fl{background:#da363333;color:#f85149}.if{background:#1f6feb33;color:#58a6ff}
|
|
pre{background:#0d1117;padding:12px;border-radius:6px;overflow:auto;font-size:12px;color:#8b949e;max-height:300px;margin-top:8px}select{padding:8px 10px;border:1px solid #30363d;border-radius:6px;background:#21262d;color:#c9d1d9;font-size:13px;max-width:400px;width:100%;margin-bottom:4px;cursor:pointer}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id=app class=c>
|
|
<h1>AIOS Evolution Dashboard</h1>
|
|
<div class=sub><span>Baseline: {{baseline}}</span><span style=color:#8b949e>{{refreshMsg}}</span></div>
|
|
<div class=g><div class=s><div class=v>{{tot}}</div><div class=l>Commands</div></div><div class=s><div class=v style=color:#3fb950>{{sok}}</div><div class=l>Succeeded</div></div><div class=s><div class=v style=color:#f85149>{{sfail}}</div><div class=l>Failed</div></div><div class=s><div class=v>{{runs}}</div><div class=l>Executions</div></div></div>
|
|
<div class=bx><div class=bh>Version Timeline</div><div class=bd><div class=rw style=font-weight:600;border-bottom:2px solid #30363d;cursor:default><span>Version</span><span>Time</span><span>Result</span></div><div v-for=v in vs class=rw @click=selVer(v)><span style=color:#58a6ff>{{v.tag}}</span><span>{{v.time||'-'}}</span><span :class="'tg '+v.result">{{v.result||'-'}}</span></div></div></div>
|
|
<div class=bx><div class=bh>Commands</div><div class=bd><select v-model=cmd @change=loadCmd><option value>-- Select --</option><option v-for=c in cmds :value=c>{{c}}</option></select><pre v-if=cdet>{{cdet}}</pre></div></div>
|
|
<div class=bx><div class=bh>Recent Agent Runs</div><div class=bd><div class=rw style=font-weight:600;border-bottom:2px solid #30363d;cursor:default><span>Agent</span><span>Command</span><span>Count</span><span>Result</span></div><div v-for=r in aruns class=rw><span>{{r.agent||'-'}}</span><span style=overflow:hidden;white-space:nowrap;text-overflow:ellipsis>{{r.command}}</span><span>{{r.interaction_count||0}}</span><span :class="'tg '+r.final_result">{{r.final_result||'-'}}</span></div></div></div></div>
|
|
<script>
|
|
const{createApp,ref,onMounted}=Vue;createApp({setup(){const vs=ref([]),cmds=ref([]),ar=ref([]),cmd=ref(''),cdet=ref(null),tot=ref(0),sok=ref(0),sfail=ref(0),runs=ref(0),baseline=ref('V1R3C1'),refreshMsg=ref('')
|
|
async function ld(){try{const r=await fetch('/admin/ai-evolution/api/data'),d=await r.json();baseline.value=d.baseline||'V1R3C1';vs.value=d.versions||[];cmds.value=d.commands||[];ar.value=d.agent_runs||[];tot.value=d.stats?.total||0;sok.value=d.stats?.success||0;sfail.value=d.stats?.failed||0;runs.value=d.stats?.agent_runs||0;refreshMsg.value='Last updated: '+new Date().toLocaleString()}catch(e){}}
|
|
async function loadCmd(){if(!cmd.value){cdet.value=null;return}try{const r=await fetch('/admin/ai-evolution/api/data?command='+encodeURIComponent(cmd.value)),d=await r.json();cdet.value=JSON.stringify(d,null,2)}catch(e){cdet.value='Error'}}
|
|
function selVer(v){cmd.value=v.tag||v.id||v.version;loadCmd()}
|
|
onMounted(()=>{ld();setInterval(ld,30000)})
|
|
return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer}})mount('#app')
|
|
</script></body></html>'''
|
|
|
|
def ai_evolution_page(self):
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
self.send_header('Content-Length', str(len(self._EVOLUTION_HTML)))
|
|
self.end_headers()
|
|
self.wfile.write(self._EVOLUTION_HTML.encode())
|
|
|
|
def ai_evolution_data(self):
|
|
q=urllib.parse.urlparse(self.path).query
|
|
params=urllib.parse.parse_qs(q)
|
|
cmd_filter=(params.get('command') or [None])[0]
|
|
bl=jload(WS/'baseline'/'current.json',{})
|
|
current_ver=bl.get('current_baseline','V1R3C1')
|
|
try:
|
|
tags=[item['name'] for item in authoritative_tags(git_dir=required_authoritative_git_dir()) if str(item['name']).startswith('V')]
|
|
except (AuthoritativeGitError, RuntimeError) as exc:
|
|
return self.sendj({'error':redact(exc),'repository_available':False},503)
|
|
versions=[]
|
|
if (WS/'baseline'/'promotions').exists():
|
|
for p in sorted((WS/'baseline'/'promotions').glob('*.json')):
|
|
d=jload(p,{});v=d.get('version',p.stem)
|
|
versions.append({'tag':v,'time':d.get('promoted_at','-'),'result':'ok'})
|
|
for t in tags:
|
|
if not any(v['tag']==t for v in versions):
|
|
versions.append({'tag':t,'time':'-','result':'if'})
|
|
agent_runs=[];ard=WS/'audit'/'agent-runs'
|
|
if ard.exists():
|
|
for f in sorted(ard.glob('*.json'),reverse=True)[:50]:
|
|
d=jload(f,{});agent_runs.append(d) if isinstance(d,dict) else None
|
|
cmd_dirs=[WS/'agents/commands/active',WS/'agents/commands/running',WS/'agents/commands/completed',WS/'agents/commands/failed']
|
|
commands=[]
|
|
for d in cmd_dirs:
|
|
if d.exists():
|
|
for f in sorted(d.glob('*.md')):
|
|
commands.append(f.name)
|
|
command_detail=None
|
|
if cmd_filter:
|
|
for d in cmd_dirs:
|
|
if d.exists():
|
|
fp=d/cmd_filter
|
|
if fp.exists():
|
|
command_detail={'command':cmd_filter,'content':fp.read_text(encoding='utf-8')}
|
|
break
|
|
if not command_detail:
|
|
command_detail={'command':cmd_filter,'error':'not_found'}
|
|
return self.sendj(command_detail)
|
|
completed=list((WS/'agents/commands/completed').glob('*.md')) if (WS/'agents/commands/completed').exists() else []
|
|
failed=list((WS/'agents/commands/failed').glob('*.md')) if (WS/'agents/commands/failed').exists() else []
|
|
stats={'total':len(commands),'success':len(completed),'failed':len(failed),'agent_runs':len(agent_runs)}
|
|
data={'baseline':current_ver,'versions':versions[-30:],'agent_runs':agent_runs,'commands':sorted(set(commands)),'stats':stats}
|
|
self.sendj(data)
|
|
|
|
|
|
def api_spec():
|
|
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
|
|
|
def main():
|
|
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
|
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
|
if __name__ == '__main__': main()
|
|
|