Files
cms-SiteForge/app/release_api.py
T
浅念 8cea7dee20 chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests)
- Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR
  imports keep post/product types instead of collapsing to page
- Docs: trade-pro theme port spec (R3) and implementation plan

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 16:49:25 +08:00

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