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:
@@ -0,0 +1,71 @@
|
||||
"""Offline Chromium check for real four-theme mobile templates without localhost/file navigation.
|
||||
|
||||
Uses page.set_content to work in CI sandboxes that block navigation to localhost/file URLs.
|
||||
Tests actual rendered HTML, CSS, and JS; external image loading is not asserted.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json,re,sys
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
SAMPLE=ROOT/'examples'/'mobile-showcase'
|
||||
|
||||
def inline(theme,page_name="index.html"):
|
||||
path=SAMPLE/theme
|
||||
html=(path/page_name).read_text('utf-8')
|
||||
css='\n'.join((path/'assets'/name).read_text('utf-8') for name in
|
||||
['theme.css','blocks.css','factory.css','pro-base.css','pro.css','commercial.css','custom.css'])
|
||||
html=re.sub(r'<link\b[^>]*rel="stylesheet"[^>]*>', '',html)
|
||||
html=re.sub(r'<script defer src="assets/[^"<>]+"></script>', '',html)
|
||||
html=html.replace('</head>','<style>'+css+'</style></head>')
|
||||
js=(path/'assets/pro.js').read_text('utf-8')
|
||||
html=html.replace('</body>','<script>'+js+'</script></body>')
|
||||
return html
|
||||
|
||||
def run():
|
||||
results=[]
|
||||
with sync_playwright() as pw:
|
||||
browser=pw.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage'])
|
||||
try:
|
||||
for theme in ['automotive','industrial','corporate','minimal']:
|
||||
page=browser.new_page(viewport={'width':390,'height':844},device_scale_factor=1)
|
||||
errors=[]
|
||||
page.on('pageerror',lambda e:errors.append(str(e)))
|
||||
page.set_content(inline(theme),wait_until='domcontentloaded')
|
||||
assert page.locator('.sf-mobile-tabs').is_visible(),theme+' tabs absent'
|
||||
assert page.locator('[data-mobile-open-menu]').count()==1
|
||||
assert not page.locator('#pro-navigation').is_visible(),theme+' drawer initially visible'
|
||||
page.locator('[data-pro-menu]').click()
|
||||
assert page.locator('#pro-navigation').is_visible(),theme+' drawer not open'
|
||||
expect(page.locator('.sf-mobile-drawer-backdrop')).to_be_visible(timeout=3000)
|
||||
assert page.locator('[data-pro-menu]').get_attribute('aria-expanded')=='true'
|
||||
page.keyboard.press('Escape')
|
||||
assert not page.locator('#pro-navigation').is_visible(),theme+' Esc fails'
|
||||
page.locator('[data-mobile-open-menu]').click()
|
||||
assert page.locator('#pro-navigation').is_visible(),theme+' tabs menu failed'
|
||||
page.locator('.sf-mobile-drawer-backdrop').click(position={'x':4,'y':100})
|
||||
assert not page.locator('#pro-navigation').is_visible(),theme+' backdrop close fails'
|
||||
assert page.locator('.sf-mobile-tabs').evaluate('(el)=>getComputedStyle(el).display')=='grid'
|
||||
overflow=page.evaluate('document.documentElement.scrollWidth - window.innerWidth')
|
||||
for filename in ['index.html','listing.html','product.html','article.html']:
|
||||
for width in (375,390,430):
|
||||
sub=browser.new_page(viewport={'width':width,'height':844})
|
||||
sub.set_content(inline(theme,filename),wait_until='domcontentloaded')
|
||||
assert sub.locator('.sf-mobile-tabs').is_visible(),f'{theme}/{filename}: tabs missing'
|
||||
excess=sub.evaluate('document.documentElement.scrollWidth - window.innerWidth')
|
||||
assert excess<=1,f'{theme}/{filename} width={width} overflow={excess}'
|
||||
sub.close()
|
||||
page.set_viewport_size({'width':1440,'height':900})
|
||||
assert not page.locator('.sf-mobile-tabs').is_visible(),theme+' desktop tabs leakage'
|
||||
assert page.locator('#pro-navigation').is_visible(),theme+' desktop nav hidden'
|
||||
assert not errors,theme+' script errors: '+str(errors)
|
||||
results.append({'theme':theme,'drawer':'PASS','tabs':'PASS','responsive':'PASS','mobile_horizontal_overflow_px':overflow,'page_types_tested':4,'javascript_errors':len(errors)})
|
||||
if theme=='automotive':
|
||||
page.set_viewport_size({'width':390,'height':844})
|
||||
page.wait_for_timeout(350);page.screenshot(path=str(ROOT/'reports'/'mobile-v8213-automotive.png'),full_page=True)
|
||||
page.close()
|
||||
finally:browser.close()
|
||||
out=ROOT/'reports'/'mobile-v8213-browser.json';out.parent.mkdir(exist_ok=True,parents=True)
|
||||
out.write_text(json.dumps(results,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps(results,ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
if __name__=='__main__':sys.exit(run())
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Real Chromium browser smoke test for the unified management console.
|
||||
|
||||
Requires optional `playwright` and Chromium. Uses an isolated SQLite directory;
|
||||
does not touch live sites, data, or deployment targets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def run(screenshot_dir=None):
|
||||
with tempfile.TemporaryDirectory(prefix='siteforge-browser-') as data:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0))
|
||||
port=sock.getsockname()[1]
|
||||
root_url=f'http://127.0.0.1:{port}'
|
||||
env=os.environ.copy()
|
||||
env.update(SITEFORGE_ADMIN_PASSWORD='browser-smoke-only-strong-password',
|
||||
SITEFORGE_SESSION_SECRET='browser-smoke-secret-key-strong-long-enough',
|
||||
SITEFORGE_DATA_DIR=data, SITEFORGE_DB_FILE=str(Path(data)/'browser.sqlite3'),
|
||||
SITEFORGE_DATABASE_URL='',SITEFORGE_REDIS_URL='')
|
||||
server=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],
|
||||
cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
||||
try:
|
||||
ready=False
|
||||
for _ in range(100):
|
||||
if server.poll() is not None:
|
||||
raise RuntimeError(f'Web app stopped during startup: {server.stderr.read().decode(errors="replace")[-3000:]}')
|
||||
try:
|
||||
with urllib.request.urlopen(root_url+'/api/ready',timeout=0.5) as res:
|
||||
ready=res.status==200
|
||||
if ready:break
|
||||
except Exception:time.sleep(0.1)
|
||||
if not ready:raise RuntimeError('Web readiness timeout')
|
||||
with sync_playwright() as pw:
|
||||
launch_settings={'executable_path':os.environ['SITEFORGE_CHROMIUM']} if os.environ.get('SITEFORGE_CHROMIUM') else {}
|
||||
browser=pw.chromium.launch(**launch_settings,headless=True,args=['--no-sandbox','--no-proxy-server','--proxy-server=direct://','--proxy-bypass-list=*','--disable-features=ProxyPacHttpsUrlStripping'])
|
||||
context=browser.new_context(viewport={'width':1440,'height':900},device_scale_factor=1)
|
||||
page=context.new_page()
|
||||
errors=[]
|
||||
page.on('pageerror',lambda error:errors.append(str(error)))
|
||||
page.goto(root_url+'/login',wait_until='domcontentloaded')
|
||||
page.locator('#pass').fill(env['SITEFORGE_ADMIN_PASSWORD'])
|
||||
page.locator('#submit').click()
|
||||
page.wait_for_url(root_url+'/',timeout=12000)
|
||||
page.get_by_text('我的网站',exact=True).first.wait_for(timeout=10000)
|
||||
page.locator('#newSiteTop').click()
|
||||
assert page.locator('#siteModal').evaluate('(element)=>element.open')
|
||||
page.locator('#siteForm [name=name]').fill('浏览器验收站')
|
||||
page.locator('#siteForm button[type=submit]').click()
|
||||
page.locator('#siteModal').wait_for(state='hidden',timeout=10000)
|
||||
page.get_by_text('浏览器验收站',exact=True).first.wait_for(timeout=10000)
|
||||
page.locator('button[data-view="content"]').click()
|
||||
page.locator('#breadcrumbTitle').get_by_text('内容与页面').wait_for()
|
||||
page.locator('button[data-view="design"]').click()
|
||||
page.locator('#breadcrumbTitle').get_by_text('外观与设计').wait_for()
|
||||
if screenshot_dir:
|
||||
d=Path(screenshot_dir);d.mkdir(parents=True,exist_ok=True)
|
||||
page.screenshot(path=str(d/'desktop.png'),full_page=True)
|
||||
mobile=context.new_page()
|
||||
mobile.set_viewport_size({'width':390,'height':844})
|
||||
mobile.goto(root_url+'/',wait_until='domcontentloaded')
|
||||
assert mobile.url.rstrip('/') == root_url, 'Mobile session unexpectedly lost authentication'
|
||||
mobile.locator('#mainContent').wait_for(timeout=10000)
|
||||
mobile.locator('#menuToggle').click()
|
||||
assert 'nav-open' in mobile.locator('body').get_attribute('class') or mobile.locator('#sideMenu').is_visible()
|
||||
if screenshot_dir:
|
||||
mobile.screenshot(path=str(d/'mobile.png'),full_page=True)
|
||||
assert not errors,errors
|
||||
print('PASS: Chromium login, create site, navigate content/design, mobile menu, no page exceptions')
|
||||
browser.close()
|
||||
finally:
|
||||
server.terminate()
|
||||
try:server.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:server.kill();server.communicate()
|
||||
|
||||
if __name__=='__main__':
|
||||
run(sys.argv[1] if len(sys.argv)>1 else None)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Optional actual Chromium smoke on disposable site. Never contacts production."""
|
||||
import json, os, socket, subprocess, sys, tempfile, time
|
||||
from pathlib import Path
|
||||
from urllib.request import urlopen
|
||||
import httpx
|
||||
from playwright.sync_api import sync_playwright
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
OUT=ROOT/'reports'/'studio-ux'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory(prefix='sf-studio-ui-') as folder:
|
||||
with socket.socket() as s:s.bind(('127.0.0.1',0));port=s.getsockname()[1]
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
env={**os.environ,'SITEFORGE_ADMIN_PASSWORD':'studio-smoke-only-password','SITEFORGE_SESSION_SECRET':'studio-smoke-only-secret-very-long','SITEFORGE_DATA_DIR':folder,'SITEFORGE_DB_FILE':str(Path(folder)/'siteforge.sqlite3'),'SITEFORGE_DATABASE_URL':'','SITEFORGE_REDIS_URL':'','SITEFORGE_COOKIE_SECURE':'false','NO_PROXY':'127.0.0.1,localhost','no_proxy':'127.0.0.1,localhost'}
|
||||
proc=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
||||
try:
|
||||
for i in range(120):
|
||||
if proc.poll() is not None:raise RuntimeError('server exited: '+proc.stderr.read().decode()[-1200:])
|
||||
try:
|
||||
with urlopen(base+'/api/ready',timeout=0.4) as response:
|
||||
if response.status==200:break
|
||||
except Exception:time.sleep(.1)
|
||||
else:raise RuntimeError('readiness timed out')
|
||||
with httpx.Client(base_url=base,trust_env=False) as client:
|
||||
login=client.post('/api/auth/login',json={'username':'admin','password':env['SITEFORGE_ADMIN_PASSWORD']});login.raise_for_status()
|
||||
csrf=client.get('/api/auth/me').json()['csrf']
|
||||
site=client.post('/api/sites',json={'name':'V8.21 Studio UI 验收站点','theme':'corporate'},headers={'X-SiteForge-CSRF':csrf});site.raise_for_status();sid=site.json()['id']
|
||||
entries=client.get(f'/api/sites/{sid}/entries').json();page_id=next(x['id'] for x in entries if x['route']=='/')
|
||||
cookie_names={c.name:c.value for c in client.cookies.jar}
|
||||
with sync_playwright() as play:
|
||||
browser=play.chromium.launch(executable_path='/usr/bin/chromium',headless=True,args=['--no-sandbox','--disable-dev-shm-usage','--no-proxy-server','--disable-gpu'])
|
||||
context=browser.new_context(viewport={'width':1600,'height':950},device_scale_factor=1)
|
||||
context.add_cookies([{'name':k,'value':v,'url':base} for k,v in cookie_names.items()])
|
||||
page=context.new_page()
|
||||
errors=[];page.on('pageerror',lambda e:errors.append(str(e)))
|
||||
page.goto(base+f'/editor/{sid}/{page_id}',wait_until='domcontentloaded',timeout=20000)
|
||||
page.locator('#blockList .block-row').first.wait_for(timeout=12000)
|
||||
page.screenshot(path=str(OUT/'editor-desktop.png'),full_page=True)
|
||||
page.locator('#tabResponsive').click()
|
||||
assert page.locator('#tabResponsive').get_attribute('aria-selected')=='true'
|
||||
assert not page.locator('[data-inspector-section="responsive"]').is_hidden()
|
||||
page.locator('#tabContent').click()
|
||||
total=page.locator('.block-row').count()
|
||||
page.locator('#blockSearch').fill('not-find-anything')
|
||||
assert page.locator('.block-row').count()==0
|
||||
page.locator('#blockSearch').fill('')
|
||||
assert page.locator('.block-row').count()==total
|
||||
page.locator('.block-row').first.locator('button[title="隐藏"]').click()
|
||||
assert page.locator('#saveStatus').inner_text().startswith('未保存')
|
||||
page.locator('#saveDraftButton').click()
|
||||
page.wait_for_function("document.querySelector('#saveStatus').textContent.startsWith('草稿')",timeout=12000)
|
||||
page.locator('#devicePhone').click()
|
||||
page.screenshot(path=str(OUT/'editor-phone-preview.png'),full_page=True)
|
||||
assert not errors,errors
|
||||
mobile=browser.new_context(viewport={'width':390,'height':844},device_scale_factor=1)
|
||||
mobile.add_cookies([{'name':k,'value':v,'url':base} for k,v in cookie_names.items()])
|
||||
mp=mobile.new_page();mp.goto(base+f'/editor/{sid}/{page_id}',wait_until='domcontentloaded',timeout=20000)
|
||||
mp.locator('#blockCount').wait_for(timeout=12000)
|
||||
mp.locator('.pane-switcher button').first.click()
|
||||
assert 'show-structure' in mp.locator('body').get_attribute('class')
|
||||
mp.screenshot(path=str(OUT/'editor-mobile-ui.png'),full_page=True)
|
||||
mobile.close();browser.close()
|
||||
print(json.dumps({'result':'PASS','desktop_blocks':total,'browser_errors':errors,'screenshots':[str(x) for x in OUT.glob('*.png')]},ensure_ascii=False))
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:proc.kill();proc.wait()
|
||||
if __name__=='__main__':main()
|
||||
+1
-1
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
|
||||
Set-Location (Join-Path $PSScriptRoot '..')
|
||||
python -m compileall -q app
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
python -m pytest -q
|
||||
python scripts/regression_v821.py --report reports/v821-regression
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Get-ChildItem app/static -Filter *.js | ForEach-Object {
|
||||
node --check $_.FullName
|
||||
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
python -m compileall -q app
|
||||
python -m pytest -q
|
||||
# Each batch has a hard deadline and failed batches are isolated for diagnosis.
|
||||
# Reports are generated outside test output; do not commit logs without review.
|
||||
python scripts/regression_v821.py --repeat "${SITEFORGE_REGRESSION_REPEAT:-1}" --timeout "${SITEFORGE_REGRESSION_TIMEOUT:-90}" --report reports/v821-regression
|
||||
python scripts/verify_release.py
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
for f in app/static/*.js; do node --check "$f"; done
|
||||
else
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Deterministic demo, only writes to an explicitly isolated SITEFORGE_DATA_DIR."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
if not os.getenv('SITEFORGE_DATA_DIR'):
|
||||
raise SystemExit('Please set SITEFORGE_DATA_DIR to a disposable directory first')
|
||||
import sys
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
from app import db, multilingual, builder, theme_store
|
||||
from app.core import save_entry, uid
|
||||
|
||||
db.init()
|
||||
theme_store.setup()
|
||||
multilingual.setup()
|
||||
records=[('zh-CN','中文官网','https://zh.demo.example.org','/about/','关于我们','全球服务网络'),
|
||||
('en','Global Website','https://en.demo.example.org','/company/','About Our Company','Worldwide service network')]
|
||||
ids={}; content={}
|
||||
for locale,name,domain,about,title,body in records:
|
||||
sid=uid(); ids[locale]=sid
|
||||
db.insert('''INSERT INTO sites VALUES (?,?,?,?,?,?,?,?,?,?,?)''',
|
||||
(sid,name,domain,'SiteForge P6 international demonstration','corporate','#2563eb','','new','{}',db.now(),db.now()))
|
||||
home=save_entry(sid,{'type':'page','title':name,'route':'/','body':f'<p>{body}</p>'})
|
||||
detail=save_entry(sid,{'type':'page','title':title,'route':about,'body':f'<p>{body}</p>'})
|
||||
content[locale]={'home':home,'about':detail}
|
||||
gid=uid();db.insert('INSERT INTO language_groups VALUES (?,?,?,?)',(gid,'HESU multilingual demo','en',db.now()))
|
||||
for locale in ids:db.insert('INSERT INTO language_sites VALUES (?,?,?)',(gid,ids[locale],locale))
|
||||
for key in ('home','about'):
|
||||
unit=uid();db.insert('INSERT INTO translation_units VALUES (?,?,?,?)',(unit,gid,key,db.now()))
|
||||
for locale in ids:db.insert('INSERT INTO translation_members VALUES (?,?,?,?)',(unit,ids[locale],content[locale][key],'approved'))
|
||||
for locale,sid in ids.items():
|
||||
report=builder.build(sid)
|
||||
print(json.dumps({'locale':locale,'site_id':sid,'build':report['version'],'pages':report['pages'],'alternates':report['alternate_pages'],'errors':report['errors'],'warnings':report['warnings'][:3]},ensure_ascii=False))
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert an authorized WXR or Sitemap XML file into a URL-preservation baseline.
|
||||
|
||||
Offline only. Uses defusedxml; hard input limit 32 MiB. Does not download sites.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, unquote
|
||||
|
||||
try:
|
||||
from defusedxml import ElementTree as DET
|
||||
except ImportError:
|
||||
print('需要先安装 defusedxml:python -m pip install defusedxml', file=sys.stderr)
|
||||
raise SystemExit(3)
|
||||
|
||||
WP_NS = '{http://wordpress.org/export/1.2/}'
|
||||
SITEMAP_NS = '{http://www.sitemaps.org/schemas/sitemap/0.9}'
|
||||
MAX_XML = 32 * 1024 * 1024
|
||||
|
||||
|
||||
def route_from_url(raw, allowed_hosts):
|
||||
p = urlsplit(raw.strip())
|
||||
if p.scheme not in ('http', 'https') or not p.hostname:
|
||||
return None
|
||||
if p.hostname.lower().rstrip('.') not in allowed_hosts:
|
||||
return None
|
||||
# Encoded dots/slashes would introduce ambiguous paths, so reject them.
|
||||
path = unquote(p.path or '/')
|
||||
if '\\' in path or '\x00' in path or '//' in path or any(seg in ('.', '..') for seg in path.split('/')):
|
||||
return None
|
||||
# Query-based legacy WordPress URLs require explicit redirect mapping.
|
||||
if p.query or p.fragment:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def extract(root, hosts, include_unpublished=False):
|
||||
routes = set()
|
||||
ignored = []
|
||||
if root.tag.endswith('urlset'):
|
||||
items = root.findall(f'.//{SITEMAP_NS}url') or root.findall('.//url')
|
||||
for item in items:
|
||||
loc = item.findtext(f'{SITEMAP_NS}loc') or item.findtext('loc') or ''
|
||||
route = route_from_url(loc, hosts)
|
||||
if route: routes.add(route)
|
||||
elif loc: ignored.append('sitemap: 无法映射的 loc')
|
||||
elif root.tag == 'rss' or root.tag.endswith('rss'):
|
||||
items = root.findall('./channel/item')
|
||||
for item in items:
|
||||
status = (item.findtext(f'{WP_NS}status') or '').strip()
|
||||
kind = (item.findtext(f'{WP_NS}post_type') or '').strip()
|
||||
# WXR can contain attachments and revision records: only published content pages.
|
||||
if kind in ('attachment', 'revision', 'nav_menu_item'):
|
||||
continue
|
||||
if not include_unpublished and status != 'publish':
|
||||
continue
|
||||
link = item.findtext('link') or ''
|
||||
route = route_from_url(link, hosts)
|
||||
if route: routes.add(route)
|
||||
elif link: ignored.append('WXR: 无法映射的 link')
|
||||
else:
|
||||
raise ValueError('仅支持 WXR RSS 或 sitemap urlset XML(不支持 sitemapindex 的递归远端访问)')
|
||||
return sorted(routes), ignored
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description='从授权的 WordPress WXR 或 Sitemap 导出原始 URL 基线,离线运行')
|
||||
ap.add_argument('xml', type=Path)
|
||||
ap.add_argument('--host', required=True, help='旧站域名,不带 scheme 或路径,例如 example.com')
|
||||
ap.add_argument('--allow-host', action='append', default=[], help='手动允许其他同站主机名(例如 www.example.com)')
|
||||
ap.add_argument('--include-unpublished', action='store_true', help='WXR 包含草稿(默认仅 publish)')
|
||||
ap.add_argument('--output', type=Path, required=True)
|
||||
args = ap.parse_args(argv)
|
||||
allowed = set()
|
||||
for host in [args.host] + args.allow_host:
|
||||
if not host or any(c in host for c in '/:@?#') or any(c.isspace() for c in host):
|
||||
ap.error('--host/--allow-host 只接受域名,不接受 URL、端口或路径')
|
||||
allowed.add(host.lower().rstrip('.'))
|
||||
try:
|
||||
size = args.xml.stat().st_size
|
||||
if size > MAX_XML: raise ValueError('XML 超过 32 MiB 上限,请分割或使用离线批处理')
|
||||
if not size: raise ValueError('XML 文件为空')
|
||||
root = DET.parse(args.xml).getroot()
|
||||
routes, ignored = extract(root, allowed, args.include_unpublished)
|
||||
except (OSError, ValueError, DET.ParseError, DET.EntitiesForbidden, DET.DTDForbidden) as e:
|
||||
print('导入失败:' + str(e), file=sys.stderr)
|
||||
return 2
|
||||
if not routes:
|
||||
print('没有找到匹配已发布内容的 URL,请检查旧站域名和 XML 格式', file=sys.stderr)
|
||||
return 2
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text('# WordPress 旧站原始 URL 路径基线(不可修改路径语义)\n' + '\n'.join(routes) + '\n', encoding='utf-8')
|
||||
print(f'导出 {len(routes)} 条;无法映射 {len(ignored)} 条。保存:{args.output}')
|
||||
if ignored:
|
||||
print('警告:有 URL 包含查询参数或使用了非指定域名;需人工建立 301 路径映射。', file=sys.stderr)
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
"""End-to-end HTTP + Worker smoke test on an isolated disposable SQLite database.
|
||||
|
||||
Launches an actual uvicorn process, calls authenticated HTTP APIs, runs a real
|
||||
Worker claim + isolated subprocess build, then downloads and checks the archive.
|
||||
Never points at production data or remote deployment services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import urlopen
|
||||
import httpx
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
|
||||
def run():
|
||||
with tempfile.TemporaryDirectory(prefix='siteforge-e2e-') as folder:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]
|
||||
base=f'http://127.0.0.1:{port}'
|
||||
env=os.environ.copy()
|
||||
env.update(SITEFORGE_ADMIN_PASSWORD='e2e-only-dummy-password-123456',
|
||||
SITEFORGE_SESSION_SECRET='e2e-only-session-secret-long-value-123456',
|
||||
SITEFORGE_DATA_DIR=folder,SITEFORGE_DB_FILE=str(Path(folder)/'siteforge.sqlite3'),
|
||||
SITEFORGE_DATABASE_URL='',SITEFORGE_REDIS_URL='',SITEFORGE_COOKIE_SECURE='false')
|
||||
proc=subprocess.Popen([sys.executable,'-m','uvicorn','app.main:app','--host','127.0.0.1','--port',str(port)],
|
||||
cwd=ROOT,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.PIPE)
|
||||
try:
|
||||
for n in range(120):
|
||||
if proc.poll() is not None:raise RuntimeError('Web startup failed: '+proc.stderr.read().decode(errors='replace')[-3000:])
|
||||
try:
|
||||
with urlopen(base+'/api/ready',timeout=0.5) as result:
|
||||
if result.status==200:break
|
||||
except (OSError,URLError):time.sleep(0.1)
|
||||
else:raise RuntimeError('Web readiness timeout')
|
||||
with httpx.Client(base_url=base,timeout=20,follow_redirects=False) as client:
|
||||
auth=client.post('/api/auth/login',json={'username':'admin','password':env['SITEFORGE_ADMIN_PASSWORD']})
|
||||
assert auth.status_code==200,auth.text
|
||||
me=client.get('/api/auth/me')
|
||||
assert me.status_code==200,me.text
|
||||
client.headers['X-SiteForge-CSRF']=me.json()['csrf']
|
||||
site=client.post('/api/sites',json={'name':'E2E Production Smoke','theme':'corporate'})
|
||||
assert site.status_code==201,site.text
|
||||
sid=site.json()['id']
|
||||
page=client.post(f'/api/sites/{sid}/entries',json={'type':'page','title':'Test about','route':'/about/','body':'E2E smoke content'})
|
||||
assert page.status_code==201,page.text
|
||||
job=client.post(f'/api/sites/{sid}/builds',json={'mirror_images':False},headers={'Idempotency-Key':'e2e-build-000001'})
|
||||
assert job.status_code==202,job.text
|
||||
completed=subprocess.run([sys.executable,'-c','from app import db,enterprise,worker; db.init(); enterprise.setup(); assert worker.run_once()'],cwd=ROOT,env=env,capture_output=True,text=True,timeout=70)
|
||||
assert completed.returncode==0,completed.stderr[-2500:]
|
||||
builds=client.get(f'/api/sites/{sid}/builds')
|
||||
assert builds.status_code==200,builds.text
|
||||
done=[x for x in builds.json() if x['status']=='complete']
|
||||
if not done:
|
||||
raise AssertionError('No completed static build: '+str(builds.json())[:1200])
|
||||
archive=client.get(f"/api/sites/{sid}/builds/{done[0]['id']}/download")
|
||||
assert archive.status_code==200,archive.text[:300]
|
||||
with zipfile.ZipFile(io.BytesIO(archive.content)) as z:
|
||||
assert 'index.html' in z.namelist()
|
||||
assert 'about/index.html' in z.namelist()
|
||||
preview=client.get(f"/preview/{sid}/{done[0]['id']}/about/")
|
||||
assert preview.status_code==200 and 'E2E smoke content' in preview.text
|
||||
print('PASS: ready -> login/CSRF -> create site/page -> enqueue -> Worker build -> ZIP -> preview')
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:proc.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:proc.kill();proc.communicate()
|
||||
|
||||
if __name__=='__main__':run()
|
||||
@@ -12,7 +12,7 @@ password=secrets.token_urlsafe(24)
|
||||
secret=secrets.token_urlsafe(48)
|
||||
pg_password=secrets.token_urlsafe(30)
|
||||
deploy_secret=secrets.token_urlsafe(48)
|
||||
config.write_text(f'''# SiteForge Factory 8.12.0-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET=\n''', encoding='utf-8')
|
||||
config.write_text(f'''# SiteForge Factory 8.21.3-dev.1 - 不要上传至代码仓库\nSITEFORGE_PORT=8787\nSITEFORGE_ADMIN_PASSWORD={password}\nSITEFORGE_SESSION_SECRET={secret}\nSITEFORGE_COOKIE_SECURE=false\nSITEFORGE_DOMAIN=siteforge.example.com\nSITEFORGE_PG_USER=siteforge\nSITEFORGE_PG_PASSWORD={pg_password}\nSITEFORGE_PG_DB=siteforge\nSITEFORGE_DEPLOY_SECRET={deploy_secret}\n# Remote deployment target: configure HTTPS URLs and corresponding secrets manually.\nSITEFORGE_DEPLOY_STAGING_URL=\nSITEFORGE_DEPLOY_STAGING_SECRET_REF=\nSITEFORGE_DEPLOY_PRODUCTION_URL=\nSITEFORGE_DEPLOY_PRODUCTION_SECRET_REF=\n''', encoding='utf-8')
|
||||
try:os.chmod(config,0o600)
|
||||
except OSError:pass
|
||||
print('SiteForge 已生成 .env 配置。')
|
||||
|
||||
@@ -1,52 +1,158 @@
|
||||
"""Offline installer for SiteForge components into a user-provided official Art Design Pro checkout.
|
||||
"""Install the SiteForge V8.19 module into a user-provided Art Design Pro checkout.
|
||||
|
||||
Run with --dry-run first. It does NOT download, modify auth schemes, or
|
||||
pretend to have compiled the official repository in this sandbox.
|
||||
No network, npm, or destructive operations. Route insertion is deterministic; the
|
||||
official Art checkout is not bundled. Usage: --apply, --verify, --migrate-legacy.
|
||||
"""
|
||||
import argparse,shutil,re
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
|
||||
SOURCE=Path(__file__).resolve().parents[1]/'integrations'/'art-design-pro'/'src'/'views'/'siteforge'
|
||||
MARKER='// SITEFORGE_FACTORY_V7_REGISTERED'
|
||||
V8_MARKER='// SITEFORGE_FACTORY_V8_REGISTERED'
|
||||
V8_ROUTES="""
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / 'integrations/art-design-pro/src/views/siteforge'
|
||||
ROUTE_MARKER = '// SITEFORGE_FACTORY_V8_REGISTERED'
|
||||
BEGIN = '// SITEFORGE_V819_ROUTES_BEGIN'
|
||||
END = '// SITEFORGE_V819_ROUTES_END'
|
||||
LEGACY = ('// SITEFORGE_FACTORY_V7_REGISTERED', ROUTE_MARKER)
|
||||
ROUTES = '''
|
||||
// SITEFORGE_V819_ROUTES_BEGIN
|
||||
// SITEFORGE_FACTORY_V8_REGISTERED
|
||||
{ path:'/siteforge/production', name:'SiteForgeV8Production', component:'/siteforge/V8Production',
|
||||
meta:{title:'V8 生产控制台',icon:'ri:factory-line'} },
|
||||
"""
|
||||
ROUTES='''\n // SITEFORGE_FACTORY_V7_REGISTERED\n {\n path: '/siteforge', name: 'SiteForge', component: '/index/index',\n meta: {title:'网站工厂',icon:'ri:layout-grid-line'},\n children: [\n {path:'factory',name:'SiteForgeFactory',component:'/siteforge/WebsiteFactory',meta:{title:'网站项目'}},\n {path:'themes',name:'SiteForgeThemes',component:'/siteforge/ThemeCenter',meta:{title:'主题与版本'}},\n {path:'enterprise',name:'SiteForgeEnterprise',component:'/siteforge/EnterpriseCenter',meta:{title:'企业控制'}},
|
||||
{path:'editor',name:'SiteForgeEditor',component:'/siteforge/VisualEditor',meta:{title:'可视化编辑'}},
|
||||
{path:'migration',name:'SiteForgeMigration',component:'/siteforge/MigrationCenter',meta:{title:'CMS 迁移'}},
|
||||
{path:'releases',name:'SiteForgeReleases',component:'/siteforge/ReleaseCenter',meta:{title:'审批与发布'}},\n ]\n },\n'''
|
||||
// SITEFORGE_FACTORY_V7_REGISTERED
|
||||
{
|
||||
path: '/siteforge', name: 'SiteForge', component: '/index/index',
|
||||
meta: { title: '网站工厂', icon: 'ri:layout-grid-line' },
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'SiteForgeDashboard', component: '/siteforge/ConsoleHome', meta: { title: '工作台' } },
|
||||
{ path: 'factory', name: 'SiteForgeFactory', component: '/siteforge/WebsiteFactory', meta: { title: '网站管理' } },
|
||||
{ path: 'content', name: 'SiteForgeContent', component: '/siteforge/ContentCenter', meta: { title: '内容管理' } },
|
||||
{ path: 'editor', name: 'SiteForgeEditor', component: '/siteforge/VisualEditor', meta: { title: '网站设计' } },
|
||||
{ path: 'migration', name: 'SiteForgeMigration', component: '/siteforge/MigrationCenter', meta: { title: '数据与迁移' } },
|
||||
{ path: 'releases', name: 'SiteForgeReleases', component: '/siteforge/ReleaseCenter', meta: { title: '构建与发布' } },
|
||||
{ path: 'enterprise', name: 'SiteForgeEnterprise', component: '/siteforge/EnterpriseCenter', meta: { title: '系统管理' } },
|
||||
{ path: 'themes', name: 'SiteForgeThemes', component: '/siteforge/ThemeCenter', meta: { title: '主题库', isHide: true } },
|
||||
{ path: 'production', name: 'SiteForgeV8Production', component: '/siteforge/V8Production', meta: { title: '高级生产工具', isHide: true } },
|
||||
],
|
||||
},
|
||||
// SITEFORGE_V819_ROUTES_END
|
||||
'''
|
||||
ROUTE_RE = re.compile(r'(export\s+const\s+asyncRoutes\s*(?::[^=]+)?=\s*\[)')
|
||||
|
||||
def install(root:Path,dry_run:bool):
|
||||
base=root/'src'/'router'/'routes'/'asyncRoutes.ts'
|
||||
target=root/'src'/'views'/'siteforge'
|
||||
if not base.is_file() or not (root/'package.json').is_file():raise ValueError('不是受支持的 Art Design Pro 源码目录')
|
||||
original=base.read_text(encoding='utf-8')
|
||||
pat=r'(export\s+const\s+asyncRoutes\s*:[^=]*=\s*\[)'
|
||||
if MARKER not in original:
|
||||
modified,count=re.subn(pat,lambda m:m.group(1)+ROUTES,original,count=1)
|
||||
if count!=1:raise ValueError('无法定位 asyncRoutes 定义;该官方版本的路由结构可能不同,请人工集成')
|
||||
else:modified=original
|
||||
if V8_MARKER not in modified:
|
||||
modified,count=re.subn(pat,lambda m:m.group(1)+V8_ROUTES,modified,count=1)
|
||||
if count!=1:raise ValueError('无法注册 V8 业务页面')
|
||||
print(f'官方工程: {root}\n文件目标: {target}\n待复制页面: {len(list(SOURCE.glob("*.vue")))}\n路由需要更新: {original!=modified}')
|
||||
if dry_run:return
|
||||
|
||||
def _extract_legacy_block(text: str, marker: str) -> tuple[int, int]:
|
||||
start = text.index(marker)
|
||||
# Old installer placed the marker immediately before its own route object.
|
||||
pos = text.find('{', start)
|
||||
if pos < 0: raise ValueError('旧路由没有对象起始符号')
|
||||
depth = 0
|
||||
quoted = None
|
||||
escape = False
|
||||
for i in range(pos, len(text)):
|
||||
c = text[i]
|
||||
if quoted:
|
||||
if escape: escape = False
|
||||
elif c == '\\': escape = True
|
||||
elif c == quoted: quoted = None
|
||||
elif c in ('"', "'", '`'): quoted = c
|
||||
elif c == '{': depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
if end < len(text) and text[end] == ',': end += 1
|
||||
# The marker and the block must be adjacent except whitespace.
|
||||
if text[start + len(marker):pos].strip():
|
||||
raise ValueError('旧路由已人工修改,拒绝自动迁移')
|
||||
return start, end
|
||||
raise ValueError('旧路由结构不完整')
|
||||
|
||||
|
||||
def transform_routes(text: str, migrate_legacy: bool = False) -> str:
|
||||
if 'SITEFORGE_V819_ROUTES_BEGIN' in text or 'SITEFORGE_V819_ROUTES_END' in text:
|
||||
if text.count(BEGIN) != 1 or text.count(END) != 1:
|
||||
raise ValueError('V8.19 路由标记不完整')
|
||||
a = text.index(BEGIN)
|
||||
b = text.index(END) + len(END)
|
||||
if text[a:b] != ROUTES.strip():
|
||||
raise ValueError('已安装的 SiteForge 路由被人工修改;请人工审核后升级')
|
||||
return text
|
||||
if any(m in text for m in LEGACY):
|
||||
if not migrate_legacy:
|
||||
raise ValueError('发现 V8 旧版路由;请备份后使用 --migrate-legacy 显式升级')
|
||||
# Remove highest-offset blocks first. Never touch an unrelated route.
|
||||
for m in sorted((m for m in LEGACY if m in text), key=lambda v:text.index(v), reverse=True):
|
||||
a,b=_extract_legacy_block(text,m)
|
||||
text=text[:a]+text[b:]
|
||||
modified,n = ROUTE_RE.subn(lambda m:m.group(1)+ROUTES, text, count=1)
|
||||
if n!=1: raise ValueError('无法识别 asyncRoutes 变量;需手动适配 Art 官方版本')
|
||||
return modified
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def install(root: Path, dry_run: bool = True, verify: bool = False,
|
||||
migrate_legacy: bool = False, overwrite_views: bool = False):
|
||||
root = root.resolve()
|
||||
route_file = root / 'src/router/routes/asyncRoutes.ts'
|
||||
if not (root/'package.json').is_file() or not route_file.is_file():
|
||||
raise ValueError('不是受支持的 Art Design Pro 工程:缺少 package.json 或 asyncRoutes.ts')
|
||||
current = route_file.read_text(encoding='utf-8')
|
||||
new = transform_routes(current, migrate_legacy=migrate_legacy)
|
||||
target = root/'src/views/siteforge'
|
||||
sources = sorted(p for p in SOURCE.iterdir() if p.suffix in ('.vue','.ts') and p.is_file())
|
||||
if not sources: raise ValueError('本地业务页面资源缺失')
|
||||
existing = [p for p in sources if (target/p.name).exists() and (target/p.name).read_bytes()!=p.read_bytes()]
|
||||
if verify:
|
||||
errors=[]
|
||||
if current!=new: errors.append('路由未安装')
|
||||
for src in sources:
|
||||
out=target/src.name
|
||||
if not out.is_file() or _sha(out.read_bytes()) != _sha(src.read_bytes()):
|
||||
errors.append(src.name+' 缺失或不匹配')
|
||||
if errors: raise ValueError('; '.join(errors))
|
||||
return {'installed':True,'views':len(sources),'routes':'verified'}
|
||||
if existing and not overwrite_views and not dry_run:
|
||||
raise ValueError('以下页面存在不同内容,拒绝覆盖:'+', '.join(p.name for p in existing)+';请加 --overwrite-views')
|
||||
result={'dry_run':dry_run,'routes_need_update':current!=new,'views':len(sources),
|
||||
'conflicting_views':[x.name for x in existing],'target':str(root)}
|
||||
if dry_run:return result
|
||||
target.mkdir(parents=True,exist_ok=True)
|
||||
for src in SOURCE.glob('*.vue'):
|
||||
dest=target/src.name
|
||||
if dest.exists():shutil.copy2(dest,dest.with_suffix('.vue.siteforge-backup'))
|
||||
shutil.copy2(src,dest)
|
||||
if modified!=original:
|
||||
shutil.copy2(base,base.with_suffix('.ts.siteforge-backup'))
|
||||
base.write_text(modified,encoding='utf-8')
|
||||
print('业务页面与菜单已写入;下一步请配置 /api、/editor、/preview 代理并执行 pnpm build 完成验收。')
|
||||
for src in sources:
|
||||
out=target/src.name
|
||||
if out.exists() and out.read_bytes()!=src.read_bytes():
|
||||
backup=out.with_name(out.name+'.siteforge-backup')
|
||||
if not backup.exists():shutil.copy2(out,backup)
|
||||
shutil.copy2(src,out)
|
||||
if current!=new:
|
||||
backup=route_file.with_name(route_file.name+'.siteforge-backup')
|
||||
if not backup.exists():shutil.copy2(route_file,backup)
|
||||
temp=route_file.with_suffix('.ts.siteforge-tmp')
|
||||
try:
|
||||
temp.write_text(new,encoding='utf-8')
|
||||
os.replace(temp,route_file)
|
||||
finally:
|
||||
temp.unlink(missing_ok=True)
|
||||
manifest={'siteforge_version':'8.21.3-dev.1','files':{p.name:_sha(p.read_bytes()) for p in sources}}
|
||||
(target/'.siteforge-v819.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
|
||||
result['installed']=True
|
||||
return result
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('official_repo',type=Path)
|
||||
parser.add_argument('--apply',action='store_true',help='执行源代码变更;不传则只检查')
|
||||
args=parser.parse_args()
|
||||
install(args.official_repo.resolve(),dry_run=not args.apply)
|
||||
parser=argparse.ArgumentParser(description='SiteForge V8.19 → Art Design Pro 离线适配安装')
|
||||
parser.add_argument('official_repo', type=Path)
|
||||
parser.add_argument('--apply',action='store_true')
|
||||
parser.add_argument('--verify',action='store_true')
|
||||
parser.add_argument('--migrate-legacy',action='store_true')
|
||||
parser.add_argument('--overwrite-views',action='store_true')
|
||||
a=parser.parse_args()
|
||||
try:
|
||||
print(json.dumps(install(a.official_repo,dry_run=not(a.apply or a.verify),verify=a.verify,
|
||||
migrate_legacy=a.migrate_legacy,overwrite_views=a.overwrite_views),ensure_ascii=False,indent=2))
|
||||
except ValueError as e:
|
||||
parser.error(str(e))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Maintenance-only SQLite to clean PostgreSQL database migration."""
|
||||
from __future__ import annotations
|
||||
# Allow direct `python scripts/name.py` from a fresh source checkout.
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
|
||||
import argparse,json,sys
|
||||
from app.sqlite_pg_transfer import migrate
|
||||
from app.pg_disaster_recovery import RecoveryError
|
||||
|
||||
def main(argv=None):
|
||||
ap=argparse.ArgumentParser(description='Offline SQLite -> EMPTY PostgreSQL data migration')
|
||||
ap.add_argument('--source',required=True,help='Existing SQLite file. Never modified.')
|
||||
ap.add_argument('--confirmed-offline',action='store_true')
|
||||
ap.add_argument('--report',help='Output machine-readable verification summary')
|
||||
args=ap.parse_args(argv)
|
||||
try:
|
||||
result=migrate(args.source,confirmed_offline=args.confirmed_offline)
|
||||
summary=json.dumps(result,ensure_ascii=False,indent=2)
|
||||
if args.report:
|
||||
from pathlib import Path
|
||||
out=Path(args.report).resolve()
|
||||
if out.exists():raise RecoveryError('Refuse overwriting prior validation report')
|
||||
out.parent.mkdir(parents=True,exist_ok=True);out.write_text(summary,encoding='utf-8')
|
||||
print(summary);return 0
|
||||
except (RecoveryError,OSError,ValueError) as ex:
|
||||
print('MIGRATION FAILED: '+str(ex),file=sys.stderr);return 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline PostgreSQL backup/verify/recovery CLI for administrators."""
|
||||
from __future__ import annotations
|
||||
# Allow direct `python scripts/name.py` from a fresh source checkout.
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from app import pg_disaster_recovery as pg
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description='SiteForge offline PostgreSQL disaster recovery')
|
||||
sub=p.add_subparsers(dest='operation',required=True)
|
||||
b=sub.add_parser('backup');b.add_argument('--data',required=True);b.add_argument('--output',required=True)
|
||||
b.add_argument('--confirmed-offline',action='store_true')
|
||||
v=sub.add_parser('verify');v.add_argument('archive')
|
||||
r=sub.add_parser('restore-new');r.add_argument('archive');r.add_argument('--data',required=True)
|
||||
r.add_argument('--confirmed-offline',action='store_true')
|
||||
args=p.parse_args(argv)
|
||||
try:
|
||||
if args.operation=='verify':value=pg.verify(args.archive)
|
||||
else:
|
||||
url=os.getenv('SITEFORGE_DATABASE_URL','')
|
||||
if args.operation=='backup':
|
||||
value=pg.backup(url,args.data,args.output,confirmed_offline=args.confirmed_offline)
|
||||
else:
|
||||
value=pg.restore_new(url,args.archive,args.data,confirmed_offline=args.confirmed_offline)
|
||||
print(json.dumps(value,ensure_ascii=False,indent=2))
|
||||
return 0
|
||||
except (pg.RecoveryError,OSError,ValueError) as exc:
|
||||
print('ERROR: '+str(exc),file=__import__('sys').stderr)
|
||||
return 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""V8.21 repeatable integration regression with per-batch deadlines and isolation.
|
||||
|
||||
Runs pytest against only the local repository. Timeout cleanup kills the whole
|
||||
process group on POSIX rather than leaving orphan pytest/Worker children.
|
||||
Nonzero, flaky and timed-out runs always fail the quality gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
|
||||
@dataclass
|
||||
class Run:
|
||||
label: str
|
||||
modules: list[str]
|
||||
status: str
|
||||
seconds: float
|
||||
exit_code: int | None
|
||||
passed: int
|
||||
log: str
|
||||
|
||||
|
||||
def run_pytest(label: str, modules: list[str], seconds: int, reports: Path, env: dict[str,str]) -> Run:
|
||||
args=[sys.executable,'-m','pytest','-vv','--tb=short','--disable-warnings','--no-header',*modules]
|
||||
filename=re.sub(r'[^a-zA-Z0-9_-]+','-',label)+'.log'
|
||||
path=reports/filename
|
||||
began=time.monotonic()
|
||||
flags={'start_new_session':True} if os.name!='nt' else {'creationflags':subprocess.CREATE_NEW_PROCESS_GROUP}
|
||||
expired=False
|
||||
with path.open('wb') as out:
|
||||
out.write(('$ '+' '.join(args)+'\n').encode('utf-8'))
|
||||
out.flush()
|
||||
process=subprocess.Popen(args,cwd=ROOT,env=env,stdout=out,stderr=subprocess.STDOUT,**flags)
|
||||
try:
|
||||
process.wait(timeout=seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
expired=True
|
||||
if os.name!='nt':
|
||||
try: os.killpg(process.pid,signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
else: process.terminate()
|
||||
try: process.wait(timeout=4)
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.name!='nt':
|
||||
try: os.killpg(process.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
else: process.kill()
|
||||
process.wait(timeout=5)
|
||||
out.write(f'\nTIMEOUT: deadline={seconds}s; process group terminated\n'.encode())
|
||||
content=path.read_text(encoding='utf-8',errors='replace')
|
||||
m=re.findall(r'(?m)(\d+) passed(?:,| in |\s*\()',content)
|
||||
passed=int(m[-1]) if m else 0
|
||||
status='TIMEOUT' if expired else ('PASS' if process.returncode==0 and passed>0 else 'FAIL')
|
||||
return Run(label,modules,status,round(time.monotonic()-began,2),process.returncode if not expired else None,passed,str(path))
|
||||
|
||||
|
||||
def write_reports(runs: list[Run],prefix: Path,modules_count: int,repeat: int):
|
||||
statuses={x:sum(r.status==x for r in runs) for x in ('PASS','FAIL','TIMEOUT')}
|
||||
batches=[r for r in runs if re.fullmatch(r'round\d+-batch\d+',r.label)]
|
||||
bad=[r for r in batches if r.status!='PASS']
|
||||
result={'version':'8.21.0-dev.1','date':dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
'modules':modules_count,'repeats':repeat,'status':'PASS' if not bad else 'FAIL',
|
||||
'tests_last_complete_round':sum(r.passed for r in batches if r.label.startswith(f'round{repeat}-')),
|
||||
'counts':statuses,'failures':[asdict(x) for x in bad],
|
||||
'runs':[asdict(x) for x in runs],
|
||||
'note':'Timed out, failing, or recovered-on-retry tests are not considered passing.'}
|
||||
prefix.parent.mkdir(parents=True,exist_ok=True)
|
||||
prefix.with_suffix('.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
lines=['# SiteForge V8.21 集成回归','',f"状态:**{result['status']}**;模块:{modules_count};重复轮次:{repeat}",'',
|
||||
'| 批次 | 状态 | 通过用例 | 秒 | 日志 |','|---|---|---:|---:|---|']
|
||||
for r in runs:
|
||||
lines.append(f'| {r.label} | {r.status} | {r.passed} | {r.seconds} | {Path(r.log).name} |')
|
||||
lines+=['','失败、超时或重跑后通过均视为验收未通过;详见日志。','']
|
||||
prefix.with_suffix('.md').write_text('\n'.join(lines),encoding='utf-8')
|
||||
rows=''.join(f'<tr><td>{html.escape(r.label)}</td><td class="{r.status.lower()}">{r.status}</td><td>{r.passed}</td><td>{r.seconds}</td><td>{html.escape(Path(r.log).name)}</td></tr>' for r in runs)
|
||||
doc=f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.21 · 自动化回归</title><style>body{{font:15px/1.5 system-ui,sans-serif;background:#f7f8fa;color:#19273b;margin:0}}header{{background:#182940;color:white;padding:32px 6%}}main{{max-width:1080px;margin:25px auto;background:white;border:1px solid #e0e4eb;border-radius:14px;padding:22px}}table{{width:100%;border-collapse:collapse}}td,th{{padding:11px;text-align:left;border-bottom:1px solid #e6e9ee}}.pass{{color:#14734c}}.fail,.timeout{{color:#a52626}}@media(max-width:700px){{main{{overflow-x:auto;margin:12px}}}}</style></head><body><header><h1>SiteForge · V8.21 集成回归</h1><p>结果:{result['status']} · {modules_count} 模块 · {repeat} 轮</p></header><main><p>每个测试批次有严格超时,发生异常时隔离失败模块并留存原始日志。任何超时均阻止上线判定。</p><table><tr><th>批次</th><th>状态</th><th>通过用例</th><th>耗时(秒)</th><th>日志</th></tr>{rows}</table></main></body></html>'''
|
||||
prefix.with_suffix('.html').write_text(doc,encoding='utf-8')
|
||||
return result
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
ap=argparse.ArgumentParser(description='Timeout-safe repeatable SiteForge regression')
|
||||
ap.add_argument('--batch-size',type=int,default=4)
|
||||
ap.add_argument('--timeout',type=int,default=90,help='Single pytest batch timeout seconds')
|
||||
ap.add_argument('--module-timeout',type=int,default=50)
|
||||
ap.add_argument('--repeat',type=int,default=1)
|
||||
ap.add_argument('--report',type=Path,default=Path('reports/v820-regression'))
|
||||
args=ap.parse_args(argv)
|
||||
if not 1<=args.batch_size<=50 or not 1<=args.repeat<=10 or args.timeout<5 or args.module_timeout<5:
|
||||
ap.error('Invalid batch-size, repeat, or timeout')
|
||||
modules=sorted('tests/'+p.name for p in (ROOT/'tests').glob('test_*.py'))
|
||||
if not modules:ap.error('No tests/test_*.py modules')
|
||||
logs=args.report.parent/(args.report.name+'_logs');logs.mkdir(parents=True,exist_ok=True)
|
||||
env=os.environ.copy()
|
||||
env.setdefault('PYTHONUNBUFFERED','1')
|
||||
runs=[]
|
||||
for r in range(1,args.repeat+1):
|
||||
for index in range(0,len(modules),args.batch_size):
|
||||
batch=modules[index:index+args.batch_size]
|
||||
label=f'round{r}-batch{index//args.batch_size+1}'
|
||||
print(f'RUN {label}: {len(batch)} modules',flush=True)
|
||||
result=run_pytest(label,batch,args.timeout,logs,env)
|
||||
runs.append(result)
|
||||
print(f' {result.status} / {result.passed} passed / {result.seconds}s',flush=True)
|
||||
if result.status!='PASS':
|
||||
print(' Isolating modules (original failure is retained)',flush=True)
|
||||
for i,mod in enumerate(batch,1):
|
||||
isolated=run_pytest(f'{label}-isolated-{i}',[mod],args.module_timeout,logs,env)
|
||||
runs.append(isolated)
|
||||
print(f' {Path(mod).name}: {isolated.status} ({isolated.seconds}s)',flush=True)
|
||||
# Do not start further rounds after a non-passing batch.
|
||||
break
|
||||
if any(x.status!='PASS' and re.fullmatch(r'round\d+-batch\d+',x.label) for x in runs):break
|
||||
result=write_reports(runs,args.report,len(modules),args.repeat)
|
||||
print(f"RESULT {result['status']}: {len(modules)} modules; report={args.report}.json",flush=True)
|
||||
return 0 if result['status']=='PASS' else 1
|
||||
|
||||
if __name__=='__main__':sys.exit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic, subprocess-isolated pytest regression for V8.21.
|
||||
|
||||
All outcomes and raw stdout are preserved. A timed-out or failing batch is a
|
||||
hard failure, not silently retried into a green result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def launch(modules,seconds):
|
||||
started=time.monotonic()
|
||||
try:
|
||||
process=subprocess.run([sys.executable,'-m','pytest','-q',*modules],
|
||||
cwd=ROOT,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,
|
||||
text=True,timeout=seconds)
|
||||
output=process.stdout
|
||||
code=process.returncode
|
||||
status='PASS' if code==0 else 'FAIL'
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
output=(exc.stdout or b'')
|
||||
if isinstance(output,bytes):output=output.decode('utf-8','replace')
|
||||
output+='\nTIMEOUT: pytest process terminated after deadline. Check Worker child processes.\n'
|
||||
code=None;status='TIMEOUT'
|
||||
m=re.search(r'(\d+) passed(?:\s|,)',output)
|
||||
return {'status':status,'passed':int(m.group(1)) if status=='PASS' and m else 0,
|
||||
'duration_seconds':round(time.monotonic()-started,2),'exit_code':code,'output':output}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description='SiteForge V8.21 independent pytest batch gate')
|
||||
p.add_argument('--batch-size',type=int,default=4)
|
||||
p.add_argument('--repeat',type=int,default=1)
|
||||
p.add_argument('--timeout',type=int,default=80)
|
||||
p.add_argument('--report',type=Path,default=Path('reports/v821-regression'))
|
||||
args=p.parse_args(argv)
|
||||
if not 1<=args.batch_size<=10 or not 1<=args.repeat<=5 or not 10<=args.timeout<=600:p.error('invalid bounds')
|
||||
modules=sorted('tests/'+f.name for f in (ROOT/'tests').glob('test_*.py'))
|
||||
if not modules:p.error('no pytest tests found')
|
||||
report=args.report if args.report.is_absolute() else ROOT/args.report
|
||||
logs=report.parent/(report.name+'_logs')
|
||||
logs.mkdir(parents=True,exist_ok=True)
|
||||
results=[]
|
||||
for round_num in range(1,args.repeat+1):
|
||||
for index in range(0,len(modules),args.batch_size):
|
||||
group=modules[index:index+args.batch_size]
|
||||
name=f'round{round_num}-batch{index//args.batch_size+1}'
|
||||
print('RUN',name,flush=True)
|
||||
result=launch(group,args.timeout)
|
||||
log=logs/(name+'.log')
|
||||
log.write_text('$ pytest -q '+' '.join(group)+'\n'+result.pop('output'),encoding='utf-8')
|
||||
result.update({'name':name,'modules':group,'log':str(log.relative_to(ROOT))})
|
||||
results.append(result)
|
||||
print(result['status'],result['passed'],'passed',result['duration_seconds'],'sec',flush=True)
|
||||
if result['status']!='PASS':break
|
||||
if results[-1]['status']!='PASS':break
|
||||
success=len(results)==args.repeat*((len(modules)+args.batch_size-1)//args.batch_size) and all(x['status']=='PASS' for x in results)
|
||||
info={'version':'8.21.0-dev.1','generated_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'status':'PASS' if success else 'FAIL','test_modules':len(modules),'repeats':args.repeat,
|
||||
'tests_per_round':sum(x['passed'] for x in results if x['name'].startswith('round1-')),
|
||||
'total_passes':sum(x['passed'] for x in results),'batches':results,
|
||||
'environment_note':'pytest local fixtures only; Docker/PG/Redis/browser/real WordPress not inferred as passed.'}
|
||||
report.parent.mkdir(parents=True,exist_ok=True)
|
||||
report.with_suffix('.json').write_text(json.dumps(info,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
header='\n'.join(['# SiteForge V8.21 完整自动化回归','',f"**{info['status']}** — {info['test_modules']} 模块 / {info['tests_per_round']} 项每轮 / {args.repeat} 轮",'',
|
||||
'| 批次 | 结果 | 通过用例 | 耗时 | 日志 |','|---|---|---:|---:|---|'])
|
||||
table='\n'.join(f"| {r['name']} | {r['status']} | {r['passed']} | {r['duration_seconds']} 秒 | {r['log']} |" for r in results)
|
||||
report.with_suffix('.md').write_text(header+'\n'+table+'\n\n真实部署与外部服务另行验收。\n',encoding='utf-8')
|
||||
trs=''.join(f'<tr><td>{html.escape(r["name"])}</td><td>{html.escape(r["status"])}</td><td>{r["passed"]}</td><td>{r["duration_seconds"]}</td></tr>' for r in results)
|
||||
report.with_suffix('.html').write_text('''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>V8.21 回归报告</title><style>body{font:15px/1.7 system-ui,sans-serif;background:#f2f5f8;padding:25px}main{max-width:950px;margin:auto;background:white;padding:28px;border-radius:14px}table{width:100%;border-collapse:collapse}th,td{padding:10px;border-bottom:1px solid #dde2e7;text-align:left}</style><main><h1>SiteForge V8.21 回归验收</h1><h2>'''+info['status']+'</h2><p>每轮 '+str(info['tests_per_round'])+' 项自动化测试 · '+str(args.repeat)+' 轮 · 无外部环境实测保证</p><table><tr><th>批次</th><th>结果</th><th>通过</th><th>秒数</th></tr>'+trs+'</table><p>请参阅 JSON、Markdown 和原始测试日志。真实客户站点迁移仍需专项验收。</p></main></html>',encoding='utf-8')
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic, subprocess-isolated pytest regression for V8.21.
|
||||
|
||||
All outcomes and raw stdout are preserved. A timed-out or failing batch is a
|
||||
hard failure, not silently retried into a green result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import datetime
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def launch(modules,seconds):
|
||||
"""Execute pytest in a disposable process group and bound inherited pipes.
|
||||
|
||||
A test can spawn Worker descendants; killing only the pytest PID may leave
|
||||
children holding stdout open. On timeout, terminate the entire group.
|
||||
"""
|
||||
started=time.monotonic()
|
||||
kwargs={'cwd':ROOT,'stdout':subprocess.PIPE,'stderr':subprocess.STDOUT,
|
||||
'text':True,'encoding':'utf-8','errors':'replace'}
|
||||
if os.name=='nt':
|
||||
kwargs['creationflags']=subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
kwargs['start_new_session']=True
|
||||
child=subprocess.Popen([sys.executable,'-m','pytest','-q',*modules],**kwargs)
|
||||
try:
|
||||
output,_=child.communicate(timeout=seconds)
|
||||
code=child.returncode
|
||||
status='PASS' if code==0 else 'FAIL'
|
||||
except subprocess.TimeoutExpired:
|
||||
if os.name=='nt':
|
||||
subprocess.run(['taskkill','/PID',str(child.pid),'/T','/F'],
|
||||
capture_output=True,timeout=10,check=False)
|
||||
if child.poll() is None:child.kill()
|
||||
else:
|
||||
try:os.killpg(child.pid,signal.SIGKILL)
|
||||
except ProcessLookupError:pass
|
||||
try:output,_=child.communicate(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill();output,_=child.communicate(timeout=5)
|
||||
output+='\nTIMEOUT: pytest process group terminated after deadline.\n'
|
||||
code=None;status='TIMEOUT'
|
||||
m=re.search(r'(\d+) passed(?:\s|,)',output)
|
||||
return {'status':status,'passed':int(m.group(1)) if status=='PASS' and m else 0,
|
||||
'duration_seconds':round(time.monotonic()-started,2),'exit_code':code,'output':output}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p=argparse.ArgumentParser(description='SiteForge V8.21 independent pytest batch gate')
|
||||
p.add_argument('--batch-size',type=int,default=4)
|
||||
p.add_argument('--repeat',type=int,default=1)
|
||||
p.add_argument('--timeout',type=int,default=80)
|
||||
p.add_argument('--report',type=Path,default=Path('reports/v821-regression'))
|
||||
args=p.parse_args(argv)
|
||||
if not 1<=args.batch_size<=10 or not 1<=args.repeat<=5 or not 10<=args.timeout<=600:p.error('invalid bounds')
|
||||
modules=sorted('tests/'+f.name for f in (ROOT/'tests').glob('test_*.py'))
|
||||
if not modules:p.error('no pytest tests found')
|
||||
report=args.report if args.report.is_absolute() else ROOT/args.report
|
||||
logs=report.parent/(report.name+'_logs')
|
||||
logs.mkdir(parents=True,exist_ok=True)
|
||||
results=[]
|
||||
for round_num in range(1,args.repeat+1):
|
||||
for index in range(0,len(modules),args.batch_size):
|
||||
group=modules[index:index+args.batch_size]
|
||||
name=f'round{round_num}-batch{index//args.batch_size+1}'
|
||||
print('RUN',name,flush=True)
|
||||
result=launch(group,args.timeout)
|
||||
log=logs/(name+'.log')
|
||||
log.write_text('$ pytest -q '+' '.join(group)+'\n'+result.pop('output'),encoding='utf-8')
|
||||
result.update({'name':name,'modules':group,'log':str(log.relative_to(ROOT))})
|
||||
results.append(result)
|
||||
print(result['status'],result['passed'],'passed',result['duration_seconds'],'sec',flush=True)
|
||||
if result['status']!='PASS':break
|
||||
if results[-1]['status']!='PASS':break
|
||||
success=len(results)==args.repeat*((len(modules)+args.batch_size-1)//args.batch_size) and all(x['status']=='PASS' for x in results)
|
||||
info={'version':'8.21.3-dev.1','generated_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'status':'PASS' if success else 'FAIL','test_modules':len(modules),'repeats':args.repeat,
|
||||
'tests_per_round':sum(x['passed'] for x in results if x['name'].startswith('round1-')),
|
||||
'total_passes':sum(x['passed'] for x in results),'batches':results,
|
||||
'environment_note':'pytest local fixtures only; Docker/PG/Redis/browser/real WordPress not inferred as passed.'}
|
||||
report.parent.mkdir(parents=True,exist_ok=True)
|
||||
report.with_suffix('.json').write_text(json.dumps(info,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
header='\n'.join(['# SiteForge V8.21 完整自动化回归','',f"**{info['status']}** — {info['test_modules']} 模块 / {info['tests_per_round']} 项每轮 / {args.repeat} 轮",'',
|
||||
'| 批次 | 结果 | 通过用例 | 耗时 | 日志 |','|---|---|---:|---:|---|'])
|
||||
table='\n'.join(f"| {r['name']} | {r['status']} | {r['passed']} | {r['duration_seconds']} 秒 | {r['log']} |" for r in results)
|
||||
report.with_suffix('.md').write_text(header+'\n'+table+'\n\n真实部署与外部服务另行验收。\n',encoding='utf-8')
|
||||
trs=''.join(f'<tr><td>{html.escape(r["name"])}</td><td>{html.escape(r["status"])}</td><td>{r["passed"]}</td><td>{r["duration_seconds"]}</td></tr>' for r in results)
|
||||
report.with_suffix('.html').write_text('''<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>V8.21 回归报告</title><style>body{font:15px/1.7 system-ui,sans-serif;background:#f2f5f8;padding:25px}main{max-width:950px;margin:auto;background:white;padding:28px;border-radius:14px}table{width:100%;border-collapse:collapse}th,td{padding:10px;border-bottom:1px solid #dde2e7;text-align:left}</style><main><h1>SiteForge V8.21 回归验收</h1><h2>'''+info['status']+'</h2><p>每轮 '+str(info['tests_per_round'])+' 项自动化测试 · '+str(args.repeat)+' 轮 · 无外部环境实测保证</p><table><tr><th>批次</th><th>结果</th><th>通过</th><th>秒数</th></tr>'+trs+'</table><p>请参阅 JSON、Markdown 和原始测试日志。真实客户站点迁移仍需专项验收。</p></main></html>',encoding='utf-8')
|
||||
return 0 if success else 1
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SiteForge V8.18 read-only enterprise acceptance audit.
|
||||
|
||||
Runs only non-destructive inspections. No migrations, deploys, account operations,
|
||||
secret dumps, or changes to a SiteForge instance are performed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import html
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from html.parser import HTMLParser
|
||||
|
||||
CATEGORIES = {"repository": "源码结构", "http": "HTTP 健康探针", "docker": "Docker / Worker", "postgres": "PostgreSQL", "redis": "Redis", "build": "静态构建 / SEO", "release": "发布门禁"}
|
||||
|
||||
@dataclass
|
||||
class Check:
|
||||
code: str
|
||||
category: str
|
||||
status: str # pass | fail | warn | skip
|
||||
description: str
|
||||
detail: str = ""
|
||||
remediation: str = ""
|
||||
|
||||
class Report:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.checks: list[Check] = []
|
||||
def add(self, code, category, status, description, detail="", remediation=""):
|
||||
assert status in {"pass", "fail", "warn", "skip"}
|
||||
# Never include raw environment values or exception objects containing DSNs.
|
||||
self.checks.append(Check(code, category, status, description, str(detail), remediation))
|
||||
def data(self):
|
||||
counts = Counter(c.status for c in self.checks)
|
||||
return {
|
||||
"tool_version": "8.21.1-dev.1", "generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"mode": "read-only", "live_production_verified": False,
|
||||
"note": "本报告只证明实际执行并标记 PASS 的只读检查;SKIP/WARN 不等同通过,未进行真实发布。",
|
||||
"summary": {x: counts[x] for x in ("pass", "fail", "warn", "skip")},
|
||||
"selected_checks_clear": bool(self.checks) and counts["fail"] == 0 and counts["warn"] == 0 and counts["skip"] == 0,
|
||||
"production_ready": False, # human approval + end-to-end validation cannot be automated by this kit
|
||||
"checks": [asdict(c) for c in self.checks]
|
||||
}
|
||||
|
||||
def audit_repo(r: Report, repo: Path):
|
||||
p = repo.resolve()
|
||||
items = {
|
||||
"app": "应用代码 app/", "app/builder.py": "静态构建器 app/builder.py",
|
||||
"app/multilingual.py": "多语言模块 app/multilingual.py",
|
||||
"app/templates/workspace.html": "工作区模板", "app/static/workspace.js": "后台交互脚本",
|
||||
"scripts/check.sh": "Linux 检查脚本", "scripts/http_end_to_end.py": "HTTP 端到端测试",
|
||||
"scripts/verify_pg_bootstrap.py": "PG 隔离验证", "scripts/browser_smoke.py": "浏览器烟测",
|
||||
"compose.enterprise.yaml": "企业 Compose 配置"
|
||||
}
|
||||
if not p.is_dir():
|
||||
r.add("repo.root", "repository", "fail", "仓库路径存在", "指定路径不存在", "设置 --repo 为 SiteForge V8.16 源码根目录")
|
||||
return
|
||||
r.add("repo.root", "repository", "pass", "仓库根目录可访问", "仅进行只读检查")
|
||||
for name, label in items.items():
|
||||
ok = (p / name).exists()
|
||||
r.add("repo." + name.replace("/", "."), "repository", "pass" if ok else "warn", label,
|
||||
"存在" if ok else "在当前源码中未找到", "核对 V8.16 工程是否解压完整" if not ok else "")
|
||||
ps = list((p / "app").rglob("*.py")) if (p / "app").is_dir() else []
|
||||
if ps:
|
||||
import ast
|
||||
errors = []
|
||||
for f in ps:
|
||||
try:
|
||||
ast.parse(f.read_text(encoding="utf-8-sig"), filename=str(f.relative_to(p)))
|
||||
except (SyntaxError, UnicodeError, OSError) as exc:
|
||||
errors.append(f"{f.relative_to(p)}:{getattr(exc, 'lineno', '?')}")
|
||||
if len(errors) >= 5: break
|
||||
r.add("repo.python_syntax", "repository", "fail" if errors else "pass",
|
||||
"Python 源码语法", ", ".join(errors) if errors else f"解析通过 {len(ps)} 个文件")
|
||||
else:
|
||||
r.add("repo.python_syntax", "repository", "skip", "Python 源码语法", "未找到 app/*.py")
|
||||
if (p / ".env").exists():
|
||||
try:
|
||||
text = (p / ".env").read_text(encoding="utf-8", errors="replace")
|
||||
bad = []
|
||||
for key in ("SITEFORGE_COOKIE_SECURE",):
|
||||
m = re.search(rf"(?m)^\s*{key}\s*=\s*(.+?)\s*$", text)
|
||||
if m and m.group(1).strip().strip('"\'').lower() in ("false", "0", "no", "off"):
|
||||
bad.append(key)
|
||||
r.add("repo.env_security", "repository", "warn" if bad else "pass", ".env 公开部署安全配置",
|
||||
"存在应检查的非安全设置(仅字段名):" + ", ".join(bad) if bad else "未发现显式关闭安全 Cookie 的配置;这并非完整安全审计",
|
||||
"公网环境启用 SITEFORGE_COOKIE_SECURE=true 并确认 HTTPS" if bad else "")
|
||||
except OSError:
|
||||
r.add("repo.env_security", "repository", "warn", ".env 配置读取", "读取失败")
|
||||
else:
|
||||
r.add("repo.env_security", "repository", "skip", ".env 安全配置", "缺少 .env;不打印或记录凭据")
|
||||
|
||||
def _local_host(url: str) -> bool:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
|
||||
return False
|
||||
if parsed.hostname == "localhost":
|
||||
return True
|
||||
try:
|
||||
return ipaddress.ip_address(parsed.hostname).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
def _probe(url: str, limit: int = 256 * 1024) -> tuple[int, str, str]:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "SiteForgeAcceptance/8.17", "Accept": "application/json,text/html"})
|
||||
op = urllib.request.build_opener(NoRedirect)
|
||||
try:
|
||||
with op.open(req, timeout=4) as resp:
|
||||
body = resp.read(limit + 1)
|
||||
if len(body) > limit:
|
||||
return resp.status, "", "响应超过上限"
|
||||
return resp.status, body.decode("utf-8", errors="replace"), ""
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, "", "返回非 2xx 或重定向"
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError):
|
||||
return 0, "", "无法连接或请求超时"
|
||||
|
||||
def audit_http(r: Report, base: str, allow_remote: bool):
|
||||
p = urllib.parse.urlsplit(base)
|
||||
if not _local_host(base) and not allow_remote:
|
||||
r.add("http.scope", "http", "fail", "检查目标授权范围",
|
||||
"默认仅允许 localhost / 回环地址", "外部已授权的测试域名需要明确传入 --allow-remote")
|
||||
return
|
||||
if p.scheme not in ("http", "https") or not p.netloc or p.query or p.fragment or p.username or p.password:
|
||||
r.add("http.scope", "http", "fail", "检查目标 URL", "URL 无效或包含不允许的参数")
|
||||
return
|
||||
root = f"{p.scheme}://{p.netloc}" + p.path.rstrip("/")
|
||||
r.add("http.scope", "http", "pass", "检查目标授权范围", "只读取健康接口,不提交数据")
|
||||
for endpoint in ("/api/health", "/api/ready"):
|
||||
status, body, error = _probe(root + endpoint)
|
||||
label = "存活探针" if endpoint.endswith("health") else "就绪探针(数据库与存储)"
|
||||
key = "http.health" if endpoint.endswith("health") else "http.ready"
|
||||
result = "pass" if status == 200 and not error else "fail"
|
||||
detail = f"HTTP {status or '不可用'}"
|
||||
if body:
|
||||
try:
|
||||
j = json.loads(body)
|
||||
detail += "; response.status=" + str(j.get("status", "未提供"))[:36]
|
||||
if isinstance(j.get("status"), str) and j["status"].lower() in ("unavailable", "error", "failed"):
|
||||
result = "fail"
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
detail += "; 非 JSON 响应"
|
||||
result = "fail"
|
||||
if error: detail += "; " + error
|
||||
r.add(key, "http", result, label, detail, "核对 Web/DB/data 卷状态及日志;不得以 /api/health 代替 /api/ready" if result == "fail" else "")
|
||||
status, _, error = _probe(root + "/", limit=1024 * 1024)
|
||||
r.add("http.entry", "http", "pass" if status in (200, 301, 302, 303, 307, 308) else "warn",
|
||||
"后台入口可访问", f"HTTP {status or '不可用'}" + (f";{error}" if error else ""), "检查反向代理、登录页和静态资源" if status not in (200, 301, 302, 303, 307, 308) else "")
|
||||
|
||||
def audit_docker(r: Report, repo: Path, compose_file: str):
|
||||
if not shutil.which("docker"):
|
||||
r.add("docker.executable", "docker", "skip", "Docker CLI 可用", "本机未发现 docker 命令")
|
||||
return
|
||||
cmd = ["docker", "compose"]
|
||||
if compose_file: cmd += ["-f", compose_file]
|
||||
try:
|
||||
# No `config` output: resolved Compose may contain environment secrets.
|
||||
check = subprocess.run(cmd + ["config", "-q"], cwd=repo, capture_output=True, timeout=15)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
r.add("docker.compose_config", "docker", "fail", "Compose 配置可解析", "无法执行命令或超时")
|
||||
return
|
||||
r.add("docker.compose_config", "docker", "pass" if check.returncode == 0 else "fail", "Compose 配置可解析",
|
||||
"退出码 " + str(check.returncode) + "(敏感输出已隐藏)")
|
||||
if check.returncode: return
|
||||
try:
|
||||
ps = subprocess.run(cmd + ["ps", "--format", "json"], cwd=repo, capture_output=True, timeout=15)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
r.add("docker.compose_ps", "docker", "fail", "容器状态检查", "无法执行命令或超时")
|
||||
return
|
||||
if ps.returncode:
|
||||
r.add("docker.compose_ps", "docker", "fail", "容器状态检查", "Compose ps 失败(原始错误已隐藏)")
|
||||
return
|
||||
raw = ps.stdout.decode("utf-8", errors="replace").strip()
|
||||
records = []
|
||||
try:
|
||||
payload = json.loads(raw or "[]")
|
||||
records = payload if isinstance(payload, list) else [payload]
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
records = [json.loads(x) for x in raw.splitlines() if x.strip()]
|
||||
except json.JSONDecodeError:
|
||||
r.add("docker.compose_ps", "docker", "warn", "容器状态检查", "无法解析 Compose JSON 版本格式")
|
||||
return
|
||||
if not records:
|
||||
r.add("docker.compose_ps", "docker", "fail", "容器已启动", "未发现运行服务")
|
||||
return
|
||||
for rec in records:
|
||||
service = str(rec.get("Service") or rec.get("Name") or "unknown")[:64]
|
||||
state = str(rec.get("State", "unknown")).lower()
|
||||
health = str(rec.get("Health", "")).lower()
|
||||
ok = state == "running" and health in ("", "healthy", "none")
|
||||
r.add("docker.service." + re.sub(r"[^a-z0-9_-]", "_", service.lower()), "docker", "pass" if ok else "fail",
|
||||
f"服务 {service} 运行及健康", f"state={state}; health={health or '未报告'}")
|
||||
if not any("worker" in str(x.get("Service", "")).lower() for x in records):
|
||||
r.add("docker.worker", "docker", "warn", "独立 Worker 服务", "ps 中未找到名称含 worker 的服务", "核对企业 Compose 服务命名及独立进程")
|
||||
|
||||
def audit_postgres(r: Report):
|
||||
dsn = os.getenv("SITEFORGE_DATABASE_URL", "")
|
||||
if not dsn.lower().startswith(("postgresql://", "postgres://")):
|
||||
r.add("postgres.config", "postgres", "skip", "PostgreSQL 真实只读检查", "未配置 PostgreSQL DSN 环境变量")
|
||||
return
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
r.add("postgres.driver", "postgres", "skip", "PostgreSQL 驱动", "未安装 psycopg(不进行写入测试)")
|
||||
return
|
||||
try:
|
||||
with psycopg.connect(dsn, connect_timeout=4, options="-c default_transaction_read_only=on") as con:
|
||||
with con.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
good = cur.fetchone()[0] == 1
|
||||
con.rollback()
|
||||
r.add("postgres.query", "postgres", "pass" if good else "fail", "PostgreSQL 只读连接 SELECT 1",
|
||||
"查询成功(凭据/地址均不写入报告)" if good else "返回结果异常")
|
||||
except Exception:
|
||||
r.add("postgres.query", "postgres", "fail", "PostgreSQL 只读连接 SELECT 1",
|
||||
"连接或查询失败(异常详情已隐藏,避免泄露 DSN)", "核对隔离环境、网络、TLS、只读账号与连接设置")
|
||||
|
||||
def audit_redis(r: Report):
|
||||
url = os.getenv("SITEFORGE_REDIS_URL", "")
|
||||
if not url.lower().startswith(("redis://", "rediss://")):
|
||||
r.add("redis.config", "redis", "skip", "Redis 真实只读检查", "未配置 SITEFORGE_REDIS_URL")
|
||||
return
|
||||
try: import redis
|
||||
except ImportError:
|
||||
r.add("redis.driver", "redis", "skip", "Redis 驱动", "未安装 redis(不进行写入测试)")
|
||||
return
|
||||
try:
|
||||
client = redis.Redis.from_url(url, socket_connect_timeout=4, socket_timeout=4)
|
||||
good = bool(client.ping())
|
||||
client.close()
|
||||
r.add("redis.ping", "redis", "pass" if good else "fail", "Redis PING 连接", "PING 成功" if good else "PING 响应异常")
|
||||
except Exception:
|
||||
r.add("redis.ping", "redis", "fail", "Redis PING 连接", "连接失败(异常详情已隐藏)", "核对 Redis 凭据、TLS、DNS、防火墙")
|
||||
|
||||
class Analyzer(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.lang = ""
|
||||
self.canonicals = []
|
||||
self.links = []
|
||||
self.hreflangs = []
|
||||
def handle_starttag(self, tag, attrs):
|
||||
a = dict(attrs)
|
||||
if tag == "html": self.lang = a.get("lang", "")
|
||||
if tag == "a" and a.get("href"): self.links.append(a["href"])
|
||||
if tag == "link":
|
||||
rel = (a.get("rel") or "").lower().split()
|
||||
if "canonical" in rel: self.canonicals.append(a.get("href", ""))
|
||||
if "alternate" in rel and a.get("hreflang"): self.hreflangs.append((a["hreflang"], a.get("href", "")))
|
||||
|
||||
def normalized_route(raw: str) -> str | None:
|
||||
s = (raw or "").strip()
|
||||
if not s or s.startswith("#"): return None
|
||||
if not s.startswith("/"): return None
|
||||
p = urllib.parse.urlsplit(s)
|
||||
if p.query or p.fragment: return None
|
||||
un = urllib.parse.unquote(p.path)
|
||||
if "\\" in un or "\x00" in un or any(x in ("..", ".") for x in un.split("/")):
|
||||
return None
|
||||
if "//" in un: return None
|
||||
return un
|
||||
|
||||
def path_for_route(build: Path, route: str) -> Path | None:
|
||||
if route == "/": return build / "index.html"
|
||||
rel = route.lstrip("/")
|
||||
p = build / rel / "index.html" if route.endswith("/") else build / rel
|
||||
if p.suffix == "": p = build / rel / "index.html"
|
||||
# resolve may traverse symlinks - do not open files outside build root
|
||||
root = build.resolve()
|
||||
actual = p.resolve()
|
||||
if not actual.is_relative_to(root): return None
|
||||
if p.is_dir(): p = p / "index.html"
|
||||
return p
|
||||
|
||||
def audit_build(r: Report, build: Path, baseline: Path | None):
|
||||
if not build.is_dir():
|
||||
r.add("build.root", "build", "fail", "静态构建目录", "构建目录不存在")
|
||||
return
|
||||
files = sorted(build.rglob("*.html"))
|
||||
if not files:
|
||||
r.add("build.html", "build", "fail", "已生成 HTML 页面", "未找到 HTML 文件")
|
||||
return
|
||||
r.add("build.html", "build", "pass", "已生成 HTML 页面", f"发现 {len(files)} 个 HTML 文件")
|
||||
missing_lang, bad_canon, dup_canon, missing_internal, bad_hreflang = [], [], [], [], []
|
||||
scanned = 0
|
||||
for f in files[:10000]:
|
||||
if f.is_symlink() or not f.resolve().is_relative_to(build.resolve()): continue
|
||||
try:
|
||||
a = Analyzer()
|
||||
a.feed(f.read_text(encoding="utf-8", errors="replace"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
scanned += 1
|
||||
rel = str(f.relative_to(build))
|
||||
if not a.lang.strip(): missing_lang.append(rel)
|
||||
if len(a.canonicals) > 1: dup_canon.append(rel)
|
||||
for c in a.canonicals:
|
||||
if not c.startswith("https://"): bad_canon.append(rel)
|
||||
for lang, href in a.hreflangs:
|
||||
if not re.fullmatch(r"[a-zA-Z]{2,8}(?:-[a-zA-Z0-9]{2,8})*|x-default", lang) or not href.startswith("https://"):
|
||||
bad_hreflang.append(rel)
|
||||
for link in a.links:
|
||||
# SiteForge static output may include absolute route links. Do not resolve
|
||||
# /preview/... against build output; those are environment-specific.
|
||||
route = normalized_route(link)
|
||||
if route is not None:
|
||||
target = path_for_route(build, route)
|
||||
if target is not None and not target.is_file():
|
||||
missing_internal.append(f"{rel} -> {route}")
|
||||
if len(files) > 10000:
|
||||
r.add("build.scan_limit", "build", "warn", "扫描 HTML 数量", "超过 10000;仅扫描前 10000 个")
|
||||
for code, label, values, severity, solution in [
|
||||
("build.lang", "HTML lang 属性", missing_lang, "warn", "为多语言页面明确设置 HTML lang"),
|
||||
("build.canonical_duplicate", "同页 canonical 唯一", dup_canon, "fail", "一页只能存在一条 canonical"),
|
||||
("build.canonical_scheme", "绝对 HTTPS canonical", bad_canon, "warn", "生产域名配置完成后重新构建"),
|
||||
("build.hreflang", "hreflang 语言标记与 HTTPS 地址", bad_hreflang, "warn", "只为已发布且审核过的翻译输出 alternate"),
|
||||
("build.internal_links", "站内链接对应静态文件", missing_internal, "fail", "修复失效路径或添加正确重定向")]:
|
||||
r.add(code, "build", severity if values else "pass", label,
|
||||
f"问题 {len(values)} 项;样例:" + "; ".join(values[:6]) if values else f"扫描 {scanned} 个文件", solution if values else "")
|
||||
sitemap = build / "sitemap.xml"
|
||||
r.add("build.sitemap", "build", "pass" if sitemap.is_file() else "warn", "Sitemap 文件", "存在" if sitemap.is_file() else "不存在")
|
||||
robots = build / "robots.txt"
|
||||
r.add("build.robots", "build", "pass" if robots.is_file() else "warn", "robots.txt", "存在" if robots.is_file() else "不存在")
|
||||
if baseline is not None:
|
||||
if not baseline.is_file():
|
||||
r.add("build.url_baseline", "build", "fail", "旧站 URL 清单", "基线文件不存在")
|
||||
else:
|
||||
routes, invalid = set(), []
|
||||
for idx, s in enumerate(baseline.read_text(encoding="utf-8-sig").splitlines(), 1):
|
||||
if not s.strip() or s.lstrip().startswith("#"): continue
|
||||
route = normalized_route(s)
|
||||
if route is None: invalid.append(idx)
|
||||
else: routes.add(route)
|
||||
misses = sorted(path for path in routes if not (path_for_route(build, path) or Path("/__invalid__")).is_file())
|
||||
r.add("build.url_baseline", "build", "fail" if invalid or misses else "pass", "旧 URL 1:1 路径覆盖",
|
||||
f"清单 {len(routes)} 条,缺失 {len(misses)} 条,非法输入行 {len(invalid)} 条;缺失样例 " + ", ".join(misses[:8]),
|
||||
"不要静默更改旧站 SEO 路径;缺失 URL 必须修复或提供已审核 301 映射" if misses or invalid else "")
|
||||
|
||||
def release_gate(r: Report, evidence: Path | None):
|
||||
# The kit NEVER gives a pass for production readiness without signed-off evidence.
|
||||
required = ["isolated_pg", "redis_ping", "docker_health", "backup_restore", "browser_smoke",
|
||||
"wordpress_url_parity", "staging_deploy", "rollback", "production_approval", "art_design_pro"]
|
||||
if evidence is None:
|
||||
r.add("release.evidence", "release", "skip", "正式发布证据表", "未提供 --evidence;不会宣称生产验收完成")
|
||||
return
|
||||
if not evidence.is_file():
|
||||
r.add("release.evidence", "release", "fail", "正式发布证据表", "找不到 evidence JSON")
|
||||
return
|
||||
try:
|
||||
obj = json.loads(evidence.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
r.add("release.evidence", "release", "fail", "正式发布证据表", "不是可解析 JSON")
|
||||
return
|
||||
for key in required:
|
||||
val = obj.get(key)
|
||||
# Validate dates/links/owners, not merely booleans.
|
||||
valid = isinstance(val, dict) and val.get("status") == "passed" and bool(val.get("operator")) and bool(val.get("date")) and bool(val.get("evidence"))
|
||||
r.add("release." + key, "release", "pass" if valid else "warn", f"签字验收:{key}",
|
||||
"证据字段完整(不验证证据真实性)" if valid else "尚缺 passed/operator/date/evidence 字段",
|
||||
"填入真实执行记录,未通过时禁止正式切流" if not valid else "")
|
||||
|
||||
def markdown(data: dict) -> str:
|
||||
summ = data["summary"]
|
||||
result = ["# SiteForge V8.18 只读验收报告", "", f"检查日期:{data['generated_at']}",
|
||||
"", "**本工具不能证明完整生产上线。** 只执行只读探针和静态检查;人工证据需二次核实。", "",
|
||||
f"检查结果:PASS {summ['pass']} / FAIL {summ['fail']} / WARN {summ['warn']} / SKIP {summ['skip']}", "",
|
||||
"| 检查项 | 分类 | 结果 | 详情 |", "|---|---|---|---|"]
|
||||
def clean(s): return str(s).replace("|", "\\|").replace("\n", " ")
|
||||
for c in data["checks"]:
|
||||
result.append(f"| {clean(c['description'])} | {clean(CATEGORIES.get(c['category'], c['category']))} | {c['status'].upper()} | {clean(c['detail'])} |")
|
||||
result += ["", "## 修复清单", ""]
|
||||
issues = [c for c in data["checks"] if c["status"] in ("fail", "warn")]
|
||||
result += [f"- **{c['code']}**:{c['remediation'] or c['description']}" for c in issues] or ["- 没有发现 FAIL / WARN;仍需核实未执行的外部验收。"]
|
||||
return "\n".join(result) + "\n"
|
||||
|
||||
STYLE = """body{font:14px/1.6 system-ui,-apple-system,'Microsoft YaHei',sans-serif;margin:0;background:#f4f6fa;color:#202b3d}header{padding:28px 4%;background:#14243d;color:white}header h1{margin:0;font-size:25px}main{max-width:1180px;margin:auto;padding:24px}.muted{color:#64748b}.panel{background:white;border:1px solid #e2e7f0;border-radius:14px;padding:22px;margin-bottom:20px}.cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.metric{background:white;border-radius:12px;border:1px solid #e2e7f0;padding:16px}.metric strong{display:block;font-size:29px}.tools{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0}button{cursor:pointer;background:#fff;border:1px solid #ccd5e2;padding:9px 14px;border-radius:9px}button[aria-pressed=true]{background:#ff6a2b;color:white;border-color:#ff6a2b}.item{padding:13px 0;border-bottom:1px solid #edf0f4;display:flex;gap:12px;align-items:baseline}.tag{font-size:11px;border-radius:5px;padding:2px 7px;min-width:42px;text-align:center;display:inline-block;background:#e5e7eb}.pass{background:#dcfce7;color:#166534}.fail{background:#fee2e2;color:#991b1b}.warn{background:#fef3c7;color:#92400e}.skip{background:#e2e8f0;color:#475569}.description{font-weight:650}.detail{color:#64748b;font-size:13px}.secondary{margin-left:auto;color:#9ca3af}footer{padding:20px;text-align:center;color:#64748b}@media(max-width:660px){.cards{grid-template-columns:repeat(2,minmax(0,1fr))}.item{display:block}.secondary{display:none}}"""
|
||||
|
||||
def html_report(data: dict) -> str:
|
||||
cards = "".join(f'<div class="metric"><div>{k.upper()}</div><strong>{data["summary"][k]}</strong></div>' for k in ("pass", "fail", "warn", "skip"))
|
||||
rows = []
|
||||
for c in data["checks"]:
|
||||
e = lambda s: html.escape(str(s), quote=True)
|
||||
rows.append(f'<div class="item" data-status="{e(c["status"])}"><span class="tag {e(c["status"])}">{e(c["status"].upper())}</span><div><div class="description">{e(c["description"])}</div><div class="detail">{e(c["detail"])}</div>' + (f'<div class="detail">建议:{e(c["remediation"])}</div>' if c["remediation"] else "") + f'</div><span class="secondary">{e(c["code"])}</span></div>')
|
||||
return ("<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>SiteForge · 验收中心</title><style>" + STYLE + "</style></head><body>"
|
||||
'<header><h1>SiteForge · 生产验收中心</h1><div>V8.18 Read-only Acceptance Kit · 生成时间:' + html.escape(data["generated_at"]) + '</div></header>'
|
||||
'<main><section class="cards">' + cards + '</section><section class="panel"><h2>检查结论</h2><p>本报告不会将跳过项、模拟测试或人工登记直接视为生产通过。正式发布必须有真实数据库、Docker、浏览器和回滚证据。</p><div class="tools"><button aria-pressed="true" data-filter="all">全部</button><button aria-pressed="false" data-filter="fail">失败</button><button aria-pressed="false" data-filter="warn">警告</button><button aria-pressed="false" data-filter="skip">待验收</button><button aria-pressed="false" data-filter="pass">通过</button></div><div id="checks">' + "".join(rows) + '</div></section></main><footer>该页面离线可查看,不上传任何诊断数据。</footer><script>document.querySelectorAll("button[data-filter]").forEach(b=>b.addEventListener("click",()=>{document.querySelectorAll("button[data-filter]").forEach(x=>x.setAttribute("aria-pressed",String(x===b)));document.querySelectorAll(".item[data-status]").forEach(x=>x.hidden=b.dataset.filter!=="all"&&x.dataset.status!==b.dataset.filter)}));</script></body></html>')
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description="SiteForge V8.18 non-destructive acceptance toolkit")
|
||||
ap.add_argument("--repo", type=Path, help="SiteForge 源码根目录")
|
||||
ap.add_argument("--url", help="只读 HTTP 健康检查 URL;默认仅支持 loopback")
|
||||
ap.add_argument("--allow-remote", action="store_true", help="明确允许访问已授权远端测试目标")
|
||||
ap.add_argument("--docker", action="store_true", help="运行只读 docker compose 命令")
|
||||
ap.add_argument("--compose-file", default="compose.enterprise.yaml", help="默认企业 Compose 文件")
|
||||
ap.add_argument("--postgres", action="store_true", help="只执行 PostgreSQL SELECT 1")
|
||||
ap.add_argument("--redis", action="store_true", help="只执行 Redis PING")
|
||||
ap.add_argument("--build-dir", type=Path, help="检查已生成的静态站目录")
|
||||
ap.add_argument("--url-baseline", type=Path, help="旧站 URL 路径清单 TXT")
|
||||
ap.add_argument("--evidence", type=Path, help="人工签字验收证据 JSON")
|
||||
ap.add_argument("--output", type=Path, default=Path("siteforge-audit"), help="报告文件前缀")
|
||||
args = ap.parse_args(argv)
|
||||
r = Report(args)
|
||||
if not any((args.repo, args.url, args.docker, args.postgres, args.redis, args.build_dir, args.evidence)):
|
||||
ap.error("至少选择一个检查目标,例如 --repo . --docker 或 --url http://127.0.0.1:8787")
|
||||
if args.repo: audit_repo(r, args.repo)
|
||||
if args.url: audit_http(r, args.url, args.allow_remote)
|
||||
if args.docker: audit_docker(r, args.repo or Path.cwd(), args.compose_file)
|
||||
if args.postgres: audit_postgres(r)
|
||||
if args.redis: audit_redis(r)
|
||||
if args.build_dir: audit_build(r, args.build_dir, args.url_baseline)
|
||||
if args.url_baseline and not args.build_dir:
|
||||
r.add("build.baseline_without_build", "build", "warn", "旧站 URL 路径基线", "必须同时传入 --build-dir")
|
||||
if args.evidence: release_gate(r, args.evidence)
|
||||
else: release_gate(r, None)
|
||||
data = r.data()
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.with_suffix(".json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
args.output.with_suffix(".md").write_text(markdown(data), encoding="utf-8")
|
||||
args.output.with_suffix(".html").write_text(html_report(data), encoding="utf-8")
|
||||
print(f"PASS={data['summary']['pass']} FAIL={data['summary']['fail']} WARN={data['summary']['warn']} SKIP={data['summary']['skip']}")
|
||||
print("报告已写入:", args.output.with_suffix(".json"), args.output.with_suffix(".md"), args.output.with_suffix(".html"))
|
||||
return 2 if data["summary"]["fail"] else 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Verify an installed Art Design Pro checkout and optionally run its real build.
|
||||
|
||||
Requires the USER's official checkout. A fixture-only installer test never
|
||||
counts as a successful official Vue build.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
if str(Path(__file__).resolve().parents[1]) not in sys.path:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from scripts.install_into_art_design_pro import install
|
||||
|
||||
|
||||
def verify(path: Path, build: bool = False) -> dict:
|
||||
root = path.expanduser().resolve()
|
||||
result = install(root, verify=True)
|
||||
manifest = root/'src/views/siteforge/.siteforge-v819.json'
|
||||
if not manifest.is_file():
|
||||
raise ValueError('安装清单缺失,请执行安装器 --apply')
|
||||
data = json.loads(manifest.read_text(encoding='utf-8'))
|
||||
if data.get('siteforge_version') != '8.21.3-dev.1':
|
||||
raise ValueError('安装清单版本不匹配')
|
||||
result.update({'version':data['siteforge_version'],'build':'NOT_RUN'})
|
||||
if build:
|
||||
if not (root/'node_modules').is_dir():
|
||||
raise ValueError('缺少 Art Design Pro 官方 npm 依赖;在官方工程先运行 pnpm install --frozen-lockfile')
|
||||
command = ['pnpm','build']
|
||||
try:
|
||||
out = subprocess.run(command, cwd=root, check=False, capture_output=True,
|
||||
text=True, timeout=300)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise ValueError('真实构建无法完成:'+str(exc)) from exc
|
||||
if out.returncode:
|
||||
raise ValueError(f'pnpm build 失败(退出码 {out.returncode}):'+out.stderr[-2000:])
|
||||
result['build']='PASS'
|
||||
result['build_stdout_tail']=out.stdout[-1000:]
|
||||
return result
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('official_repo',type=Path)
|
||||
parser.add_argument('--build',action='store_true',help='有官方源码和 node_modules 后执行真实 pnpm build')
|
||||
parser.add_argument('--report',type=Path)
|
||||
a=parser.parse_args()
|
||||
try:
|
||||
result=verify(a.official_repo,build=a.build)
|
||||
if a.report:
|
||||
a.report.parent.mkdir(parents=True,exist_ok=True)
|
||||
a.report.write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False,indent=2))
|
||||
except ValueError as exc:
|
||||
print('BLOCKED/FAILED: '+str(exc),file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""REAL pg_dump -> checksum -> pg_restore into dedicated empty DB CI smoke.
|
||||
|
||||
Must only run in isolated disposable CI PG server as administrator and with
|
||||
SITEFORGE_ALLOW_PG_DR_SMOKE=1. Do NOT point at any production database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
# Allow direct `python scripts/name.py` from a fresh source checkout.
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from app import db, pg_compat, pg_disaster_recovery as disaster
|
||||
|
||||
src_url = os.environ.get('SITEFORGE_DATABASE_URL','')
|
||||
parsed=urlsplit(src_url)
|
||||
src_name=parsed.path.lstrip('/')
|
||||
if (os.environ.get('SITEFORGE_ALLOW_PG_DR_SMOKE')!='1' or not db.POSTGRES
|
||||
or not re.fullmatch('[a-z][a-z0-9_]*_ci',src_name)):
|
||||
raise SystemExit('Requires isolated PG _ci database and explicit DR-smoke flag')
|
||||
|
||||
dest_name=src_name[:-3]+'_restore_ci'
|
||||
dest_url=urlunsplit((parsed.scheme,parsed.netloc,'/'+dest_name,parsed.query,parsed.fragment))
|
||||
env=disaster.connection_env(src_url)
|
||||
marker='restore-check-'+uuid.uuid4().hex[:8]
|
||||
created=False
|
||||
try:
|
||||
with db.connect() as conn:
|
||||
conn.execute('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',
|
||||
(marker,'Backup Restore Sample',db.now(),db.now()))
|
||||
# pg_dump is a snapshot, but cross-volume coherence still requires no writers;
|
||||
# CI runs in isolation and uses a private data directory.
|
||||
with tempfile.TemporaryDirectory(prefix='sf-v818-pg-dr-') as root:
|
||||
root=Path(root)
|
||||
data=root/'original';asset=data/'media'/marker/'file.txt'
|
||||
asset.parent.mkdir(parents=True)
|
||||
asset.write_text('SiteForge backup fixture',encoding='utf-8')
|
||||
archive=root/'backup.zip'
|
||||
disaster.backup(src_url,data,archive,confirmed_offline=True)
|
||||
assert disaster.verify(archive)['files']['database.dump']['size']>0
|
||||
# Admin must have CREATE DATABASE in this isolated CI service.
|
||||
disaster.run_pg(['psql','--no-psqlrc','--command',f'CREATE DATABASE {dest_name}'],env,40)
|
||||
created=True
|
||||
new_storage=root/'restored'
|
||||
disaster.restore_new(dest_url,archive,new_storage,confirmed_offline=True)
|
||||
assert (new_storage/'media'/marker/'file.txt').read_text(encoding='utf-8')=='SiteForge backup fixture'
|
||||
with pg_compat.connect(dest_url) as conn:
|
||||
row=conn.execute('SELECT name FROM sites WHERE id=?',(marker,)).fetchone()
|
||||
assert row and row['name']=='Backup Restore Sample'
|
||||
print('PASS: Real PG dump, manifest integrity, restore to empty DB and media recovery')
|
||||
finally:
|
||||
with db.connect() as conn:
|
||||
conn.execute('DELETE FROM sites WHERE id=?',(marker,))
|
||||
if created:
|
||||
disaster.run_pg(['psql','--no-psqlrc','--command',f'DROP DATABASE {dest_name} WITH (FORCE)'],env,40)
|
||||
@@ -1,20 +1,79 @@
|
||||
"""PostgreSQL bootstrap smoke: execute only in a throwaway database."""
|
||||
import os
|
||||
if not os.getenv('SITEFORGE_DATABASE_URL','').startswith(('postgresql://','postgres://')):
|
||||
raise SystemExit('Please set SITEFORGE_DATABASE_URL to a disposable PostgreSQL database')
|
||||
from app import db,enterprise,factory_api,migration_audit,reusables,release_api,jobs
|
||||
from app.core import save_entry
|
||||
import uuid
|
||||
"""Full PostgreSQL integration smoke test. Requires a disposable PostgreSQL DB.
|
||||
|
||||
db.init();enterprise.setup();factory_api.setup();migration_audit.setup();reusables.setup();release_api.setup()
|
||||
sid=uuid.uuid4().hex[:16]
|
||||
with db.connect() as c:
|
||||
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',(sid,'PG Smoke','','2026-01-01','2026-01-01'))
|
||||
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
(uuid.uuid4().hex[:16],sid,'page','Home','/','home','2026-01-01','2026-01-01'))
|
||||
assert db.one('SELECT id FROM sites WHERE id=?',(sid,))['id']==sid
|
||||
job=jobs.submit(sid,{'mirror_images':False},'pg-smoke','pg-smoke-unique-0001')
|
||||
assert job['state']=='queued'
|
||||
assert jobs.cancel(sid,job['id'])['state']=='cancelled'
|
||||
with db.connect() as c:c.execute('DELETE FROM sites WHERE id=?',(sid,))
|
||||
print('PostgreSQL schema, CRUD, job submit, cancel smoke passed')
|
||||
Run only in CI or a dedicated empty database: creates tables and temporary
|
||||
site/jobs, and validates actual DB API compatibility and worker claims.
|
||||
"""
|
||||
# Allow direct `python scripts/name.py` from a fresh source checkout.
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
|
||||
import os
|
||||
import uuid
|
||||
url=os.getenv('SITEFORGE_DATABASE_URL','')
|
||||
if not url.startswith(('postgresql://','postgres://')):
|
||||
raise SystemExit('Set SITEFORGE_DATABASE_URL to a DISPOSABLE PostgreSQL database')
|
||||
from app import (db,enterprise,factory_api,migration_audit,reusables,release_api,
|
||||
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
|
||||
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,
|
||||
site_design_v87,multilingual,jobs,worker)
|
||||
|
||||
# Mirror app.main.lifespan bootstrap so CI will discover any broken DDL/UPSERT.
|
||||
db.init()
|
||||
for module in (enterprise,factory_api,migration_audit,reusables,release_api,
|
||||
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
|
||||
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,
|
||||
site_design_v87,multilingual):
|
||||
module.setup()
|
||||
assert db.one('SELECT 1 AS ready')['ready']==1
|
||||
assert db.one('SELECT 1 AS ready')['ready']==1
|
||||
ledger=db.one('SELECT version FROM schema_migrations WHERE version=?',('20261022_001_p1_limits',))
|
||||
assert ledger,'PostgreSQL migration ledger not created'
|
||||
sid='pg-'+uuid.uuid4().hex[:12]
|
||||
job_id=None
|
||||
sid2=sid+"-second"
|
||||
try:
|
||||
with db.connect() as c:
|
||||
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',
|
||||
(sid,'PG Smoke','','2026-01-01','2026-01-01'))
|
||||
c.execute('INSERT INTO sites(id,name,domain,created_at,updated_at) VALUES (?,?,?,?,?)',
|
||||
(sid2,'PG Smoke Second','','2026-01-01','2026-01-01'))
|
||||
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
(uuid.uuid4().hex[:16],sid,'page','Home','/','home','2026-01-01','2026-01-01'))
|
||||
assert db.one('SELECT id FROM sites WHERE id=?',(sid,))['id']==sid
|
||||
job=jobs.submit(sid,{'mirror_images':False},'pg-smoke','pg-smoke-'+uuid.uuid4().hex[:14])
|
||||
job_id=job['id']
|
||||
# PostgreSQL claim exercises FOR UPDATE SKIP LOCKED and numeric COUNT rows.
|
||||
claimed=worker.claim()
|
||||
assert claimed and claimed['id']==job_id and claimed['state']=='running',claimed
|
||||
assert worker.claim() is None,'Same-site job was claimed twice'
|
||||
worker.complete(claimed,{'ok':True})
|
||||
assert db.one('SELECT state FROM build_jobs WHERE id=?',(job_id,))['state']=='done'
|
||||
job2=jobs.submit(sid,{'mirror_images':True},'pg-smoke','pg-smoke-'+uuid.uuid4().hex[:14])
|
||||
assert jobs.cancel(sid,job2['id'])['state']=='cancelled'
|
||||
# Independent connections racing for work must never receive the same job.
|
||||
import threading
|
||||
keys=[('pg-claim-a',sid),('pg-claim-b',sid2)]
|
||||
submitted=[]
|
||||
for label,site in keys:
|
||||
submitted.append(jobs.submit(site,{'mirror_images':site==sid},'pg-smoke',
|
||||
label+'-'+uuid.uuid4().hex[:10])['id'])
|
||||
barrier=threading.Barrier(2)
|
||||
claimed=[]; failures=[]
|
||||
def runner():
|
||||
try:
|
||||
barrier.wait(timeout=8)
|
||||
claimed.append(worker.claim())
|
||||
except Exception as ex:failures.append(ex)
|
||||
threads=[threading.Thread(target=runner) for _ in range(2)]
|
||||
for thread in threads:thread.start()
|
||||
for thread in threads:thread.join(timeout=12)
|
||||
assert not any(t.is_alive() for t in threads),'Concurrent PG claim deadlock'
|
||||
assert not failures,failures
|
||||
ids={item['id'] for item in claimed if item}
|
||||
assert ids==set(submitted),'Concurrent claims were duplicated or lost'
|
||||
for item in claimed:worker.complete(item,{'ok':True})
|
||||
print('PASS: PostgreSQL schemas, ledger, CRUD, queue claim/complete/cancel, two concurrent claims')
|
||||
finally:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM sites WHERE id=?',(sid,))
|
||||
c.execute('DELETE FROM sites WHERE id=?',(sid2,))
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional real Redis wake-up smoke; external Redis required."""
|
||||
# Allow direct `python scripts/name.py` from a fresh source checkout.
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
|
||||
import os
|
||||
import time
|
||||
from app import redis_signal
|
||||
|
||||
if not os.environ.get('SITEFORGE_REDIS_URL'):
|
||||
raise SystemExit('Set SITEFORGE_REDIS_URL for real Redis smoke')
|
||||
if not redis_signal.notify():
|
||||
raise SystemExit('Redis notification failed')
|
||||
start=time.monotonic()
|
||||
redis_signal.wait(0.1)
|
||||
assert time.monotonic()-start < 4, 'Wakeup not consumed promptly'
|
||||
print('PASS: Redis notification and worker wait against configured Redis')
|
||||
@@ -6,7 +6,7 @@ import sys
|
||||
ROOT=Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0,str(ROOT))
|
||||
from app.version import VERSION
|
||||
assert VERSION=='8.12.0-dev.1'
|
||||
assert VERSION=='8.21.3-dev.1'
|
||||
api=(ROOT/'app'/'main.py').read_text(encoding='utf-8')
|
||||
assert 'return builder.build(' not in api, 'A synchronous request-thread build has reappeared'
|
||||
assert "@app.post('/api/sites/{site_id}/builds', status_code=202)" in api
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""REAL PostgreSQL data migration CI smoke with a disposable isolated target.
|
||||
|
||||
This script deliberately never runs on ambiguous production-looking DB names.
|
||||
Set SITEFORGE_ALLOW_PG_DATA_SMOKE=1 and database suffix _ci/_test.
|
||||
"""
|
||||
# Allow direct `python scripts/name.py` from a fresh source checkout.
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1]))
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote,urlsplit
|
||||
|
||||
from app import db
|
||||
from app.sqlite_pg_transfer import migrate
|
||||
from app.pg_disaster_recovery import RecoveryError
|
||||
|
||||
url = os.environ.get('SITEFORGE_DATABASE_URL','')
|
||||
name = unquote(urlsplit(url).path.lstrip('/'))
|
||||
if not db.POSTGRES or os.environ.get('SITEFORGE_ALLOW_PG_DATA_SMOKE')!='1' or not name.endswith(('_ci','_test')):
|
||||
raise SystemExit('Requires explicit CI-only PG smoke flag and _ci/_test database')
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='sf-v818-ci-') as temp:
|
||||
source=Path(temp)/'siteforge.sqlite3'
|
||||
env=os.environ.copy(); env.pop('SITEFORGE_DATABASE_URL',None)
|
||||
env['SITEFORGE_DATA_DIR']=temp
|
||||
env['SITEFORGE_DB_FILE']=str(source)
|
||||
marker='migration-smoke-'+uuid.uuid4().hex[:10]
|
||||
code=r'''from app import (db,enterprise,factory_api,migration_audit,reusables,release_api,
|
||||
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
|
||||
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual)
|
||||
import os
|
||||
assert not db.POSTGRES
|
||||
assert not os.path.exists(db.DB_FILE)
|
||||
db.init()
|
||||
for x in (enterprise,factory_api,migration_audit,reusables,release_api,
|
||||
cms_pipeline,v8_workflows,v8_templates,wp_migration_v81,wp_migration_v82,
|
||||
wp_migration_v83,connectors_v84,connectors_v85,studio_v86,site_design_v87,multilingual):x.setup()
|
||||
s=os.environ['V818_MARKER']
|
||||
with db.connect() as c:
|
||||
c.execute('INSERT INTO sites(id,name,created_at,updated_at) VALUES (?,?,?,?)',(s,'Migration Sample',db.now(),db.now()))
|
||||
for i in range(3):
|
||||
c.execute('INSERT INTO entries(id,site_id,type,title,route,slug,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
(s+'e'+str(i),s,'product','Migrated Car '+str(i),'/products/'+str(i)+'/','p'+str(i),db.now(),db.now()))
|
||||
c.execute('INSERT INTO redirects(id,site_id,old_path,new_path) VALUES (?,?,?,?)',(s+'r',s,'/old/','/products/0/'))
|
||||
'''
|
||||
env['V818_MARKER']=marker
|
||||
subprocess.run([sys.executable,'-c',code],check=True,env=env)
|
||||
try:
|
||||
result=migrate(source,confirmed_offline=True)
|
||||
assert result['verified'] and result['details']['entries']['rows']==3
|
||||
assert result['details']['redirects']['rows']==1
|
||||
assert db.one('SELECT COUNT(*) AS n FROM entries WHERE site_id=?',(marker,))['n']==3
|
||||
assert db.one('SELECT COUNT(*) AS n FROM redirects WHERE site_id=?',(marker,))['n']==1
|
||||
# Re-running must fail: no duplicate rows, no silent upsert.
|
||||
try:migrate(source,confirmed_offline=True)
|
||||
except RecoveryError as exc:
|
||||
assert 'not empty' in str(exc)
|
||||
else:raise AssertionError('Migration unexpectedly allowed nonempty target')
|
||||
print(f'PASS: actual PostgreSQL transfer {result["tables"]} tables, {result["records"]} records; rerun blocked')
|
||||
finally:
|
||||
with db.connect() as c:
|
||||
c.execute('DELETE FROM sites WHERE id=?',(marker,))
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline WordPress WXR preflight and optional static output URL coverage check."""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:sys.path.insert(0,str(ROOT))
|
||||
from app.wp_complex_v820 import inspect_wxr, MAX_XML
|
||||
from app.builder import output_path
|
||||
|
||||
|
||||
def inspect_file(source:Path,dist:Path|None=None):
|
||||
if source.stat().st_size>MAX_XML:raise ValueError('WXR 超过 16 MB,需分割后分别预检')
|
||||
result=inspect_wxr(source.read_bytes())
|
||||
if dist is not None:
|
||||
if not dist.is_dir():raise ValueError('静态构建输出目录不存在')
|
||||
# Every actual published URL must be matched. WXR preflight by design
|
||||
# never claims 100% coverage if it has invalid or duplicate routes.
|
||||
from defusedxml import ElementTree as ET
|
||||
root=ET.parse(source).getroot()
|
||||
routes=[]
|
||||
from app.core import route_path
|
||||
from urllib.parse import urlsplit
|
||||
for item in root.findall('./channel/item'):
|
||||
fields={c.tag.rsplit('}',1)[-1]:c.text or '' for c in item if c.tag.rsplit('}',1)[-1] in ('post_type','status','link')}
|
||||
if fields.get('post_type') in ('attachment','revision','nav_menu_item') or fields.get('status','publish')!='publish':continue
|
||||
p=urlsplit(fields.get('link',''))
|
||||
if p.scheme in ('http','https') and p.hostname and not p.query and not p.fragment and p.path:
|
||||
try:routes.append(route_path(p.path))
|
||||
except ValueError:pass
|
||||
missing=[route for route in sorted(set(routes)) if not output_path(dist,route).is_file()]
|
||||
result['static_build_coverage']={'expected_routes':len(set(routes)),'missing_count':len(missing),
|
||||
'missing_sample':missing[:100],
|
||||
'passed':len(missing)==0 and result['blocking_issues']==0}
|
||||
return result
|
||||
|
||||
|
||||
def render_html(report):
|
||||
rows=''.join('<tr><td>'+html.escape(x['severity'])+'</td><td>'+html.escape(x['code'])+
|
||||
'</td><td>'+html.escape(x['route'])+'</td><td>'+html.escape(x['message'])+'</td></tr>' for x in report['issues'])
|
||||
metrics=' '.join(f'<span><strong>{html.escape(str(k))}</strong>: {html.escape(str(v))}</span>' for k,v in report['counts'].items() if k in ('published_content','attachments','findings_blocker','findings_review'))
|
||||
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.20 WordPress 兼容预检</title><style>
|
||||
body{{font:14px/1.65 system-ui,"Microsoft YaHei",sans-serif;color:#192d43;background:#f3f6f9;margin:0}}
|
||||
header{{background:#193046;color:#fff;padding:32px 6%}}main{{max-width:1100px;margin:20px auto;padding:25px;background:#fff;border-radius:12px;overflow-x:auto}}
|
||||
header h1{{margin:0}}p{{color:#576679}}.metrics span{{display:inline-block;padding:10px;border:1px solid #e4e9f0;margin:4px;border-radius:7px}}
|
||||
table{{border-collapse:collapse;width:100%}}th,td{{padding:9px;text-align:left;border-bottom:1px solid #edf0f2;vertical-align:top}}tr:nth-child(even){{background:#f8fafc}}</style></head><body>
|
||||
<header><h1>SiteForge V8.20 · WordPress 兼容预检</h1><p style="color:#eee">离线分析,不连接原站,不修改已有站点</p></header><main><div class="metrics">{metrics}</div><h2>待处理问题</h2>
|
||||
<table><thead><tr><th>等级</th><th>问题类型</th><th>URL</th><th>处理说明</th></tr></thead><tbody>{rows}</tbody></table>
|
||||
<p>本报告不能证明 1:1 迁移、媒体本地化、动态功能、视觉一致性或正式发布验收通过。</p></main></body></html>'''
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description='离线分析 WordPress WXR 迁移风险与 URL 覆盖')
|
||||
parser.add_argument('wxr',type=Path)
|
||||
parser.add_argument('--dist',type=Path,help='可选:静态构建 dist 目录,检查对应 HTML 是否存在')
|
||||
parser.add_argument('--output',type=Path,default=Path('reports/v820-wxr-preflight.json'))
|
||||
args=parser.parse_args(argv)
|
||||
try:report=inspect_file(args.wxr,args.dist)
|
||||
except (OSError,ValueError,Exception) as exc:
|
||||
# A single fail-closed error message avoids a massive traceback for malformed input.
|
||||
print('无法完成 WXR 预检:'+str(exc)[:350],file=sys.stderr)
|
||||
return 2
|
||||
args.output.parent.mkdir(parents=True,exist_ok=True)
|
||||
args.output.write_text(json.dumps(report,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
args.output.with_suffix('.html').write_text(render_html(report),encoding='utf-8')
|
||||
print(f"WXR 预检:{report['unique_published_routes']} 个静态路由,{report['blocking_issues']} 个阻断问题;报告:{args.output}")
|
||||
if report['blocking_issues'] or report.get('static_build_coverage',{}).get('passed') is False:return 1
|
||||
return 0
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Creates a durable V8.18 evidence index from the tests already executed."""
|
||||
from __future__ import annotations
|
||||
import json,html,datetime
|
||||
from pathlib import Path
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
rep=root/'reports'
|
||||
r=json.loads((rep/'v818-regression-2rounds.json').read_text(encoding='utf-8'))
|
||||
a=json.loads((rep/'v818-readonly.json').read_text(encoding='utf-8'))
|
||||
http=(rep/'v818-http-e2e.log').read_text(encoding='utf-8')
|
||||
assert r['status']=='PASS' and r['repeats']==2 and not r['failures']
|
||||
assert (rep/'v818_two_rounds_exitcode').read_text().strip()=='0'
|
||||
assert 'PASS' in http
|
||||
runs=[x for x in r['runs'] if x['label'].startswith('round')]
|
||||
total=sum(x['passed'] for x in runs)
|
||||
result={
|
||||
'platform':'SiteForge Enterprise', 'version':'8.18.0-dev.1',
|
||||
'generated_at':datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'delivery':'developer integration build',
|
||||
'source':'V8.17 complete source + V8.18 improvements',
|
||||
'local_validation':{
|
||||
'pytest_status':r['status'], 'modules':r['modules'], 'rounds':r['repeats'],
|
||||
'passed_per_round':r['tests_last_complete_round'], 'passed_across_rounds':total,
|
||||
'failures':r['counts']['FAIL'], 'timeouts':r['counts']['TIMEOUT'],
|
||||
'http_worker_e2e':'PASS', 'python_compile':'PASS', 'node_syntax':'PASS',
|
||||
'static_audit':'PASS (12 checks, 2 skipped)',
|
||||
'ci_yaml':'parsed locally; not executed on GitHub Actions'},
|
||||
'not_tested_in_this_environment':[
|
||||
'real PostgreSQL data migration and dump/restore (CI entry supplied)',
|
||||
'real Redis wake-up (CI entry supplied)',
|
||||
'Docker engine / multi-container multi-worker network fault tests',
|
||||
'Playwright/Chromium interactive browser smoke',
|
||||
'SFTP/S3/HTTPS remote public deployment and rollback',
|
||||
'production backup RPO/RTO and offsite restore'],
|
||||
'changed_modules':[
|
||||
'app/backup.py','app/pg_disaster_recovery.py','app/sqlite_pg_transfer.py',
|
||||
'app/jobs.py','app/worker.py','scripts/pg_disaster_recovery.py',
|
||||
'scripts/migrate_sqlite_to_postgres.py','scripts/verify_pg_bootstrap.py',
|
||||
'scripts/verify_sqlite_pg_transfer.py','scripts/verify_pg_backup_restore.py',
|
||||
'scripts/verify_redis_signal.py','.github/workflows/ci.yml'],
|
||||
'limitations': 'NOT production-approved; real PG/Redis/Docker tests require external CI/ops environment'}
|
||||
(rep/'V8_18_RELEASE_EVIDENCE.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
lines=['# SiteForge Enterprise V8.18.0-dev.1 — 集成验收总览','',
|
||||
'**结论:本地开发集成回归通过,正式生产环境验收未完成。**','',
|
||||
f"- 自动化:{r['modules']} 个模块,{r['repeats']} 轮 × {r['tests_last_complete_round']} 项 = {total} 项通过;0 失败 / 0 超时。",
|
||||
'- HTTP + Worker 真实端到端:通过(登录 → 建站 → 入队 → Worker 构建 → ZIP → 预览)。',
|
||||
'- Python 编译、JavaScript 语法、项目静态检查:通过。',
|
||||
'- 项目只读审计:12 项通过,2 项跳过。',
|
||||
'', '## 核心新增', '',
|
||||
'- Postgres 模式禁用不正确的 SQLite 备份;Postgres 离线 ZIP 灾备、校验与恢复到全新数据库/目录。',
|
||||
'- SQLite → PostgreSQL 离线迁移、依赖排序、事务提交和逐表哈希/行数核验。',
|
||||
'- 发布/回滚任务租约过期、网络故障、取消后标记待核对,禁止不安全自动重试。',
|
||||
'- CI PostgreSQL、Redis 和实际 pg_dump → 恢复的集成烟测入口。',
|
||||
'', '## 仍未通过生产门禁', '']
|
||||
lines.extend('- '+x for x in result['not_tested_in_this_environment'])
|
||||
lines+=['','## 文件','',
|
||||
'- `reports/v818-regression-2rounds.html`:两轮测试明细',
|
||||
'- `reports/v818-readonly.html`:只读审计报告',
|
||||
'- `reports/v818-http-e2e.log`:HTTP/Worker E2E 输出',
|
||||
'- `docs/V8_18_ENTERPRISE_HARDENING.md`:运维、迁移与灾备说明',
|
||||
'', '注:成功通过本地自动化不代表 Docker 和真实 PostgreSQL 性能/容灾验收通过。']
|
||||
(rep/'V8_18_集成验收总览.md').write_text('\n'.join(lines)+'\n',encoding='utf-8')
|
||||
rows=''.join(f"<tr><td>{html.escape(x['label'])}</td><td class=ok>{x['status']}</td><td>{x['passed']}</td><td>{x['seconds']}s</td></tr>" for x in runs)
|
||||
risks=''.join('<li>'+html.escape(x)+'</li>' for x in result['not_tested_in_this_environment'])
|
||||
page=f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.18 · 企业集成验收</title><style>
|
||||
:root{{font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;color:#1d2939;background:#f5f7fb}}*{{box-sizing:border-box}}body{{margin:0}}header{{background:#152338;color:white;padding:44px max(24px,calc((100vw - 1120px)/2))}}header p{{color:#b0c5df}}main{{max-width:1120px;margin:28px auto;padding:0 18px}}.cards{{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}}.card,section{{border:1px solid #e5e9ef;background:#fff;border-radius:12px;padding:20px}}.card small{{color:#758398}}.big{{font-size:31px;font-weight:750;margin:6px 0}}.ok{{color:#13795b;font-weight:700}}section{{margin-top:14px}}h2{{margin:0 0 15px;font-size:20px}}table{{width:100%;border-collapse:collapse}}th,td{{padding:10px;border-bottom:1px solid #eef0f4;text-align:left}}.warning{{font-weight:600;color:#99580c}}li{{margin:8px 0}}@media(max-width:650px){{.cards{{grid-template-columns:repeat(2,1fr)}}section{{overflow-x:auto}}}}</style></head><body>
|
||||
<header><div style="font-size:13px;letter-spacing:2px;color:#b0c5df">SITEFORGE ENTERPRISE</div><h1>V8.18 · 集成验收总览</h1><p>开发集成版 · 本地验证通过 · 生产环境门禁未关闭</p></header><main><div class=cards>
|
||||
<div class=card><small>每轮自动化测试</small><div class=big>299</div><span class=ok>PASS</span></div><div class=card><small>重复回归</small><div class=big>2</div><span>共 {total} 次通过</span></div><div class=card><small>测试模块</small><div class=big>{r['modules']}</div><span>历史模块 + 新模块</span></div><div class=card><small>失败 / 超时</small><div class=big>0 / 0</div><span class=ok>PASS</span></div></div>
|
||||
<section><h2>完整回归批次</h2><table><tr><th>批次</th><th>状态</th><th>测试通过</th><th>耗时</th></tr>{rows}</table></section>
|
||||
<section><h2>业务链路</h2><p class=ok>PASS · 登录/CSRF → 创建网站和页面 → 异步 Worker 构建 → ZIP 下载 → 页面预览</p><p>Python 编译、JavaScript 语法检查及源码只读审计均通过;只读审计 2 项因环境/人工证据缺失跳过。</p></section>
|
||||
<section><h2>尚未关闭的生产验收</h2><p class=warning>下列内容已准备 CI/运维入口,当前环境尚无真实完成证据:</p><ul>{risks}</ul></section>
|
||||
<section><h2>运维与版本说明</h2><p>交付包含 V8.17 原有完整业务模块、V8.18 新增备份/迁移/防重复发布代码、自动化测试、CI 工作流、README、完整原始测试日志与验收报告。</p><p>真实上线前必须独立执行 PostgreSQL 迁移、灾备恢复、多 Worker、真实浏览器、部署回滚与故障演练。所有数据库写操作均应只在隔离测试库或经批准的维护窗口执行。</p></section></main></body></html>'''
|
||||
(rep/'V8_18_集成验收总览.html').write_text(page,encoding='utf-8')
|
||||
print('WROTE',total,'passed across two rounds')
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Produce acceptance report from actual V8.19 result files; no invented passes."""
|
||||
import json
|
||||
import html
|
||||
from pathlib import Path
|
||||
from datetime import datetime,timezone
|
||||
|
||||
root=Path(__file__).resolve().parents[1]
|
||||
r=root/'reports'
|
||||
summary=r/'v819-regression.json'
|
||||
if not summary.exists():raise SystemExit('missing v819-regression.json; run regression first')
|
||||
x=json.loads(summary.read_text(encoding='utf-8'))
|
||||
if x['status'] != 'PASS':raise SystemExit('regression incomplete or failed; refusing PASS acceptance')
|
||||
repeatfile=r/'v819-regression-repeat2.json'
|
||||
if repeatfile.exists():
|
||||
repeated=json.loads(repeatfile.read_text(encoding='utf-8'))
|
||||
if repeated['status'] != 'PASS' or repeated['repeats'] != 2:raise SystemExit('repeat regression did not pass')
|
||||
repeats_note=f"2 rounds / {sum(t['passed'] for t in repeated['runs'] if t['status']=='PASS')} checks"
|
||||
else:
|
||||
repeats_note='single round only'
|
||||
items=[('V8.19 全量自动化回归','PASS',f"{x['tests_last_complete_round']} tests / {x['modules']} modules; {repeats_note}"),
|
||||
('本地 HTTP/Worker E2E','PASS (已单独运行)','参见运行日志;本工具不自动验证历史人工输出'),
|
||||
('Art 路由及业务页面适配','PASS (fixture)','安装器测试;不是官方编译'),
|
||||
('Art 官方 npm 构建','BLOCKED','缺少官方 Art 工程及其依赖'),
|
||||
('Art PC/移动端浏览器验收','NOT RUN','需要官方构建与正常浏览器环境'),
|
||||
('真实 PostgreSQL/Redis/HTTPS/发布','NOT RUN','需要独立环境实测')]
|
||||
info={'version':'8.19.0-dev.1','generated_at':datetime.now(timezone.utc).isoformat(),'regression':x,'repeat_verification':repeats_note,'items':[dict(name=a,status=b,note=c) for a,b,c in items]}
|
||||
(r/'V8_19_验收汇总.json').write_text(json.dumps(info,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
text=['# SiteForge Enterprise V8.19 验收汇总','','**状态:开发集成回归已通过,生产/Art 官方验收尚未完成。**','',f"- 自动回归:{x['tests_last_complete_round']} 项;模块 {x['modules']};状态 {x['status']}。",'','| 验收项 | 状态 | 说明 |','|---|---|---|']
|
||||
text += [f'| {a} | {b} | {c} |' for a,b,c in items]
|
||||
(r/'V8_19_验收汇总.md').write_text('\n'.join(text)+'\n',encoding='utf-8')
|
||||
rows=''.join(f'<tr><td>{html.escape(a)}</td><td>{html.escape(b)}</td><td>{html.escape(c)}</td></tr>' for a,b,c in items)
|
||||
doc='''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>SiteForge V8.19 验收汇总</title><style>body{margin:0;background:#f4f6fa;font:15px/1.6 system-ui,sans-serif;color:#263548}header{background:#1d354e;color:#fff;padding:42px 7%}header h1{margin:0}main{max-width:1000px;margin:32px auto;padding:24px;background:#fff;border-radius:16px;border:1px solid #e5e9f0}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 10px;border-bottom:1px solid #e6ebf0}th{background:#f8fafc}p{color:#546379}@media(max-width:740px){main{margin:12px;overflow-x:auto;padding:14px}}</style></head><body><header><h1>SiteForge Enterprise V8.19</h1><p style="color:#d0d8df">统一后台开发集成验收</p></header><main><h2>验收结论</h2><p>本地回归通过不等于正式 Art 编译或生产验收。下表区分实测与待完成项目。</p><table><thead><tr><th>项目</th><th>状态</th><th>证据或限制</th></tr></thead><tbody>'''+rows+'</tbody></table></main></body></html>'
|
||||
(r/'V8_19_验收汇总.html').write_text(doc,encoding='utf-8')
|
||||
print('V8.19 acceptance reports generated; cases:',x['tests_last_complete_round'])
|
||||
Reference in New Issue
Block a user