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>
This commit is contained in:
+175
-41
@@ -3,9 +3,9 @@ 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 . 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'])
|
||||
|
||||
@@ -50,55 +50,189 @@ def approved(site_id,build_id):
|
||||
if not request:raise HTTPException(409,'Independent release approval required')
|
||||
return row
|
||||
|
||||
@router.post('/sites/{site_id}/deploy')
|
||||
|
||||
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)
|
||||
# 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}
|
||||
_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')
|
||||
@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,'仅允许回滚到本站曾经部署过的版本')
|
||||
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
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user