fix: verify and preserve instance start and stop entrypoints

Validate deployed image receipts and local identities before startup, ignore caller Compose and image overrides, and report success only after health checks.

Install versioned Bash entrypoints and their standalone controller under the deployment lock while preserving local customizations. Cover lifecycle and installation behavior in isolated tests.
This commit is contained in:
eezd committed 2026-11-01 18:27:14 +08:00
1 parent 0a150eca0f
commit 3b7aec0024
8 files changed
+408 -4

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
"""Start or stop the installed AlphaPulse instance without building or pulling."""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
SERVICES = ('api', 'web', 'market', 'crawler', 'calendar', 'trader', 'news-risk', 'jin10-calendar', 'searxng')
SEARCH_IMAGE = 'docker.io/searxng/searxng@sha256:f4aa660c54d062dedb268be84e821ea24e39f48c7a496af8dd02cc9eedb93847'
def require(condition, message):
if not condition:
raise RuntimeError(message)
def run(args, env):
result = subprocess.run(args, env=env, text=True, capture_output=True)
# Do not include command output: Compose diagnostics may contain private configuration.
require(result.returncode == 0, f'Docker 命令失败(退出码 {result.returncode}):请检查实例配置与容器状态')
return result.stdout.strip()
def start(directory, command, env):
build = json.loads((directory / 'deployment.json').read_text())
require(build.get('verified') is True, '部署回执未通过验收')
require(re.fullmatch('[0-9a-f]{40}', build['commit']), '部署回执提交无效')
require(re.fullmatch(r'\d{8}-' + build['commit'][:8], build['version']), '部署回执版本不匹配')
images = {part: build['images'][part]['digest'] for part in ('backend', 'web')}
for part, image in images.items():
require(re.fullmatch(r'git\.134333\.xyz/eezd/dev/' + part + r'/alphapulse@sha256:[0-9a-f]{64}', image), '部署镜像摘要无效')
# Inspect interpolation without reading service credentials into the controller.
config = json.loads(run([*command, 'config', '--format', 'json', '--no-env-resolution'], env))
require(config['name'] == 'alphapulse-dev' and set(config['services']) == set(SERVICES), 'Compose 实例或服务集合不匹配')
expected = {name: SEARCH_IMAGE if name == 'searxng' else images['web' if name == 'web' else 'backend'] for name in SERVICES}
require(all(config['services'][name]['image'] == image for name, image in expected.items()), 'images.env 或 Compose 镜像与部署回执不一致')
local_ids = {}
for image in set(expected.values()):
info, = json.loads(run(['docker', 'image', 'inspect', image], env))
require(info['Architecture'] == 'amd64' and info['Os'] == 'linux', '本地镜像平台不匹配')
if image != SEARCH_IMAGE:
labels = info['Config'].get('Labels') or {}
require(labels.get('org.opencontainers.image.revision') == build['commit'] and
labels.get('org.opencontainers.image.version') == build['version'], '本地镜像身份与部署回执不一致')
local_ids[image] = info['Id']
run([*command, 'config', '--quiet'], env)
run([*command, 'up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', '120'], env)
identifiers = run([*command, 'ps', '--all', '--quiet'], env).split()
require(len(identifiers) == len(SERVICES), '启动后服务数量不完整')
template = '{"service":{{json (index .Config.Labels "com.docker.compose.service")}},"image":{{json .Image}},"state":{{json .State}}}'
containers = [json.loads(line) for line in run(['docker', 'inspect', '--format', template, *identifiers], env).splitlines()]
require({c['service'] for c in containers} == set(SERVICES), '启动后服务集合不完整')
for container in containers:
state = container['state']
require(state['Running'] and not state['Paused'] and state.get('Health', {}).get('Status', 'healthy') == 'healthy',
f"服务未健康运行:{container['service']}")
require(container['image'] == local_ids[expected[container['service']]], '运行镜像与部署回执不一致')
def control(directory, action):
with (directory / '.deploy.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError('实例正在部署或启停,请稍后重试') from None
require(action != 'start' or not (directory / 'deployment.pending.json').exists(), '实例存在未完成部署,请先恢复部署状态')
files = [directory / name for name in ('compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml') if (directory / name).is_file()]
require(len(files) == 1, '实例必须有且只有一个 Compose 主文件')
require((directory / 'images.env').is_file(), '缺少实例 images.env,请由部署流程补齐')
env = {k: v for k, v in os.environ.items() if not k.startswith(('COMPOSE_', 'ALPHAPULSE_'))}
command = ['docker', 'compose', '--project-directory', str(directory), '--project-name', 'alphapulse-dev',
'--env-file', str(directory / 'images.env'), '-f', str(files[0])]
if action == 'start':
start(directory, command, env)
else:
run([*command, 'config', '--quiet'], env)
run([*command, 'stop'], env)
print('实例已健康启动' if action == 'start' else '实例已停止', ':', directory.name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=('start', 'stop'))
args = parser.parse_args()
try:
control(Path(__file__).resolve().parent, args.action)
except (RuntimeError, OSError, ValueError, KeyError, TypeError) as error:
# Only report controller messages; malformed private files must not leak their contents.
parser.exit(1, (str(error) if isinstance(error, RuntimeError) else '实例文件或 Docker 数据无效,请检查部署状态') + '\n')
if __name__ == '__main__':
main()