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:
1 parent
0a150eca0f
commit
3b7aec0024
8 files changed
+408
-4
No files matched your search
@@ -29,7 +29,7 @@ jobs:
|
|||||||
DOCKER_CONFIG: /tmp/alphapulse-docker-auth
|
DOCKER_CONFIG: /tmp/alphapulse-docker-auth
|
||||||
steps:
|
steps:
|
||||||
- name: Install container tools
|
- name: Install container tools
|
||||||
run: apk add --no-cache python3 nodejs git docker-cli-buildx docker-cli-compose
|
run: apk add --no-cache bash python3 nodejs git docker-cli-buildx docker-cli-compose
|
||||||
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
DOCKER_CONFIG: /tmp/alphapulse-docker-auth
|
DOCKER_CONFIG: /tmp/alphapulse-docker-auth
|
||||||
steps:
|
steps:
|
||||||
- name: Install container tools
|
- name: Install container tools
|
||||||
run: apk add --no-cache python3 nodejs git docker-cli-buildx docker-cli-compose
|
run: apk add --no-cache bash python3 nodejs git docker-cli-buildx docker-cli-compose
|
||||||
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|||||||
@@ -36,6 +36,39 @@ python3 scripts/deploy.py rollback
|
|||||||
|
|
||||||
Neither command restores database contents or resets trading orders. Schema migrations require separate authorization and are never part of CI auto-deployment. No scheduled backup is installed.
|
Neither command restores database contents or resets trading orders. Schema migrations require separate authorization and are never part of CI auto-deployment. No scheduled backup is installed.
|
||||||
|
|
||||||
|
### Start and stop this instance
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bash /docker/gitea/dev/AlphaPulse/start.sh
|
||||||
|
bash /docker/gitea/dev/AlphaPulse/stop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
These Bash entrypoints use the installed `instance.py` controller (host Python 3 and
|
||||||
|
Docker Compose are required), share `.deploy.lock` with CD and fail immediately when
|
||||||
|
the lock is occupied. Start rejects a pending deployment; stop remains available
|
||||||
|
while pending. Both use the explicit `alphapulse-dev` identity and instance
|
||||||
|
`images.env`, ignoring caller `COMPOSE_*` and `ALPHAPULSE_*` overrides.
|
||||||
|
|
||||||
|
Start checks the verified `deployment.json` receipt, Compose image selection and all
|
||||||
|
local image identities before changing containers. It never builds or pulls. It
|
||||||
|
waits up to 120 seconds for services to run or become healthy, then checks the nine
|
||||||
|
services and their running image IDs before reporting success. A failed start can
|
||||||
|
leave some services running; inspect the reported failure before retrying. Stop
|
||||||
|
retains containers, networks, volumes and data. Neither operation changes stored AI
|
||||||
|
or trading settings; starting resumes workers according to those settings.
|
||||||
|
|
||||||
|
Deployment installs these three versioned files and executable Bash permissions.
|
||||||
|
For an existing instance, install only the entrypoints without starting services:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 scripts/deploy.py install-entrypoints
|
||||||
|
```
|
||||||
|
|
||||||
|
`entrypoints.json` records installed checksums. Installation accepts unchanged
|
||||||
|
managed files and the original skill templates, and refuses to overwrite local
|
||||||
|
customizations. Review such customizations before updating; no other instance or
|
||||||
|
global skill template is changed.
|
||||||
|
|
||||||
## Credentials and mirroring
|
## Credentials and mirroring
|
||||||
|
|
||||||
Gitea secrets are `ALPHAPULSE_GITEA_TOKEN` (image publication) and `ALL_GITHUB_TOKEN` (the account-level GitHub PAT classic with repository write permission; no repository-level override). Ordinary checks and the deployment job do not receive publication secrets. The GitHub token is read from the task environment, never stored in Git URLs or credential files. The trusted mirror task verifies the GitHub identity and repository write permission before synchronization.
|
Gitea secrets are `ALPHAPULSE_GITEA_TOKEN` (image publication) and `ALL_GITHUB_TOKEN` (the account-level GitHub PAT classic with repository write permission; no repository-level override). Ordinary checks and the deployment job do not receive publication secrets. The GitHub token is read from the task environment, never stored in Git URLs or credential files. The trusted mirror task verifies the GitHub identity and repository write permission before synchronization.
|
||||||
|
|||||||
@@ -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()
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Resolve the instance independently of the caller's working directory.
|
||||||
|
set -euo pipefail
|
||||||
|
if (( $# != 0 )); then
|
||||||
|
printf '用法:bash %s\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
instance_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||||
|
exec python3 "$instance_dir/instance.py" start
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Keep the two operator entrypoints on the same instance controller.
|
||||||
|
set -euo pipefail
|
||||||
|
if (( $# != 0 )); then
|
||||||
|
printf '用法:bash %s\n' "$0" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
instance_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||||
|
exec python3 "$instance_dir/instance.py" stop
|
||||||
+33
-2
@@ -2,6 +2,7 @@
|
|||||||
"""Update the one data-bearing dev instance using verified image digests."""
|
"""Update the one data-bearing dev instance using verified image digests."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,6 +17,30 @@ SEARXNG_IMAGE = 'docker.io/searxng/searxng@sha256:f4aa660c54d062dedb268be84e821e
|
|||||||
INSTANCE = Path('/docker/gitea/dev/AlphaPulse')
|
INSTANCE = Path('/docker/gitea/dev/AlphaPulse')
|
||||||
SERVICES = ('api', 'web', 'market', 'crawler', 'calendar', 'trader', 'news-risk', 'jin10-calendar', 'searxng')
|
SERVICES = ('api', 'web', 'market', 'crawler', 'calendar', 'trader', 'news-risk', 'jin10-calendar', 'searxng')
|
||||||
|
|
||||||
|
# Recognize the unmodified skill templates installed before entrypoint versioning.
|
||||||
|
LEGACY_ENTRIES = {
|
||||||
|
'start.sh': 'b4709f36a66b428671a09f373f640a99259845924779dcb122f5cd9c17e85ab6',
|
||||||
|
'stop.sh': '83348a7781b7d738a715bde51387951041a5cfcd31796805422ef83139a21571',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def install_entrypoints(directory):
|
||||||
|
manifest = directory / 'entrypoints.json'
|
||||||
|
previous = json.loads(manifest.read_text()) if manifest.exists() else LEGACY_ENTRIES
|
||||||
|
sources = {name: (ROOT / 'deploy' / name).read_bytes() for name in ('instance.py', 'start.sh', 'stop.sh')}
|
||||||
|
for name, content in sources.items():
|
||||||
|
target = directory / name
|
||||||
|
require(not target.is_symlink(), f'拒绝覆盖符号链接:{name}')
|
||||||
|
require(not target.exists() or target.read_bytes() == content or
|
||||||
|
hashlib.sha256(target.read_bytes()).hexdigest() == previous.get(name), f'保留用户自定义脚本,请先核对:{name}')
|
||||||
|
for name, content in sources.items():
|
||||||
|
target = directory / name
|
||||||
|
temporary = target.with_name(name + '.tmp')
|
||||||
|
temporary.write_bytes(content)
|
||||||
|
temporary.chmod(0o755 if name.endswith('.sh') else 0o644)
|
||||||
|
temporary.replace(target)
|
||||||
|
save(manifest, {name: hashlib.sha256(content).hexdigest() for name, content in sources.items()})
|
||||||
|
|
||||||
SCHEMA = '''
|
SCHEMA = '''
|
||||||
import asyncio, json
|
import asyncio, json
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
@@ -146,6 +171,7 @@ def deploy(build, directory=INSTANCE):
|
|||||||
if branch_head() != build['commit']:
|
if branch_head() != build['commit']:
|
||||||
print('Skipped: dev advanced while preparing deployment')
|
print('Skipped: dev advanced while preparing deployment')
|
||||||
return
|
return
|
||||||
|
install_entrypoints(directory)
|
||||||
pending = {'build': build, 'previous': previous, 'previous_compose': old_compose,
|
pending = {'build': build, 'previous': previous, 'previous_compose': old_compose,
|
||||||
'previous_running': previous_running, 'started_at': time.time(), 'phase': 'preparing'}
|
'previous_running': previous_running, 'started_at': time.time(), 'phase': 'preparing'}
|
||||||
save(directory / 'deployment.pending.json', pending)
|
save(directory / 'deployment.pending.json', pending)
|
||||||
@@ -251,11 +277,16 @@ asyncio.run(main())
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('command', choices=('update', 'recover', 'rollback', 'reset-news-ai'))
|
parser.add_argument('command', choices=('update', 'recover', 'rollback', 'reset-news-ai', 'install-entrypoints'))
|
||||||
parser.add_argument('--build', type=Path, default=ROOT / 'build.json')
|
parser.add_argument('--build', type=Path, default=ROOT / 'build.json')
|
||||||
parser.add_argument('--confirm-delete-old-news-ai', action='store_true')
|
parser.add_argument('--confirm-delete-old-news-ai', action='store_true')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
if args.command == 'reset-news-ai':
|
if args.command == 'install-entrypoints':
|
||||||
|
with locked(INSTANCE / '.deploy.lock'):
|
||||||
|
require(not (INSTANCE / 'deployment.pending.json').exists(), 'Recover the pending deployment first')
|
||||||
|
install_entrypoints(INSTANCE)
|
||||||
|
print('实例启停入口已安装;未启动或停止任何服务')
|
||||||
|
elif args.command == 'reset-news-ai':
|
||||||
reset_news_ai(json.loads(args.build.read_text()), args.confirm_delete_old_news_ai)
|
reset_news_ai(json.loads(args.build.read_text()), args.confirm_delete_old_news_ai)
|
||||||
elif args.command == 'update':
|
elif args.command == 'update':
|
||||||
deploy(json.loads(args.build.read_text()))
|
deploy(json.loads(args.build.read_text()))
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""Exercise installed entrypoints with synthetic receipts and a fake Docker CLI."""
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import deploy
|
||||||
|
|
||||||
|
SPEC = importlib.util.spec_from_file_location('instance', deploy.ROOT / 'deploy/instance.py')
|
||||||
|
instance = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(instance)
|
||||||
|
|
||||||
|
FAKE_DOCKER = '''#!/usr/bin/env python3
|
||||||
|
import json,os,sys
|
||||||
|
from pathlib import Path
|
||||||
|
a=sys.argv[1:]
|
||||||
|
root=Path(os.environ['ENTRY_FIXTURE'])
|
||||||
|
fixture=json.loads((root/'fixture.json').read_text())
|
||||||
|
with (root/'calls.jsonl').open('a') as output:
|
||||||
|
output.write(json.dumps({'args':a,'env':{k:v for k,v in os.environ.items() if k.startswith(('COMPOSE_','ALPHAPULSE_'))}})+'\\n')
|
||||||
|
if 'config' in a:
|
||||||
|
if fixture.get('config_failure'):sys.exit(11)
|
||||||
|
if '--format' in a:print(json.dumps(fixture['config']))
|
||||||
|
elif a[:2]==['image','inspect']:
|
||||||
|
if fixture.get('missing_image')==a[-1]:sys.exit(12)
|
||||||
|
print(json.dumps([fixture['images'][a[-1]]]))
|
||||||
|
elif 'up' in a:
|
||||||
|
if fixture.get('up_failure'):sys.exit(13)
|
||||||
|
elif 'stop' in a:
|
||||||
|
if fixture.get('stop_failure'):sys.exit(14)
|
||||||
|
elif 'ps' in a:
|
||||||
|
print('\\n'.join(c['service'] for c in fixture['containers']))
|
||||||
|
elif a[0]=='inspect':
|
||||||
|
for container in fixture['containers']:print(json.dumps(container))
|
||||||
|
else:sys.exit(99)
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
class EntrypointTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temporary.cleanup)
|
||||||
|
self.root = Path(temporary.name)
|
||||||
|
self.directory = self.root / 'AlphaPulse'
|
||||||
|
self.directory.mkdir()
|
||||||
|
deploy.install_entrypoints(self.directory)
|
||||||
|
self.receipt = {'verified': True, 'commit': 'a' * 40, 'version': '20261101-aaaaaaaa', 'images': {
|
||||||
|
part: {'digest': repository + '@sha256:' + digit * 64}
|
||||||
|
for (part, repository), digit in zip(deploy.IMAGES.items(), ('1', '2'), strict=True)}}
|
||||||
|
self.write_receipt()
|
||||||
|
(self.directory / 'compose.yml').write_text('name: alphapulse-dev\nservices: {}\n')
|
||||||
|
(self.directory / 'images.env').write_text('SYNTHETIC=only\n')
|
||||||
|
self.expected = {name: instance.SEARCH_IMAGE if name == 'searxng' else self.receipt['images'][
|
||||||
|
'web' if name == 'web' else 'backend']['digest'] for name in instance.SERVICES}
|
||||||
|
images = {image: {'Id': 'local-' + str(i), 'Architecture': 'amd64', 'Os': 'linux', 'Config': {'Labels': {
|
||||||
|
'org.opencontainers.image.revision': self.receipt['commit'],
|
||||||
|
'org.opencontainers.image.version': self.receipt['version']}}} for i, image in enumerate(set(self.expected.values()))}
|
||||||
|
self.fixture = {'config': {'name': 'alphapulse-dev', 'services': {s: {'image': image} for s, image in self.expected.items()}},
|
||||||
|
'images': images, 'containers': [{'service': s, 'image': images[image]['Id'],
|
||||||
|
'state': {'Running': True, 'Paused': False, 'Health': {'Status': 'healthy'}}} for s, image in self.expected.items()]}
|
||||||
|
binary = self.root / 'bin'
|
||||||
|
binary.mkdir()
|
||||||
|
(binary / 'docker').write_text(FAKE_DOCKER)
|
||||||
|
(binary / 'docker').chmod(0o755)
|
||||||
|
self.env = {'PATH': str(binary) + os.pathsep + os.environ['PATH'], 'ENTRY_FIXTURE': str(self.root)}
|
||||||
|
|
||||||
|
def write_receipt(self):
|
||||||
|
(self.directory / 'deployment.json').write_text(json.dumps(self.receipt))
|
||||||
|
|
||||||
|
def invoke(self, action='start', code=0, extra_env=None, args=()):
|
||||||
|
(self.root / 'fixture.json').write_text(json.dumps(self.fixture))
|
||||||
|
(self.root / 'calls.jsonl').unlink(missing_ok=True)
|
||||||
|
result = subprocess.run(['bash', str(self.directory / (action + '.sh')), *args], cwd='/',
|
||||||
|
env={**self.env, **(extra_env or {})}, text=True, capture_output=True)
|
||||||
|
self.assertEqual(result.returncode, code, result.stdout + result.stderr)
|
||||||
|
log = self.root / 'calls.jsonl'
|
||||||
|
calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else []
|
||||||
|
if code:
|
||||||
|
self.assertNotIn('实例已健康启动', result.stdout)
|
||||||
|
self.assertNotIn('实例已停止', result.stdout)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
def test_start_ignores_caller_overrides_and_checks_health(self):
|
||||||
|
calls = self.invoke(extra_env={'COMPOSE_PROJECT_NAME': 'other', 'COMPOSE_PROFILES': 'other',
|
||||||
|
'ALPHAPULSE_BACKEND_IMAGE': 'unaccepted', 'ALPHAPULSE_WEB_IMAGE': 'unaccepted'})
|
||||||
|
self.assertTrue(all(not c['env'] for c in calls))
|
||||||
|
up = next(c['args'] for c in calls if 'up' in c['args'])
|
||||||
|
self.assertIn('--no-build', up)
|
||||||
|
self.assertEqual(up[up.index('--pull') + 1], 'never')
|
||||||
|
self.assertIn('--wait', up)
|
||||||
|
self.assertEqual(up[up.index('--project-name') + 1], 'alphapulse-dev')
|
||||||
|
self.assertEqual(up[up.index('--env-file') + 1], str(self.directory / 'images.env'))
|
||||||
|
self.assertEqual(calls[-1]['args'][0], 'inspect')
|
||||||
|
|
||||||
|
def test_missing_or_mismatched_images_never_start_any_service(self):
|
||||||
|
backend = self.receipt['images']['backend']['digest']
|
||||||
|
for case in ('missing', 'labels', 'selection'):
|
||||||
|
with self.subTest(case=case):
|
||||||
|
if case == 'missing': self.fixture['missing_image'] = backend
|
||||||
|
if case == 'labels': self.fixture['images'][backend]['Config']['Labels']['org.opencontainers.image.revision'] = 'b' * 40
|
||||||
|
if case == 'selection': self.fixture['config']['services']['api']['image'] = 'unaccepted'
|
||||||
|
calls = self.invoke(code=1)
|
||||||
|
self.assertFalse(any('up' in c['args'] for c in calls))
|
||||||
|
self.fixture.pop('missing_image', None)
|
||||||
|
self.fixture['images'][backend]['Config']['Labels']['org.opencontainers.image.revision'] = 'a' * 40
|
||||||
|
|
||||||
|
def test_receipt_must_be_verified_before_docker(self):
|
||||||
|
self.receipt['verified'] = False
|
||||||
|
self.write_receipt()
|
||||||
|
self.assertEqual(self.invoke(code=1), [])
|
||||||
|
|
||||||
|
def test_unhealthy_exited_missing_or_wrong_running_image_is_not_success(self):
|
||||||
|
for case in ('unhealthy', 'exited', 'missing', 'image'):
|
||||||
|
with self.subTest(case=case):
|
||||||
|
original = json.loads(json.dumps(self.fixture['containers']))
|
||||||
|
if case == 'unhealthy': self.fixture['containers'][0]['state']['Health']['Status'] = 'unhealthy'
|
||||||
|
if case == 'exited': self.fixture['containers'][0]['state']['Running'] = False
|
||||||
|
if case == 'missing': self.fixture['containers'].pop()
|
||||||
|
if case == 'image': self.fixture['containers'][0]['image'] = 'unexpected'
|
||||||
|
self.invoke(code=1)
|
||||||
|
self.fixture['containers'] = original
|
||||||
|
|
||||||
|
def test_pending_and_shared_lock(self):
|
||||||
|
pending = self.directory / 'deployment.pending.json'
|
||||||
|
pending.write_text('{}')
|
||||||
|
self.assertEqual(self.invoke(code=1), [])
|
||||||
|
calls = self.invoke('stop')
|
||||||
|
self.assertEqual(calls[-1]['args'][-1], 'stop')
|
||||||
|
pending.unlink()
|
||||||
|
with (self.directory / '.deploy.lock').open('a') as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
for action in ('start', 'stop'):
|
||||||
|
self.assertEqual(self.invoke(action, code=1), [])
|
||||||
|
|
||||||
|
def test_argument_config_up_stop_failures_propagate(self):
|
||||||
|
for action in ('start', 'stop'):
|
||||||
|
self.assertEqual(self.invoke(action, code=2, args=('extra',)), [])
|
||||||
|
self.fixture['config_failure'] = True
|
||||||
|
calls = self.invoke(action, code=1)
|
||||||
|
self.assertFalse(any('up' in c['args'] or 'stop' in c['args'] for c in calls))
|
||||||
|
self.fixture.pop('config_failure')
|
||||||
|
self.fixture[action.replace('start', 'up') + '_failure'] = True
|
||||||
|
self.invoke(action, code=1)
|
||||||
|
self.fixture.pop(action.replace('start', 'up') + '_failure')
|
||||||
|
|
||||||
|
def test_stop_retains_data_and_does_not_require_start_receipt(self):
|
||||||
|
(self.directory / 'deployment.json').unlink()
|
||||||
|
calls = self.invoke('stop')
|
||||||
|
self.assertEqual(len(calls), 2)
|
||||||
|
self.assertEqual(calls[-1]['args'][-1], 'stop')
|
||||||
|
self.assertFalse(any(value in ('down', 'rm', '--volumes') for c in calls for value in c['args']))
|
||||||
|
|
||||||
|
def test_duplicate_config_and_missing_env_fail_before_docker(self):
|
||||||
|
duplicate = self.directory / 'compose.yaml'
|
||||||
|
duplicate.write_text('name: other\n')
|
||||||
|
self.assertEqual(self.invoke(code=1), [])
|
||||||
|
duplicate.unlink()
|
||||||
|
(self.directory / 'images.env').unlink()
|
||||||
|
self.assertEqual(self.invoke('stop', code=1), [])
|
||||||
|
|
||||||
|
|
||||||
|
class InstallationTests(unittest.TestCase):
|
||||||
|
def test_install_update_missing_entry_and_permissions(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
deploy.install_entrypoints(directory)
|
||||||
|
for name in ('start.sh', 'stop.sh'):
|
||||||
|
self.assertEqual((directory / name).stat().st_mode & 0o777, 0o755)
|
||||||
|
old = directory / 'instance.py'
|
||||||
|
old.write_text('previous managed version')
|
||||||
|
manifest = json.loads((directory / 'entrypoints.json').read_text())
|
||||||
|
manifest['instance.py'] = hashlib.sha256(old.read_bytes()).hexdigest()
|
||||||
|
(directory / 'entrypoints.json').write_text(json.dumps(manifest))
|
||||||
|
(directory / 'stop.sh').unlink()
|
||||||
|
deploy.install_entrypoints(directory)
|
||||||
|
self.assertEqual(old.read_bytes(), (deploy.ROOT / 'deploy/instance.py').read_bytes())
|
||||||
|
self.assertTrue((directory / 'stop.sh').is_file())
|
||||||
|
|
||||||
|
def test_customizations_are_preserved_before_any_entry_changes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
custom = directory / 'stop.sh'
|
||||||
|
custom.write_text('custom stop command')
|
||||||
|
with self.assertRaisesRegex(RuntimeError, '自定义脚本'):
|
||||||
|
deploy.install_entrypoints(directory)
|
||||||
|
self.assertEqual(custom.read_text(), 'custom stop command')
|
||||||
|
self.assertFalse((directory / 'instance.py').exists())
|
||||||
|
|
||||||
|
def test_installed_helper_is_self_contained_and_service_set_matches_cd(self):
|
||||||
|
self.assertEqual(set(instance.SERVICES), set(deploy.SERVICES))
|
||||||
|
self.assertEqual(instance.SEARCH_IMAGE, deploy.SEARXNG_IMAGE)
|
||||||
|
subprocess.run(['bash', '-n', str(deploy.ROOT / 'deploy/start.sh'), str(deploy.ROOT / 'deploy/stop.sh')], check=True)
|
||||||
|
|
||||||
|
def test_install_only_does_not_use_docker_or_change_deployment_receipt(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
receipt = directory / 'deployment.json'
|
||||||
|
receipt.write_text('unchanged deployed receipt')
|
||||||
|
with patch.object(deploy, 'INSTANCE', directory), patch.object(sys, 'argv', ['deploy.py', 'install-entrypoints']), \
|
||||||
|
patch.object(deploy, 'run') as run:
|
||||||
|
deploy.main()
|
||||||
|
run.assert_not_called()
|
||||||
|
self.assertEqual(receipt.read_text(), 'unchanged deployed receipt')
|
||||||
|
|
||||||
|
def test_first_deployment_installs_versioned_entries(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
build = {'commit': 'a' * 40, 'images': {part: {'digest': 'synthetic'} for part in deploy.IMAGES}}
|
||||||
|
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'branch_head', return_value=build['commit']), \
|
||||||
|
patch.object(deploy, 'validate_images'), patch.object(deploy, 'run', return_value=''), \
|
||||||
|
patch.object(deploy, 'health'), patch.object(deploy.time, 'sleep'):
|
||||||
|
deploy.deploy(build, directory)
|
||||||
|
for name in ('start.sh', 'stop.sh', 'instance.py'):
|
||||||
|
self.assertEqual((directory / name).read_bytes(), (deploy.ROOT / 'deploy' / name).read_bytes())
|
||||||
|
self.assertFalse((directory / 'deployment.pending.json').exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user