601451f22b
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>
239 lines
13 KiB
Python
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))
|