Fix deployment preparation recovery and unify GitHub authentication
Development CI / check-and-build (push) Successful in 33s
Development CI / deploy-development (push) Successful in 2m27s
Main CI / check-and-build (push) Successful in 27s
GitHub Git mirror / mirror (push) Successful in 5s

This commit is contained in:
eezd committed 2026-10-30 11:33:53 +08:00
1 parent b4f29d4966
commit 549b818e95
5 files changed
+124 -25

No files matched your search

+26 -11
View File
@@ -89,7 +89,8 @@ def health(command, build):
containers = inspect_services(command)
require({c['Config']['Labels']['com.docker.compose.service'] for c in containers} == set(SERVICES), 'Missing services')
for container in containers:
require(container['State']['Running'] and container['RestartCount'] == 0, 'Service exited or restarted')
require(container['State']['Running'] and not container['State']['Paused'] and container['RestartCount'] == 0,
'Service exited, paused or restarted')
part = 'web' if container['Name'] == '/AlphaPulse-dev' else 'backend'
expected = json.loads(run('docker', 'image', 'inspect', build['images'][part]['digest']))[0]['Id']
require(container['Image'] == expected, 'Running image differs from the accepted digest')
@@ -119,7 +120,9 @@ def deploy(build, directory=INSTANCE):
previous_running = []
if previous:
old_command = compose(directory, previous)
previous_running = [c['Config']['Labels']['com.docker.compose.service'] for c in inspect_services(old_command) if c['State']['Running']]
containers = inspect_services(old_command)
require(not any(c['State'].get('Paused') for c in containers), 'Recover paused services before deploying')
previous_running = [c['Config']['Labels']['com.docker.compose.service'] for c in containers if c['State']['Running']]
for _ in range(60):
idle = json.loads(run('docker', 'exec', '-i', 'AlphaPulse-dev-api', 'python', '-', input=IDLE))
if not idle['busy']:
@@ -131,18 +134,17 @@ def deploy(build, directory=INSTANCE):
if branch_head() != build['commit']:
print('Skipped: dev advanced while preparing deployment')
return
if previous and 'trader' in previous_running:
run('docker', 'pause', 'AlphaPulse-dev-trader')
try:
idle = json.loads(run('docker', 'exec', '-i', 'AlphaPulse-dev-api', 'python', '-', input=IDLE))
require(not idle['busy'], 'Analysis became active during preparation; retry after it completes')
except BaseException:
run('docker', 'unpause', 'AlphaPulse-dev-trader')
raise
pending = {'build': build, 'previous': previous, 'previous_compose': old_compose,
'previous_running': previous_running, 'started_at': time.time()}
'previous_running': previous_running, 'started_at': time.time(), 'phase': 'preparing'}
save(directory / 'deployment.pending.json', pending)
try:
if previous and 'trader' in previous_running:
run('docker', 'pause', 'AlphaPulse-dev-trader')
idle = json.loads(run('docker', 'exec', '-i', 'AlphaPulse-dev-api', 'python', '-', input=IDLE))
require(not idle['busy'], 'Analysis became active during preparation; retry after it completes')
# Persist the transition before stopping any existing service.
save(directory / 'deployment.pending.json', {**pending, 'phase': 'updating'})
pending['phase'] = 'updating'
if previous:
# Docker unpauses a paused container before delivering the stop signal.
run(*old_command, 'stop', 'trader')
@@ -160,6 +162,9 @@ def deploy(build, directory=INSTANCE):
save(previous_file, build)
(directory / 'deployment.pending.json').unlink()
except BaseException:
if pending['phase'] == 'preparing':
restore(directory, pending)
raise
# Never roll back or reset business data. A pending marker blocks subsequent jobs.
subprocess.run(['docker', 'compose', '--project-name', 'alphapulse-dev', '--env-file', str(directory / 'images.env'),
'-f', str(directory / 'compose.yml'), 'stop'], capture_output=True)
@@ -172,6 +177,16 @@ def deploy(build, directory=INSTANCE):
def restore(directory, pending):
previous = pending['previous']
require(previous is not None, 'No previous deployment exists; inspect the pending first-start failure')
trader = run('docker', 'ps', '-aq', '--filter', 'name=^/AlphaPulse-dev-trader$')
if trader:
state = json.loads(run('docker', 'inspect', trader))[0]['State']
if state['Paused']:
run('docker', 'unpause', trader)
if pending.get('phase') == 'preparing':
# No service has been stopped or replaced during preparation.
(directory / 'deployment.pending.json').unlink()
print('Restored preparation state; existing services were retained', flush=True)
return
validate_images(previous)
(directory / 'compose.yml').write_text(pending['previous_compose'])
command = compose(directory, previous)
+16 -11
View File
@@ -4,13 +4,12 @@
import argparse
import os
from pathlib import Path
import shlex
import tempfile
import time
from ci import SOURCE, locked, request_json, require, run, save
TARGET = 'git@github.com:eezd/AlphaPulse.git'
TARGET = 'https://github.com/eezd/AlphaPulse.git'
DIRECTORY = Path('/docker/gitea/mirrors/AlphaPulse')
@@ -47,16 +46,22 @@ def synchronize(directory, source, target, env=None):
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.parse_args()
# A write-enabled deploy key limits GitHub access to this repository.
token = os.environ.get('ALL_GITHUB_TOKEN')
require(token, 'Missing account-level ALL_GITHUB_TOKEN')
identity = request_json('https://api.github.com/user', token=token)
require(identity['login'] == 'eezd', 'GitHub token belongs to an unexpected account')
repository = request_json('https://api.github.com/repos/eezd/AlphaPulse', token=token)
require(repository.get('permissions', {}).get('push'), 'GitHub repository write permission is required')
# The helper contains no credentials; Git reads the token from the task environment.
with tempfile.TemporaryDirectory(prefix='ap-mirror-auth-') as temporary:
key = Path(temporary) / 'key'
key.write_text(os.environ['ALPHAPULSE_GITHUB_SSH_KEY'])
key.chmod(0o600)
hosts = Path(temporary) / 'known_hosts'
hosts.write_text(''.join('github.com ' + entry + '\n' for entry in request_json('https://api.github.com/meta')['ssh_keys']))
command = ['ssh', '-i', str(key), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'UserKnownHostsFile=' + str(hosts)]
env = {**os.environ, 'GIT_SSH_COMMAND': shlex.join(command), 'GIT_TERMINAL_PROMPT': '0'}
helper = Path(temporary) / 'askpass'
helper.write_text('#!/bin/sh\ncase "$1" in\n'
' *github.com*) case "$1" in *Username*) printf "%s" "x-access-token" ;; '
'*) printf "%s" "$ALL_GITHUB_TOKEN" ;; esac ;;\n'
' *) exit 1 ;;\nesac\n')
helper.chmod(0o700)
env = {**os.environ, 'GIT_ASKPASS': str(helper), 'GIT_TERMINAL_PROMPT': '0',
'GIT_CONFIG_COUNT': '1', 'GIT_CONFIG_KEY_0': 'credential.helper', 'GIT_CONFIG_VALUE_0': ''}
synchronize(DIRECTORY, SOURCE, TARGET, env)
print('GitHub business references and history verified')
+79
View File
@@ -81,6 +81,85 @@ class BuildTests(unittest.TestCase):
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
class RecoveryTests(unittest.TestCase):
def test_pending_write_failure_never_pauses_trader(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
previous = {'images': {part: {'digest': 'synthetic'} for part in ci.IMAGES}}
ci.save(directory / 'deployment.json', previous)
(directory / 'compose.yml').write_text('synthetic')
running = [{'Config': {'Labels': {'com.docker.compose.service': 'trader'}}, 'State': {'Running': True}}]
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'branch_head', return_value='a' * 40), \
patch.object(deploy, 'validate_images'), patch.object(deploy, 'inspect_services', return_value=running), \
patch.object(deploy, 'run', return_value='{"busy":0}') as run, \
patch.object(deploy, 'save', side_effect=OSError('disk failure')):
with self.assertRaises(OSError):
deploy.deploy({**previous, 'commit': 'a' * 40}, directory)
self.assertFalse(any(call.args[:2] == ('docker', 'pause') for call in run.call_args_list))
def test_busy_recheck_recovers_without_stopping_services(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
previous = {'images': {part: {'digest': 'synthetic'} for part in ci.IMAGES}}
ci.save(directory / 'deployment.json', previous)
(directory / 'compose.yml').write_text('synthetic')
running = [{'Config': {'Labels': {'com.docker.compose.service': 'trader'}}, 'State': {'Running': True}}]
idle = iter(['{"busy":0}', '{"busy":1}'])
def command(*args, **kwargs):
if kwargs.get('input') == deploy.IDLE:
return next(idle)
if args[:2] == ('docker', 'inspect'):
return '[{"State":{"Paused":true}}]'
if args[:2] == ('docker', 'ps'):
return 'trader-id'
return '{}'
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'branch_head', return_value='a' * 40), \
patch.object(deploy, 'validate_images'), patch.object(deploy, 'inspect_services', return_value=running), \
patch.object(deploy, 'run', side_effect=command) as run:
with self.assertRaisesRegex(RuntimeError, 'Analysis became active'):
deploy.deploy({**previous, 'commit': 'a' * 40}, directory)
self.assertFalse(any('stop' in call.args for call in run.call_args_list))
self.assertTrue(any(call.args[:2] == ('docker', 'unpause') for call in run.call_args_list))
self.assertFalse((directory / 'deployment.pending.json').exists())
def test_preparation_recovery_unpauses_without_replacing_services(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
ci.save(directory / 'deployment.pending.json', {})
with patch.object(deploy, 'run', side_effect=['trader-id', '[{"State":{"Paused":true}}]', '']) as run, \
patch.object(deploy, 'validate_images') as validate:
deploy.restore(directory, {'previous': {}, 'phase': 'preparing'})
run.assert_called_with('docker', 'unpause', 'trader-id')
validate.assert_not_called()
self.assertFalse((directory / 'deployment.pending.json').exists())
def test_failed_unpause_preserves_pending(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
ci.save(directory / 'deployment.pending.json', {})
with patch.object(deploy, 'run', side_effect=['trader-id', '[{"State":{"Paused":true}}]', RuntimeError('unpause failed')]):
with self.assertRaisesRegex(RuntimeError, 'unpause failed'):
deploy.restore(directory, {'previous': {}, 'phase': 'preparing'})
self.assertTrue((directory / 'deployment.pending.json').exists())
def test_missing_or_wrong_github_identity_never_synchronizes(self):
for token, identity in [('', None), ('synthetic', {'login': 'other'})]:
with self.subTest(token=bool(token)), patch.dict(os.environ, {'ALL_GITHUB_TOKEN': token}), \
patch.object(sys, 'argv', ['mirror.py']), patch.object(mirror, 'request_json', return_value=identity), \
patch.object(mirror, 'synchronize') as sync:
with self.assertRaises(RuntimeError):
mirror.main()
sync.assert_not_called()
def test_github_read_only_token_never_synchronizes(self):
with patch.dict(os.environ, {'ALL_GITHUB_TOKEN': 'synthetic'}), patch.object(sys, 'argv', ['mirror.py']), \
patch.object(mirror, 'request_json', side_effect=[{'login': 'eezd'}, {'permissions': {'push': False}}]), \
patch.object(mirror, 'synchronize') as sync:
with self.assertRaisesRegex(RuntimeError, 'write permission'):
mirror.main()
sync.assert_not_called()
class MirrorTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()