105 lines
6.0 KiB
Python
105 lines
6.0 KiB
Python
|
|
"""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
|