Files
浅念 601451f22b upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:31:03 +08:00

239 lines
13 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
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))