2026-10-22 16:49:25 +08:00
"""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
2026-10-23 12:31:03 +08:00
from . import db , enterprise , quality , secret_refs , safe_http , jobs
2026-10-22 16:49:25 +08:00
from .deploy_agent import signature
2026-10-23 12:31:03 +08:00
from . import release_targets
2026-10-22 16:49:25 +08:00
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
2026-10-23 12:31:03 +08:00
def _target_config ( target , * , resolve_secret = True ):
if target not in ( 'staging' , 'production' ):
raise HTTPException ( 400 , '无效的目标环境' )
prefix = 'SITEFORGE_DEPLOY_' + target . upper ()
2026-10-22 16:49:25 +08:00
url = os . environ . get ( prefix + '_URL' , '' ) . rstrip ( '/' )
2026-10-23 12:31:03 +08:00
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 '' ):
2026-10-22 16:49:25 +08:00
stamp = str ( int ( time . time ()))
2026-10-23 12:31:03 +08:00
headers = { 'X-SiteForge-Timestamp' : stamp , 'X-SiteForge-Signature' : signature ( secret , site , stamp , body , method , path )}
if body : headers [ 'Content-Type' ] = 'application/zip'
2026-10-22 16:49:25 +08:00
try :
2026-10-23 12:31:03 +08:00
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 , '远程发布端拒绝操作,请检查签名、存档完整性或版本状态' )
2026-10-22 16:49:25 +08:00
response . raise_for_status ()
2026-10-23 12:31:03 +08:00
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 ))
2026-10-22 16:49:25 +08:00
class RollbackRequest ( BaseModel ):
release_id : str = Field ( pattern = r '^[0-9a-f] {20} $' )
target : str = Field ( default = 'staging' , pattern = '^(staging|production)$' )
2026-10-23 12:31:03 +08:00
@router.post ( '/sites/ {site_id} /rollback' , status_code = 202 )
2026-10-22 16:49:25 +08:00
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 ))
2026-10-23 12:31:03 +08:00
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 ))