"""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 import httpx from . import db,enterprise,quality,secret_refs,safe_http from .deploy_agent import signature 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 @router.post('/sites/{site_id}/deploy') def push(request:Request,site_id:str,payload:DeployRequest): enterprise.require(request,site_id,'owner') record=approved(site_id,payload.build_id) # Environment-specific endpoint is operator managed, never accepted from user JSON. prefix='SITEFORGE_DEPLOY_'+payload.target.upper() url=os.environ.get(prefix+'_URL','').rstrip('/') secret_ref=os.environ.get(prefix+'_SECRET_REF','') try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','') except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable') if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured') 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,'Build artifact not found') data=archive.read_bytes() stamp=str(int(time.time())) headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site_id,stamp,data,'POST',f'/deploy/{site_id}'),'Content-Type':'application/zip'} try: response=safe_http.post(f'{url}/deploy/{site_id}',body=data,headers=headers,timeout=120) response.raise_for_status() result=response.json() except (safe_http.OutboundError,OSError,ValueError) as ex:raise HTTPException(502,'Remote release failed') db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,payload.build_id,request.state.actor,'remote:'+payload.target,db.now())) db.insert('INSERT INTO remote_releases VALUES (?,?,?,?,?,?)',(site_id,payload.build_id,payload.target,result['release'],request.state.actor,db.now())) return {'published':True,'target':payload.target,'build':payload.build_id,'remote':result} 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') 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,'仅允许回滚到本站曾经部署过的版本') prefix='SITEFORGE_DEPLOY_'+payload.target.upper() url=os.environ.get(prefix+'_URL','').rstrip('/') secret_ref=os.environ.get(prefix+'_SECRET_REF','') try:secret=secret_refs.resolve(secret_ref) if secret_ref else os.environ.get(prefix+'_SECRET','') except secret_refs.SecretReferenceError:raise HTTPException(503,'Deployment secret reference unavailable') if not url.startswith('https://') or len(secret)<32:raise HTTPException(503,'HTTPS deployment target not configured') stamp=str(int(time.time())) headers={'X-SiteForge-Timestamp':stamp,'X-SiteForge-Signature':signature(secret,site_id,stamp,b'','POST',f'/sites/{site_id}/rollback/{payload.release_id}')} try: r=safe_http.post(f'{url}/sites/{site_id}/rollback/{payload.release_id}',headers=headers,timeout=30) r.raise_for_status() result=r.json() except (safe_http.OutboundError,OSError,ValueError) as ex:raise HTTPException(502,'Remote rollback failed') db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(enterprise.uid(),site_id,old['build_id'],request.state.actor,'remote-rollback:'+payload.target,db.now())) return result