qa: validate server queries and prepare offline backend web update

This commit is contained in:
weiyuqing committed 2026-10-17 14:41:45 +08:00
1 parent 6292755ff9
commit c076bbc0d6
11 files changed
+640 -1

No files matched your search

+1
View File
@@ -0,0 +1 @@
* text eol=lf
+6
View File
@@ -0,0 +1,6 @@
# 离线增量镜像;发布清单另固定基础镜像digest,不联网安装依赖。
FROM local/mi-fitness-nas:20261017-audit3
USER root
COPY src/mi_fitness_mcp/ /src/src/mi_fitness_mcp/
RUN python -c "import shutil,sysconfig; shutil.copytree('/src/src/mi_fitness_mcp',sysconfig.get_paths()['purelib']+'/mi_fitness_mcp',dirs_exist_ok=True)"
USER 1000:1001
+30
View File
@@ -0,0 +1,30 @@
param(
[Parameter(Mandatory=$true)][string]$Package,
[string]$SshHost = 'fn',
[switch]$Deploy
)
$ErrorActionPreference = 'Stop'
$release = (Resolve-Path -LiteralPath $Package).Path
$manifest = Join-Path $release 'manifest.sha256'
foreach ($line in Get-Content -LiteralPath $manifest -Encoding utf8) {
if ($line -notmatch '^([a-f0-9]{64}) ([A-Za-z0-9._-]+)$') { throw '发布清单格式不符' }
if ((Get-FileHash -LiteralPath (Join-Path $release $Matches[2]) -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Matches[1]) { throw '本地发布文件校验失败' }
}
if (-not $Deploy) {
Write-Output '本地发布包校验通过。仅更新backend/web,worker、端口、密码和数据挂载沿用。'
Write-Output '得到明确部署确认后,在交互PowerShell使用同一命令加 -Deploy;sudo密码只在终端输入。'
return
}
if ($SshHost -notmatch '^[A-Za-z0-9._-]+$') { throw 'SSH别名无效' }
$remote = '/tmp/mi-fitness-summary8-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
& ssh $SshHost "umask 077; mkdir '$remote'"
if ($LASTEXITCODE -ne 0) { throw '创建远程发布目录失败' }
foreach ($line in Get-Content -LiteralPath $manifest -Encoding utf8) {
$name = ($line -split ' ',2)[1]
& scp (Join-Path $release $name) "${SshHost}:$remote/$name"
if ($LASTEXITCODE -ne 0) { throw '发布文件传输失败' }
}
& scp $manifest "${SshHost}:$remote/manifest.sha256"
if ($LASTEXITCODE -ne 0) { throw '发布清单传输失败' }
& ssh -t $SshHost "sudo sh '$remote/update-health.sh'"
if ($LASTEXITCODE -ne 0) { throw '部署未通过,请保留终端输出和回退备份。' }
+40
View File
@@ -0,0 +1,40 @@
"""部署后最小就绪核对;不查询健康记录,不输出凭据。"""
import json
import subprocess
import sys
IMAGE = 'local/mi-fitness-nas:20261017-summary8'
items = json.loads(subprocess.check_output(['docker', 'inspect', 'mi-fitness-health-backend-1', 'mi-fitness-health-web-1', 'mi-fitness-health-worker-1']))
backend, web, worker = items
assert backend['Config']['Image'] == web['Config']['Image'] == IMAGE
assert worker['Config']['Image'] == 'local/mi-fitness-nas:20261017-audit3'
if len(sys.argv) > 1:
assert backend['Image'] == web['Image'] == sys.argv[1]
assert backend['HostConfig']['ReadonlyRootfs'] and web['HostConfig']['ReadonlyRootfs']
assert any(m['Destination'] == '/state' for m in backend['Mounts'])
assert any(m['Destination'] == '/run/secrets/keyring_master' and not m['RW'] for m in backend['Mounts'])
assert any(m['Destination'] == '/calendar' for m in web['Mounts'])
assert set(web['HostConfig']['PortBindings']) == {'8322/tcp'}
assert {(p['HostIp'], p['HostPort']) for p in web['HostConfig']['PortBindings']['8322/tcp']} == {('127.0.0.1', '18322'), ('192.168.31.201', '18322')}
# 密码只在容器内部使用,不读取到控制端、不打印、不放在命令行。
probe = r'''
import base64,json,os,urllib.error,urllib.request
opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))
token=base64.b64encode(('admin:'+os.environ['MI_FITNESS_WEB_PASSWORD']).encode()).decode()
def get(path,auth=True):
req=urllib.request.Request('http://127.0.0.1:8322'+path,headers={'Authorization':'Basic '+token} if auth else {})
try:
with opener.open(req,timeout=10) as r:return r.status,r.read(300000).decode()
except urllib.error.HTTPError as e:return e.code,''
assert get('/',False)[0]==401
assert '20261017-summary8' in get('/')[1]
for path,marker in [('/static/js/health-views.js','fromSummary'),('/static/js/health-ui.js','/api/views/'),('/static/js/app.js','/api/overview')]:
status,body=get(path);assert status==200 and marker in body
assert json.loads(get('/api/proxy_status')[1])['backend_reachable']
for path in ['/proxy/api/views/stress','/proxy/api/views/stress/records','/proxy/api/views/stress/export','/proxy/api/overview']:
assert get(path+'?start_date=invalid&end_date=invalid')[0]==422
assert os.environ.get('MI_FITNESS_CALENDAR_DIR')=='/calendar'
assert json.loads(get('/api/calendar?start=2026&end=2026')[1])['years']['2026']['days']
print('backend/web资源、代理、匿名拒绝和新路由就绪;未查询健康记录。')
'''
subprocess.run(['docker', 'exec', '-i', 'mi-fitness-health-web-1', 'python', '-'], input=probe.encode('utf-8'), check=True)
+17
View File
@@ -0,0 +1,17 @@
# 沿用当前NAS的端口、密码来源和日历挂载,只升级backend/web。
services:
backend:
image: local/mi-fitness-nas:20261017-summary8
web:
image: local/mi-fitness-nas:20261017-summary8
environment:
MI_FITNESS_CALENDAR_DIR: /calendar
MI_FITNESS_ALLOWED_HOSTS: localhost,127.0.0.1,192.168.31.201
MI_FITNESS_WEB_PASSWORD: ${MI_FITNESS_WEB_PASSWORD:?尚未初始化网页访问密码}
volumes:
- ./calendar:/calendar
ports: !override
- '127.0.0.1:18322:8322'
- '192.168.31.201:18322:8322'
worker:
image: local/mi-fitness-nas:20261017-audit3
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
umask 077
[ "$(id -u)" = 0 ] || { echo '请通过sudo在交互终端执行。'; exit 1; }
package=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
target=/vol2/1000/Soft/mi-fitness-health
cd "$package"
sha256sum -c manifest.sha256
python3 - "$target/compose.override.yml" "$package/expected-current.sha256" <<'PY'
import hashlib,sys
from pathlib import Path
assert hashlib.sha256(Path(sys.argv[1]).read_bytes()).hexdigest()==Path(sys.argv[2]).read_text().strip(), '现有覆盖配置已变化,停止避免覆盖'
PY
[ -d "$target/calendar" ] && [ ! -L "$target/calendar" ] || { echo '日历挂载目录缺失或异常,停止。'; exit 1; }
cd "$target"
compose() { docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f compose.override.yml "$@"; }
snapshot() { docker ps -aq --filter label=com.docker.compose.project=mi-fitness-health | sort | while read -r id; do docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}} {{.Id}} {{.Image}}' "$id"; done | grep -v -E '^(backend|web) ' | sort; }
before=$(snapshot)
[ "$(docker inspect --format '{{.Config.Image}}' mi-fitness-health-worker-1)" = local/mi-fitness-nas:20261017-audit3 ] || { echo 'worker版本已变化,停止。'; exit 1; }
backup="$target/compose.override.pre-summary8-$(date +%Y%m%d-%H%M%S).yml"
cp compose.override.yml "$backup"
docker inspect --format '{{.Config.Image}} {{.Image}}' mi-fitness-health-backend-1 mi-fitness-health-web-1 > "$backup.images"
# 不清理旧镜像;覆盖配置和镜像ID均保留以便回退。
docker load -i "$package/health-summary8.tar"
expected=$(cat "$package/image-id.txt")
[ "$(docker image inspect --format '{{.Id}}' local/mi-fitness-nas:20261017-summary8)" = "$expected" ] || { echo '导入镜像ID不符。'; exit 1; }
docker compose --project-name mi-fitness-health --env-file .env -f compose.yml -f "$package/compose.override.yml" config --quiet
complete=0
recover() {
if [ "$complete" = 0 ]; then
cp "$backup" "$target/compose.override.yml"
chown 1000:1001 "$target/compose.override.yml"
chmod 600 "$target/compose.override.yml"
compose up -d --no-deps --no-build --pull never backend web || true
echo "更新未通过,已恢复原配置并尝试回退backend/web。备份:$backup"
fi
}
trap recover EXIT
cp "$package/compose.override.yml" compose.override.yml
chown 1000:1001 compose.override.yml
chmod 600 compose.override.yml
compose up -d --no-deps --no-build --pull never --wait --wait-timeout 90 backend web
python3 "$package/check-ready.py" "$expected"
[ "$before" = "$(snapshot)" ] || { echo 'worker或其他健康服务发生变化,停止验收。'; exit 1; }
complete=1
trap - EXIT
printf '\nbackend/web更新完成;worker及其他健康服务未重建,原覆盖配置和旧镜像已保留。\n'