"""Site-level production endpoints: approval+quality gates, signed HTTPS releases.""" import hashlib,json,os,time from pathlib import Path from fastapi import APIRouter,HTTPException,Request,Body from pydantic import BaseModel,Field from . import db,enterprise,quality,secret_refs,safe_http,jobs from .deploy_agent import signature from . import release_targets router=APIRouter(prefix='/api/production',tags=['Production delivery']) def setup(): with db.connect() as c: c.executescript('''CREATE TABLE IF NOT EXISTS remote_releases( site_id TEXT NOT NULL, build_id TEXT NOT NULL, target TEXT NOT NULL, release_id TEXT NOT NULL, actor TEXT NOT NULL, deployed_at TEXT NOT NULL, PRIMARY KEY(site_id,target,release_id), FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE);''') class DeployRequest(BaseModel): build_id:str=Field(min_length=1,max_length=100) target:str=Field(default='staging',pattern='^(staging|production)$') def approved(site_id,build_id): from . import factory_api row=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id)) if not row: raise HTTPException(404,'Build not found') # Only the persisted finished build is deployable. Queued/running/unknown # states must fail closed (the legacy check accidentally allowed them). if row['status'] != 'complete': raise HTTPException(409,'Build has not completed successfully') if not row['zip_path']: raise HTTPException(409,'Build archive unavailable') site=db.one('SELECT mode FROM sites WHERE id=?',(site_id,)) if site['mode']=='migration': diff=enterprise.diff_baseline(site_id) if not diff['baseline_count'] or diff['errors']: raise HTTPException(409,'Migration audit incomplete') from . import cms_pipeline if cms_pipeline.open_reviews(site_id): raise HTTPException(409,'Unresolved CMS import review findings') try: q=quality.report(site_id,build_id) except (FileNotFoundError, OSError): raise HTTPException(409,'Build output missing; rebuild before release') if not q['passed']:raise HTTPException(409,'Build quality gate failed') request=db.one('SELECT id FROM release_requests WHERE site_id=? AND build_id=? AND state=? LIMIT 1',(site_id,build_id,'approved')) if not request:raise HTTPException(409,'Independent release approval required') return row def _target_config(target, *, resolve_secret=True): if target not in ('staging','production'): raise HTTPException(400,'无效的目标环境') prefix='SITEFORGE_DEPLOY_'+target.upper() url=os.environ.get(prefix+'_URL','').rstrip('/') ref=os.environ.get(prefix+'_SECRET_REF','') if not resolve_secret: if ref: try:secret_refs.validate_reference(ref) except secret_refs.SecretReferenceError:raise HTTPException(503,'密钥引用格式无效') if not url.startswith('https://') or not (ref or os.environ.get(prefix+'_SECRET')): raise HTTPException(503,'请先配置 HTTPS 发布目标和密钥引用') return url,'' try:secret=secret_refs.resolve(ref) if ref else os.environ.get(prefix+'_SECRET','') except secret_refs.SecretReferenceError:raise HTTPException(503,'未能读取远程发布密钥') if not url.startswith('https://') or len(secret)<32: raise HTTPException(503,'请先配置 HTTPS 发布目标和长度不少于32位的密钥') return url,secret def _remote(site,url,secret,method,path,body=b''): stamp=str(int(time.time())) headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site,stamp,body,method,path)} if body:headers['Content-Type']='application/zip' try: if method=='POST':response=safe_http.post(url+path,body=body,headers=headers,timeout=120) else:response=safe_http.fetch(url+path,headers=headers,method='GET',timeout=30) # 4xx are validation failures, not retryable network errors. if response.status_code in (400,401,403,404,409,413): raise HTTPException(409,'远程发布端拒绝操作,请检查签名、存档完整性或版本状态') response.raise_for_status() return response.json() except HTTPException:raise except (safe_http.OutboundError,OSError,ValueError) as ex: # Never include URL, secret, request headers or upstream response in error message. raise HTTPException(502,'远程连接或回应失败,请从任务中心重试') from ex def _archive_for(site_id,record): archive=Path(record['zip_path']).resolve() expected_root=(db.DATA/'archives').resolve() if not archive.is_relative_to(expected_root) or not archive.name.startswith(site_id+'-') or not archive.is_file(): raise HTTPException(409,'构建 ZIP 不存在或地址不安全') if archive.stat().st_size>120*1024*1024: raise HTTPException(409,'构建包超出 Agent 上传限制') return archive def run_delivery(site_id,kind,opts,job_id): """Called only by Worker subprocess; re-check approvals before changing live pointer.""" target=opts['target'] mode=release_targets.backend(target) if mode=='agent':url,secret=_target_config(target) if kind=='remote_deploy': build_id=opts['build_id'] record=approved(site_id,build_id) archive=_archive_for(site_id,record) if mode!='agent': value=release_targets.deploy(target,site_id,archive) db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)', (enterprise.uid(),site_id,build_id,'worker:'+job_id,'remote:'+target,db.now())) db.insert('''INSERT INTO remote_releases VALUES (?,?,?,?,?,?) ON CONFLICT(site_id,target,release_id) DO UPDATE SET deployed_at=excluded.deployed_at,actor=excluded.actor''', (site_id,build_id,target,value['release'],'worker:'+job_id,db.now())) return {**value,'build_id':build_id} body=archive.read_bytes() digest=hashlib.sha256(body).hexdigest() # Stage only: no traffic change on upload or checksum failure. staged=_remote(site_id,url,secret,'POST',f'/staging/{site_id}',body) release=staged['release'] if digest!=staged.get('sha256') or release!=digest[:20]: raise HTTPException(409,'远程存档摘要不匹配,未激活') verified=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}') if not verified.get('verified') or verified.get('sha256')!=digest: raise HTTPException(409,'远程文件校验失败,未激活') activated=_remote(site_id,url,secret,'POST',f'/sites/{site_id}/activate/{release}') if activated.get('current')!=release or not activated.get('active'): raise HTTPException(502,'远程版本切换未确认') confirmation=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}') # Verify public document is actually served, not only that the pointer changed. with __import__('zipfile').ZipFile(archive) as package: index_hash=hashlib.sha256(package.read('index.html')).hexdigest() probe_ok=False try: response=safe_http.fetch(f'{url}/public/{site_id}/',timeout=20,max_bytes=8_000_000) probe_ok=(response.status_code==200 and hashlib.sha256(response.content).hexdigest()==index_hash) except (safe_http.OutboundError,OSError,ValueError): pass if not confirmation.get('active') or confirmation.get('sha256')!=digest or not probe_ok: previous=activated.get('previous') if previous and previous!=release: try:_remote(site_id,url,secret,'POST',f'/sites/{site_id}/rollback/{previous}') except Exception:pass raise HTTPException(502,'发布后内容验证未通过,已尝试恢复旧版本,请人工复核') db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)', (enterprise.uid(),site_id,build_id,'worker:'+job_id,'remote:'+target,db.now())) db.insert('''INSERT INTO remote_releases VALUES (?,?,?,?,?,?) ON CONFLICT(site_id,target,release_id) DO UPDATE SET deployed_at=excluded.deployed_at,actor=excluded.actor''', (site_id,build_id,target,release,'worker:'+job_id,db.now())) return {'published':True,'release':release,'build_id':build_id,'target':target, 'files':confirmation['files'],'sha256':digest,'previous':activated.get('previous')} if kind=='remote_rollback': release=opts['release_id'] old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?', (site_id,target,release)) if not old:raise HTTPException(404,'只能回滚至本站曾发布的历史版本') if mode!='agent': value=release_targets.rollback(target,site_id,release) db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)', (enterprise.uid(),site_id,old['build_id'],'worker:'+job_id, 'remote-rollback:'+target,db.now())) return {**value,'build_id':old['build_id']} verified=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}') if not verified.get('verified'):raise HTTPException(409,'旧版本文件校验失败') result=_remote(site_id,url,secret,'POST',f'/sites/{site_id}/rollback/{release}') confirmation=_remote(site_id,url,secret,'GET',f'/sites/{site_id}/verify/{release}') if not confirmation.get('active'):raise HTTPException(502,'回滚后未能确认线上版本') db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)', (enterprise.uid(),site_id,old['build_id'],'worker:'+job_id,'remote-rollback:'+target,db.now())) return {'rolled_back':True,'target':target,'release':release,'build_id':old['build_id']} raise ValueError('Unknown delivery job kind') @router.get('/sites/{site_id}/delivery-status') def delivery_status(request:Request,site_id:str): enterprise.require(request,site_id,'viewer') active=db.rows("SELECT id,kind,state,attempts,error,created_at,result_json FROM build_jobs WHERE site_id=? AND kind IN ('remote_deploy','remote_rollback') ORDER BY created_at DESC LIMIT 30",(site_id,)) history=db.rows('SELECT * FROM remote_releases WHERE site_id=? ORDER BY deployed_at DESC LIMIT 30',(site_id,)) # Return target readiness without revealing hostnames or credential refs. configured={} modes={} for t in ('staging','production'): modes[t]=release_targets.backend(t) try: if modes[t]=='agent':_target_config(t,resolve_secret=False) else:release_targets.config(t) configured[t]=True except HTTPException:configured[t]=False return {'jobs':active,'history':history,'configured':configured,'backends':modes} @router.get('/sites/{site_id}/preflight/{build_id}') def preflight(request:Request,site_id:str,build_id:str): enterprise.require(request,site_id,'viewer') try: record=approved(site_id,build_id) _archive_for(site_id,record) return {'ready':True,'build_id':build_id,'message':'已满足构建、质量、迁移和独立审批条件'} except HTTPException as exc: return {'ready':False,'build_id':build_id,'message':str(exc.detail)} @router.post('/sites/{site_id}/deploy',status_code=202) def push(request:Request,site_id:str,payload:DeployRequest): enterprise.require(request,site_id,'owner') record=approved(site_id,payload.build_id) _archive_for(site_id,record) if release_targets.backend(payload.target)=='agent':_target_config(payload.target,resolve_secret=False) else:release_targets.config(payload.target) key=request.headers.get('idempotency-key') try:return jobs.submit_delivery(site_id,'remote_deploy', {'target':payload.target,'build_id':payload.build_id},request.state.actor,key) except jobs.JobConflict as exc:raise HTTPException(409,str(exc)) except jobs.JobCapacityError as exc:raise HTTPException(429,str(exc)) class RollbackRequest(BaseModel): release_id:str=Field(pattern=r'^[0-9a-f]{20}$') target:str=Field(default='staging',pattern='^(staging|production)$') @router.post('/sites/{site_id}/rollback',status_code=202) def rollback_remote(request:Request,site_id:str,payload:RollbackRequest): enterprise.require(request,site_id,'owner') old=db.one('SELECT * FROM remote_releases WHERE site_id=? AND target=? AND release_id=?', (site_id,payload.target,payload.release_id)) if not old:raise HTTPException(404,'仅允许回滚到本站曾部署过的版本') if release_targets.backend(payload.target)=='agent':_target_config(payload.target,resolve_secret=False) else:release_targets.config(payload.target) key=request.headers.get('idempotency-key') try:return jobs.submit_delivery(site_id,'remote_rollback', {'target':payload.target,'release_id':payload.release_id},request.state.actor,key) except jobs.JobConflict as exc:raise HTTPException(409,str(exc)) except jobs.JobCapacityError as exc:raise HTTPException(429,str(exc))