Add Gitea CI and automatic dev deployment
This commit is contained in:
1 parent
543564a29c
commit
b4f29d4966
20 files changed
+1109
-29
No files matched your search
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build, verify and publish immutable AlphaPulse development images."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
from datetime import UTC, datetime
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zlib
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REGISTRY = 'git.134333.xyz'
|
||||
REPOSITORY = 'eezd/AlphaPulse'
|
||||
SOURCE = f'https://{REGISTRY}/{REPOSITORY}.git'
|
||||
IMAGES = {part: f'{REGISTRY}/eezd/dev/{part}/alphapulse' for part in ('backend', 'web')}
|
||||
LOCK_DIR = Path('/docker/gitea/mirrors/AlphaPulse')
|
||||
LABEL = 'io.alphapulse.ci.run'
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def run(*args, input=None, env=None):
|
||||
result = subprocess.run(list(map(str, args)), input=input, env=env, text=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if result.returncode:
|
||||
# Callers never pass credentials as command arguments.
|
||||
raise RuntimeError(f'{args[0]} failed ({result.returncode}): {result.stderr[-6000:]}{result.stdout[-6000:]}')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def save(path, value):
|
||||
path = Path(path)
|
||||
temporary = path.with_name(path.name + '.tmp')
|
||||
with temporary.open('w') as stream:
|
||||
json.dump(value, stream, indent=2)
|
||||
stream.write('\n')
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def locked(path):
|
||||
with Path(path).open('a') as stream:
|
||||
fcntl.flock(stream, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
|
||||
def version(commit, timestamp):
|
||||
require(re.fullmatch('[0-9a-f]{40}', commit), 'Expected a complete commit SHA')
|
||||
return datetime.fromtimestamp(int(timestamp), UTC).strftime('%Y%m%d') + '-' + commit[:8]
|
||||
|
||||
|
||||
def metadata():
|
||||
commit = run('git', 'rev-parse', 'HEAD')
|
||||
require(os.environ.get('GITEA_SHA', commit) == commit, 'Checkout differs from the event commit')
|
||||
return {'commit': commit, 'version': version(commit, run('git', 'show', '-s', '--format=%ct', commit)),
|
||||
'built_at': datetime.now(UTC).isoformat(), 'platform': 'linux/amd64', 'images': {}}
|
||||
|
||||
|
||||
def request_json(url, token=None, data=None, method=None):
|
||||
headers = {'User-Agent': 'AlphaPulse-CI', 'Accept': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = 'token ' + token
|
||||
if data is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
data = json.dumps(data).encode()
|
||||
with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers, method=method), timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def branch_head():
|
||||
return request_json(f'https://{REGISTRY}/api/v1/repos/{REPOSITORY}/branches/dev')['commit']['id']
|
||||
|
||||
|
||||
def registry_json(path, token=None):
|
||||
url = f'https://{REGISTRY}/v2/{path}'
|
||||
headers = {'User-Agent': 'AlphaPulse-CI',
|
||||
'Accept': 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json'}
|
||||
if token:
|
||||
headers['Authorization'] = 'Bearer ' + token
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=60) as response:
|
||||
raw = response.read()
|
||||
return json.loads(raw), response.headers.get('Docker-Content-Digest') or 'sha256:' + hashlib.sha256(raw).hexdigest()
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code == 404:
|
||||
return None
|
||||
if error.code != 401 or token:
|
||||
raise
|
||||
challenge = error.headers.get('WWW-Authenticate', '')
|
||||
fields = dict(re.findall(r'(\w+)="([^"]*)"', challenge))
|
||||
require(fields.get('realm', '').startswith(f'https://{REGISTRY}/'), 'Unexpected registry authentication endpoint')
|
||||
auth_headers = {'User-Agent': 'AlphaPulse-CI'}
|
||||
secret = os.environ.get('ALPHAPULSE_GITEA_TOKEN')
|
||||
if secret:
|
||||
auth_headers['Authorization'] = 'Basic ' + base64.b64encode(('eezd:' + secret).encode()).decode()
|
||||
auth_url = fields['realm'] + '?' + urllib.parse.urlencode({k: fields[k] for k in ('service', 'scope') if k in fields})
|
||||
with urllib.request.urlopen(urllib.request.Request(auth_url, headers=auth_headers), timeout=30) as response:
|
||||
bearer = json.load(response)['token']
|
||||
return registry_json(path, bearer)
|
||||
|
||||
|
||||
def existing_image(part, build):
|
||||
repository = IMAGES[part].split('/', 1)[1]
|
||||
result = registry_json(f'{repository}/manifests/{build["version"]}')
|
||||
if result is None:
|
||||
return None
|
||||
manifest, digest = result
|
||||
require('config' in manifest, 'Expected a single Linux amd64 image manifest')
|
||||
config = registry_json(f'{repository}/blobs/{manifest["config"]["digest"]}')[0]
|
||||
labels = config['config'].get('Labels') or {}
|
||||
require(labels.get('org.opencontainers.image.revision') == build['commit'], 'Existing version belongs to a different commit')
|
||||
require(labels.get('org.opencontainers.image.version') == build['version'], 'Existing image version differs')
|
||||
require(config.get('architecture') == 'amd64' and config.get('os') == 'linux', 'Existing image platform differs')
|
||||
return IMAGES[part] + '@' + digest
|
||||
|
||||
|
||||
def wait_command(*command, attempts=60):
|
||||
for attempt in range(attempts):
|
||||
try:
|
||||
return run(*command)
|
||||
except RuntimeError:
|
||||
if attempt == attempts - 1:
|
||||
raise
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def cleanup(label, images=()):
|
||||
for identifier in run('docker', 'ps', '-aq', '--filter', f'label={LABEL}={label}').split():
|
||||
run('docker', 'rm', '-fv', identifier)
|
||||
for identifier in run('docker', 'network', 'ls', '-q', '--filter', f'label={LABEL}={label}').split():
|
||||
run('docker', 'network', 'rm', identifier)
|
||||
for image in images:
|
||||
if image.startswith('alphapulse-ci:'):
|
||||
run('docker', 'image', 'rm', image)
|
||||
|
||||
|
||||
def smoke(build, label):
|
||||
network = 'ap-ci-' + label
|
||||
names = {part: network + '-' + part for part in ('db', 'redis', 'api', 'web')}
|
||||
# Pull before entering the internal network; no runtime service can reach external I/O.
|
||||
for image in ('postgres:18-alpine', 'redis:8-alpine'):
|
||||
run('docker', 'pull', image)
|
||||
run('docker', 'network', 'create', '--internal', '--label', f'{LABEL}={label}', network)
|
||||
common = ['--network', network, '--label', f'{LABEL}={label}']
|
||||
try:
|
||||
run('docker', 'run', '-d', '--name', names['db'], *common, '-e', 'POSTGRES_PASSWORD=ci-only',
|
||||
'-e', 'POSTGRES_USER=ci', '-e', 'POSTGRES_DB=ci', 'postgres:18-alpine')
|
||||
run('docker', 'run', '-d', '--name', names['redis'], *common, 'redis:8-alpine')
|
||||
wait_command('docker', 'exec', names['db'], 'pg_isready', '-U', 'ci', '-d', 'ci')
|
||||
run('docker', 'exec', names['db'], 'psql', '-U', 'ci', '-d', 'ci', '-v', 'ON_ERROR_STOP=1',
|
||||
'-c', 'CREATE EXTENSION IF NOT EXISTS pg_trgm')
|
||||
image = build['images']['backend']['local']
|
||||
password_hash = run('docker', 'run', '--rm', '--network', 'none', image, 'python', '-c',
|
||||
'from argon2 import PasswordHasher; print(PasswordHasher().hash("ci-only"))')
|
||||
env = {'DATABASE_URL': f'postgresql+asyncpg://ci:ci-only@{names["db"]}:5432/ci',
|
||||
'REDIS_URL': f'redis://{names["redis"]}:6379/0', 'JWT_SECRET': 'isolated-ci-only-secret-32-characters',
|
||||
'SECRET_ENCRYPTION_KEY': 'MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=',
|
||||
'ADMIN_USERNAME': 'ci', 'ADMIN_PASSWORD_HASH': password_hash}
|
||||
env_args = [entry for key, value in env.items() for entry in ('-e', key + '=' + value)]
|
||||
run('docker', 'run', '-d', '--name', names['api'], '--network-alias', 'api', *common, *env_args,
|
||||
image, 'sh', '-ec', 'alembic upgrade head && exec uvicorn alphapulse.api.main:app --host 0.0.0.0')
|
||||
run('docker', 'run', '-d', '--name', names['web'], '--network-alias', 'web', *common, build['images']['web']['local'])
|
||||
wait_command('docker', 'exec', names['api'], 'python', '-c',
|
||||
'import urllib.request; urllib.request.urlopen("http://localhost:8000/api/health", timeout=3)')
|
||||
output = run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-',
|
||||
input=(ROOT / 'scripts/smoke_app.py').read_text())
|
||||
print(output, flush=True)
|
||||
# Force a different API address while retaining the synthetic database and Redis.
|
||||
old_address = json.loads(run('docker', 'inspect', names['api']))[0]['NetworkSettings']['Networks'][network]['IPAddress']
|
||||
run('docker', 'rm', '-f', names['api'])
|
||||
run('docker', 'run', '-d', '--name', network + '-address-holder', *common, 'redis:8-alpine', 'sleep', '180')
|
||||
run('docker', 'run', '-d', '--name', names['api'], '--network-alias', 'api', *common, *env_args,
|
||||
image, 'sh', '-ec', 'alembic upgrade head && exec uvicorn alphapulse.api.main:app --host 0.0.0.0')
|
||||
new_address = json.loads(run('docker', 'inspect', names['api']))[0]['NetworkSettings']['Networks'][network]['IPAddress']
|
||||
require(new_address != old_address, 'Acceptance did not exercise an API address change')
|
||||
wait_command('docker', 'exec', names['api'], 'python', '-c',
|
||||
'import urllib.request; urllib.request.urlopen("http://web/api/health", timeout=3)')
|
||||
run('docker', 'exec', '-i', '-e', 'SMOKE_WEB=' + names['web'], names['api'], 'python', '-',
|
||||
input=(ROOT / 'scripts/smoke_app.py').read_text())
|
||||
except BaseException:
|
||||
for part in ('api', 'web'):
|
||||
result = subprocess.run(['docker', 'logs', '--tail', '30', names[part]], capture_output=True, text=True)
|
||||
print(result.stdout + result.stderr, flush=True)
|
||||
raise
|
||||
finally:
|
||||
cleanup(label)
|
||||
|
||||
|
||||
def check():
|
||||
build = metadata()
|
||||
label = os.environ.get('CI_RESOURCE_ID', 'local-' + str(os.getpid()))
|
||||
require(re.fullmatch('[a-zA-Z0-9-]+', label), 'Invalid CI resource identifier')
|
||||
build['resource_id'] = label
|
||||
save(ROOT / 'build.json', build)
|
||||
for part in IMAGES:
|
||||
print('Checking ' + part, flush=True)
|
||||
run('docker', 'build', '--provenance=false', '--platform', 'linux/amd64', '--target', 'check',
|
||||
'-f', ROOT / f'deploy/Dockerfile.{part}', ROOT / part)
|
||||
reused = existing_image(part, build)
|
||||
local = reused or 'alphapulse-ci:' + label + '-' + part
|
||||
build['images'][part] = {'local': local, 'reused': bool(reused)}
|
||||
save(ROOT / 'build.json', build)
|
||||
if reused:
|
||||
run('docker', 'pull', reused)
|
||||
else:
|
||||
print('Building ' + part, flush=True)
|
||||
run('docker', 'build', '--provenance=false', '--platform', 'linux/amd64', '--target', 'runtime',
|
||||
'--build-arg', 'APP_VERSION=' + build['version'], '--build-arg', 'APP_COMMIT=' + build['commit'],
|
||||
'--label', f'{LABEL}={label}', '--tag', local, '-f', ROOT / f'deploy/Dockerfile.{part}', ROOT / part)
|
||||
info = json.loads(run('docker', 'image', 'inspect', local))[0]
|
||||
require(info['Architecture'] == 'amd64' and info['Os'] == 'linux', 'Unexpected image platform')
|
||||
require(info['Config']['Labels']['org.opencontainers.image.revision'] == build['commit'], 'Image commit mismatch')
|
||||
smoke(build, label)
|
||||
build['verified'] = True
|
||||
save(ROOT / 'build.json', build)
|
||||
print('All checks and isolated acceptance passed', flush=True)
|
||||
|
||||
|
||||
def check_upload_layers(image):
|
||||
# Docker may send an entire compressed layer in one registry request.
|
||||
with tempfile.TemporaryDirectory(prefix='ap-layer-check-') as temporary:
|
||||
archive_path = Path(temporary) / 'image.tar'
|
||||
run('docker', 'save', '-o', archive_path, image)
|
||||
with tarfile.open(archive_path) as archive:
|
||||
manifest = json.load(archive.extractfile('manifest.json'))[0]
|
||||
for name in manifest['Layers']:
|
||||
with archive.extractfile(name) as stream:
|
||||
first = stream.read(1024 * 1024)
|
||||
compressor = None if first.startswith((b'\x1f\x8b', b'\x28\xb5\x2f\xfd')) else zlib.compressobj(wbits=31)
|
||||
size = len(compressor.compress(first)) if compressor else len(first)
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
size += len(compressor.compress(chunk)) if compressor else len(chunk)
|
||||
if compressor:
|
||||
size += len(compressor.flush())
|
||||
require(size < 90_000_000, f'A compressed image layer ({size} bytes) exceeds the CDN upload budget')
|
||||
|
||||
|
||||
def publish():
|
||||
require(os.environ.get('GITEA_EVENT_NAME') == 'push' and os.environ.get('GITEA_REF') == 'refs/heads/dev',
|
||||
'Only a dev push may publish development images')
|
||||
build = json.loads((ROOT / 'build.json').read_text())
|
||||
require(build.get('verified') and build['commit'] == os.environ['GITEA_SHA'], 'Missing acceptance for this commit')
|
||||
with locked(LOCK_DIR / '.publish.lock'):
|
||||
for part in IMAGES:
|
||||
current = existing_image(part, build)
|
||||
if not current:
|
||||
tag = IMAGES[part] + ':' + build['version']
|
||||
check_upload_layers(build['images'][part]['local'])
|
||||
run('docker', 'tag', build['images'][part]['local'], tag)
|
||||
run('docker', 'push', tag)
|
||||
current = existing_image(part, build)
|
||||
require(current, 'Published manifest is missing')
|
||||
build['images'][part]['digest'] = current
|
||||
save(ROOT / 'build.json', build)
|
||||
if output := os.environ.get('GITHUB_OUTPUT'):
|
||||
with open(output, 'a') as stream:
|
||||
stream.write(f'version={build["version"]}\ncommit={build["commit"]}\n')
|
||||
for part in IMAGES:
|
||||
stream.write(f'{part}={build["images"][part]["digest"]}\n')
|
||||
print('Verified development digests published', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=('check', 'publish', 'cleanup'))
|
||||
args = parser.parse_args()
|
||||
if args.command == 'check':
|
||||
check()
|
||||
elif args.command == 'publish':
|
||||
publish()
|
||||
elif (ROOT / 'build.json').exists():
|
||||
build = json.loads((ROOT / 'build.json').read_text())
|
||||
cleanup(build['resource_id'], [v['local'] for v in build['images'].values()])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Update the one data-bearing dev instance using verified image digests."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from ci import IMAGES, ROOT, branch_head, locked, require, run, save
|
||||
|
||||
INSTANCE = Path('/docker/gitea/dev/AlphaPulse')
|
||||
SERVICES = ('api', 'web', 'market', 'crawler', 'calendar', 'trader')
|
||||
|
||||
SCHEMA = '''
|
||||
import asyncio, json
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import text
|
||||
from alphapulse.core.db import engine
|
||||
async def main():
|
||||
async with engine.connect() as connection:
|
||||
async with connection.begin():
|
||||
await connection.execute(text("SET TRANSACTION READ ONLY"))
|
||||
actual = list((await connection.execute(text("SELECT version_num FROM alembic_version"))).scalars())
|
||||
expected = ScriptDirectory.from_config(Config("alembic.ini")).get_heads()
|
||||
assert sorted(actual) == sorted(expected), "Database migration requires separate authorization"
|
||||
await engine.dispose()
|
||||
print(json.dumps({"schema": actual}))
|
||||
asyncio.run(main())
|
||||
'''
|
||||
|
||||
IDLE = '''
|
||||
import asyncio, json
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from alphapulse.core.auth import create_token, require_session, revoke_session
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from alphapulse.core.config import get_settings
|
||||
from alphapulse.core.db import engine
|
||||
async def main():
|
||||
async with engine.connect() as connection:
|
||||
busy = await connection.scalar(text("SELECT count(*) FROM trading_analyses WHERE status='running'"))
|
||||
token, _ = await create_token(get_settings().admin_username)
|
||||
principal = await require_session(HTTPAuthorizationCredentials(scheme="Bearer", credentials=token))
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=False) as client:
|
||||
response = await client.get("http://localhost:8000/api/chat/session", headers={"Authorization":"Bearer "+token})
|
||||
response.raise_for_status()
|
||||
busy += int(response.json()["data"]["generating"])
|
||||
finally:
|
||||
await revoke_session(principal)
|
||||
await engine.dispose()
|
||||
print(json.dumps({"busy": busy}))
|
||||
asyncio.run(main())
|
||||
'''
|
||||
|
||||
|
||||
def validate_images(build):
|
||||
require(build.get('verified') is True, 'Build has not passed isolated acceptance')
|
||||
require(re.fullmatch('[0-9a-f]{40}', build['commit']), 'Invalid commit SHA')
|
||||
require(re.fullmatch(r'\d{8}-[0-9a-f]{8}', build['version']), 'Invalid development version')
|
||||
require(build['version'].endswith(build['commit'][:8]), 'Version does not identify this commit')
|
||||
for part, repository in IMAGES.items():
|
||||
image = build['images'][part]['digest']
|
||||
require(re.fullmatch(re.escape(repository) + r'@sha256:[0-9a-f]{64}', image), 'Expected a development image digest')
|
||||
run('docker', 'pull', image)
|
||||
info = json.loads(run('docker', 'image', 'inspect', image))[0]
|
||||
labels = info['Config'].get('Labels') or {}
|
||||
require(labels.get('org.opencontainers.image.revision') == build['commit'], 'Image revision mismatch')
|
||||
require(labels.get('org.opencontainers.image.version') == build['version'], 'Image version mismatch')
|
||||
require(info['Architecture'] == 'amd64' and info['Os'] == 'linux', 'Unexpected image architecture')
|
||||
|
||||
|
||||
def compose(directory, release):
|
||||
images = directory / 'images.env'
|
||||
images.write_text(''.join(f'ALPHAPULSE_{part.upper()}_IMAGE={release["images"][part]["digest"]}\n' for part in IMAGES))
|
||||
return ['docker', 'compose', '--project-name', 'alphapulse-dev', '--env-file', str(images),
|
||||
'-f', str(directory / 'compose.yml')]
|
||||
|
||||
|
||||
def inspect_services(command):
|
||||
identifiers = run(*command, 'ps', '-aq').split()
|
||||
return json.loads(run('docker', 'inspect', *identifiers)) if identifiers else []
|
||||
|
||||
|
||||
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')
|
||||
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')
|
||||
payload = run('docker', 'exec', 'AlphaPulse-dev-api', 'python', '-c',
|
||||
'import json,urllib.request; print(json.load(urllib.request.urlopen("http://web/api/health"))["data"]["commit"])')
|
||||
require(payload == build['commit'], 'Served API revision mismatch')
|
||||
payload = run('docker', 'exec', 'AlphaPulse-dev-api', 'python', '-c',
|
||||
'import json,urllib.request; print(json.load(urllib.request.urlopen("http://web/version.json"))["commit"])')
|
||||
require(payload == build['commit'], 'Served frontend revision mismatch')
|
||||
|
||||
|
||||
def deploy(build, directory=INSTANCE):
|
||||
directory = Path(directory).resolve(strict=True)
|
||||
require(directory == INSTANCE, 'Unexpected instance directory')
|
||||
with locked(directory / '.deploy.lock'):
|
||||
require(not (directory / 'deployment.pending.json').exists(), 'Resolve the previous pending deployment with recover first')
|
||||
if branch_head() != build['commit']:
|
||||
print('Skipped: dev has advanced')
|
||||
return
|
||||
validate_images(build)
|
||||
# Only this read-only schema guard receives the existing application environment.
|
||||
print(run('docker', 'run', '--rm', '-i', '--network', 'dev_default', '--env-file', directory / '.env',
|
||||
build['images']['backend']['digest'], 'python', '-', input=SCHEMA), flush=True)
|
||||
previous_file = directory / 'deployment.json'
|
||||
previous = json.loads(previous_file.read_text()) if previous_file.exists() else None
|
||||
old_compose = (directory / 'compose.yml').read_text() if previous else None
|
||||
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']]
|
||||
for _ in range(60):
|
||||
idle = json.loads(run('docker', 'exec', '-i', 'AlphaPulse-dev-api', 'python', '-', input=IDLE))
|
||||
if not idle['busy']:
|
||||
break
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise RuntimeError('Chat or trading analysis did not become idle within five minutes')
|
||||
# Recheck after pulls and waiting; only a current dev commit can mutate the 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()}
|
||||
save(directory / 'deployment.pending.json', pending)
|
||||
try:
|
||||
if previous:
|
||||
# Docker unpauses a paused container before delivering the stop signal.
|
||||
run(*old_command, 'stop', 'trader')
|
||||
run(*old_command, 'stop', 'crawler', 'calendar', 'market', 'web', 'api')
|
||||
(directory / 'compose.yml').write_text((ROOT / 'deploy/docker-compose.yml').read_text())
|
||||
command = compose(directory, build)
|
||||
run(*command, 'config', '--quiet')
|
||||
run(*command, 'up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', '120', 'api', 'web')
|
||||
run(*command, 'up', '-d', '--no-build', '--pull', 'never', 'market', 'crawler', 'calendar', 'trader')
|
||||
time.sleep(5)
|
||||
health(command, build)
|
||||
if previous:
|
||||
save(directory / 'deployment.previous.json', previous)
|
||||
(directory / 'compose.previous.yml').write_text(old_compose)
|
||||
save(previous_file, build)
|
||||
(directory / 'deployment.pending.json').unlink()
|
||||
except BaseException:
|
||||
# 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)
|
||||
if previous:
|
||||
restore(directory, pending)
|
||||
raise
|
||||
print('All six services now run the accepted development digests', flush=True)
|
||||
|
||||
|
||||
def restore(directory, pending):
|
||||
previous = pending['previous']
|
||||
require(previous is not None, 'No previous deployment exists; inspect the pending first-start failure')
|
||||
validate_images(previous)
|
||||
(directory / 'compose.yml').write_text(pending['previous_compose'])
|
||||
command = compose(directory, previous)
|
||||
run(*command, 'up', '-d', '--no-build', '--pull', 'never', '--wait', '--wait-timeout', '120', *pending['previous_running'])
|
||||
if set(pending['previous_running']) == set(SERVICES):
|
||||
health(command, previous)
|
||||
save(directory / 'deployment.json', previous)
|
||||
(directory / 'deployment.pending.json').unlink()
|
||||
print('Restored previous images and configuration; business data was retained', flush=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('command', choices=('update', 'recover', 'rollback'))
|
||||
parser.add_argument('--build', type=Path, default=ROOT / 'build.json')
|
||||
args = parser.parse_args()
|
||||
if args.command == 'update':
|
||||
deploy(json.loads(args.build.read_text()))
|
||||
else:
|
||||
with locked(INSTANCE / '.deploy.lock'):
|
||||
if args.command == 'recover':
|
||||
restore(INSTANCE, json.loads((INSTANCE / 'deployment.pending.json').read_text()))
|
||||
else:
|
||||
require(not (INSTANCE / 'deployment.pending.json').exists(), 'Recover the pending deployment first')
|
||||
previous = json.loads((INSTANCE / 'deployment.previous.json').read_text())
|
||||
pending = {'previous': previous, 'previous_compose': (INSTANCE / 'compose.previous.yml').read_text(),
|
||||
'previous_running': list(SERVICES)}
|
||||
save(INSTANCE / 'deployment.pending.json', pending)
|
||||
run(*compose(INSTANCE, json.loads((INSTANCE / 'deployment.json').read_text())), 'stop')
|
||||
restore(INSTANCE, pending)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reconcile all business Git references from Gitea to GitHub under one lock."""
|
||||
|
||||
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 = '[email protected]:eezd/AlphaPulse.git'
|
||||
DIRECTORY = Path('/docker/gitea/mirrors/AlphaPulse')
|
||||
|
||||
|
||||
def refs(repository, remote=None, env=None):
|
||||
args = ['ls-remote', remote, 'refs/heads/*', 'refs/tags/*'] if remote else [
|
||||
'for-each-ref', '--format=%(objectname)\t%(refname)', 'refs/heads/', 'refs/tags/']
|
||||
output = run('git', '-C', repository, *args, env=env)
|
||||
return {ref: sha for line in output.splitlines() for sha, ref in [line.split()] if not ref.endswith('^{}')}
|
||||
|
||||
|
||||
def synchronize(directory, source, target, env=None):
|
||||
directory = Path(directory)
|
||||
with locked(directory / '.mirror.lock'):
|
||||
repository = directory / 'repository.git'
|
||||
if not repository.exists():
|
||||
run('git', 'init', '--bare', repository)
|
||||
for _ in range(3):
|
||||
run('git', '-C', repository, 'fetch', '--prune', '--no-tags', source,
|
||||
'+refs/heads/*:refs/heads/*', '+refs/tags/*:refs/tags/*', env=env)
|
||||
snapshot = refs(repository)
|
||||
require('refs/heads/main' in snapshot, 'Source main is missing; refusing destructive synchronization')
|
||||
if run('git', '-C', repository, 'log', '--all', '--format=', '--name-only', '--', '.gitattributes', '**/.gitattributes'):
|
||||
run('git', '-C', repository, 'lfs', 'fetch', '--all', source, env=env)
|
||||
run('git', '-C', repository, 'lfs', 'push', '--all', target, env=env)
|
||||
run('git', '-C', repository, 'push', '--atomic', '--prune', target,
|
||||
'+refs/heads/*:refs/heads/*', '+refs/tags/*:refs/tags/*', env=env)
|
||||
require(refs(repository, target, env) == snapshot, 'GitHub references differ from the selected source snapshot')
|
||||
if refs(repository, source, env) == snapshot:
|
||||
save(directory / 'last-sync.json', {'refs': snapshot, 'verified_at': time.time()})
|
||||
return
|
||||
raise RuntimeError('Source kept changing; retry the mirror')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.parse_args()
|
||||
# A write-enabled deploy key limits GitHub access to this repository.
|
||||
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'}
|
||||
synchronize(DIRECTORY, SOURCE, TARGET, env)
|
||||
print('GitHub business references and history verified')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Exercise the real API in the internal, synthetic CI network only."""
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from websockets.asyncio.client import connect
|
||||
|
||||
from alphapulse.core.db import SessionLocal
|
||||
from alphapulse.core.redis import redis
|
||||
from alphapulse.models import EconomicRelease, NewsItem
|
||||
|
||||
|
||||
async def main():
|
||||
assert os.environ['DATABASE_URL'].startswith('postgresql+asyncpg://ci:ci-only@ap-ci-')
|
||||
now = datetime.now(UTC)
|
||||
async with SessionLocal() as session:
|
||||
if await session.scalar(select(NewsItem.id).where(NewsItem.external_id == 'ci-synthetic')) is None:
|
||||
session.add(NewsItem(source_code='wscn_news', external_id='ci-synthetic', url='https://example.invalid/ci',
|
||||
title='CI synthetic news', summary='Isolated fixture', content_text='CI synthetic news',
|
||||
content_html='<p>CI synthetic news</p>', published_at=now, content_hash='0' * 64))
|
||||
session.add(EconomicRelease(source='bls', external_id='ci-synthetic', family='cpi', title='CI CPI',
|
||||
period=now.strftime('%Y-%m'), scheduled_at=now, schedule_url='https://example.invalid/ci',
|
||||
schedule_checked_at=now, next_check_at=now,
|
||||
values={'cpi_yoy': {'actual': '2.0', 'previous': '2.1'}}))
|
||||
await session.commit()
|
||||
origin = 'http://' + os.environ['SMOKE_WEB']
|
||||
async with httpx.AsyncClient(base_url=origin, timeout=15, trust_env=False) as client:
|
||||
response = await client.get('/api/health')
|
||||
response.raise_for_status()
|
||||
assert response.json()['data']['commit'] == os.environ['APP_COMMIT']
|
||||
assert (await client.get('/api/news')).status_code == 401
|
||||
response = await client.post('/api/auth/login', json={'username': 'ci', 'password': 'ci-only'})
|
||||
response.raise_for_status()
|
||||
token = response.json()['data']['token']
|
||||
client.headers['Authorization'] = 'Bearer ' + token
|
||||
try:
|
||||
for path in ['/api/auth/me', '/api/news?q=CI', '/api/crawler/sources', '/api/market/symbols',
|
||||
'/api/skills/tree', '/api/trading/status', '/api/chat/session',
|
||||
'/api/calendar?start=' + now.strftime('%Y-%m-%d')]:
|
||||
response = await client.get(path)
|
||||
response.raise_for_status()
|
||||
assert response.json()['code'] == 0, path
|
||||
assert 'CI synthetic news' in (await client.get('/api/news?q=CI')).text
|
||||
assert 'CI CPI' in (await client.get('/api/calendar?start=' + now.strftime('%Y-%m-%d'))).text
|
||||
for path in ['/', '/news', '/calendar', '/crawler', '/market', '/trading', '/chat', '/skills', '/settings']:
|
||||
response = await client.get(path)
|
||||
assert response.status_code == 200 and '<html' in response.text.lower(), path
|
||||
assert (await client.get('/version.json')).json()['commit'] == os.environ['APP_COMMIT']
|
||||
schema = (await client.get('/api/openapi.json')).json()
|
||||
assert 'openapi' in schema and 'code' not in schema
|
||||
async with client.stream('GET', '/api/crawler/logs/stream') as stream:
|
||||
assert stream.status_code == 200 and 'text/event-stream' in stream.headers['content-type']
|
||||
async def publish_log():
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(.15)
|
||||
await redis.publish('ap:pub:logs', json.dumps({'source_code': 'wscn_news', 'message': 'ci-event'}))
|
||||
publisher = asyncio.create_task(publish_log())
|
||||
try:
|
||||
async with asyncio.timeout(8):
|
||||
async for line in stream.aiter_lines():
|
||||
if line.startswith('data:'):
|
||||
assert 'ci-event' in line
|
||||
break
|
||||
finally:
|
||||
publisher.cancel()
|
||||
await asyncio.gather(publisher, return_exceptions=True)
|
||||
ticket = (await client.post('/api/auth/ws-ticket')).json()['data']['ticket']
|
||||
async with connect('ws://' + os.environ['SMOKE_WEB'] + '/api/market/ws',
|
||||
subprotocols=['alphapulse.market.v1', 'ticket.' + ticket], proxy=None) as socket:
|
||||
await socket.send(json.dumps({'market': 'um', 'symbol': 'BTCUSDT', 'interval': '1m'}))
|
||||
async def publish_tick():
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(.15)
|
||||
await redis.publish('ap:pub:ticker:um:BTCUSDT', json.dumps({'price': '100'}))
|
||||
publisher = asyncio.create_task(publish_tick())
|
||||
try:
|
||||
event = json.loads(await asyncio.wait_for(socket.recv(), 8))
|
||||
assert event['type'] == 'ticker' and event['data']['price'] == '100'
|
||||
await client.post('/api/auth/logout')
|
||||
await asyncio.wait_for(socket.wait_closed(), 5)
|
||||
assert socket.close_code == 4401
|
||||
finally:
|
||||
publisher.cancel()
|
||||
await asyncio.gather(publisher, return_exceptions=True)
|
||||
assert (await client.get('/api/auth/me')).status_code == 401
|
||||
finally:
|
||||
await client.post('/api/auth/logout')
|
||||
await redis.aclose()
|
||||
print('Synthetic API, pages, SSE, WS, revocation and persistence passed')
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Verify publication gates, immutable identities and Git mirror reconciliation."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import os
|
||||
import fcntl
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
import ci
|
||||
import deploy
|
||||
import mirror
|
||||
|
||||
|
||||
class BuildTests(unittest.TestCase):
|
||||
def test_version_is_utc_commit_identity(self):
|
||||
timestamp = datetime(2026, 10, 29, 23, 59, tzinfo=UTC).timestamp()
|
||||
self.assertEqual(ci.version('abcdef0123456789' * 2 + 'abcdef01', timestamp), '20261029-abcdef01')
|
||||
with self.assertRaises(RuntimeError):
|
||||
ci.version('abcdef0', timestamp)
|
||||
|
||||
def test_existing_version_rejects_different_full_commit(self):
|
||||
commit = 'a' * 40
|
||||
manifest = {'config': {'digest': 'sha256:' + '0' * 64}}
|
||||
config = {'config': {'Labels': {'org.opencontainers.image.revision': 'a' * 39 + 'b',
|
||||
'org.opencontainers.image.version': '20261029-aaaaaaaa'}},
|
||||
'architecture': 'amd64', 'os': 'linux'}
|
||||
with patch.object(ci, 'registry_json', side_effect=[(manifest, 'sha256:' + '1' * 64), (config, '')]):
|
||||
with self.assertRaisesRegex(RuntimeError, 'different commit'):
|
||||
ci.existing_image('backend', {'commit': commit, 'version': '20261029-aaaaaaaa'})
|
||||
|
||||
def test_publication_rejects_pr_and_main_before_reading_artifacts(self):
|
||||
for event, ref in [('pull_request', 'refs/heads/dev'), ('push', 'refs/heads/main'), ('workflow_dispatch', 'refs/heads/dev')]:
|
||||
with self.subTest(event=event, ref=ref), patch.dict(os.environ, {'GITEA_EVENT_NAME': event, 'GITEA_REF': ref}):
|
||||
with self.assertRaisesRegex(RuntimeError, 'Only a dev push'):
|
||||
ci.publish()
|
||||
|
||||
def test_pending_deployment_blocks_all_updates(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
(directory / 'deployment.pending.json').write_text('{}')
|
||||
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'branch_head') as head:
|
||||
with self.assertRaisesRegex(RuntimeError, 'pending deployment'):
|
||||
deploy.deploy({}, directory)
|
||||
head.assert_not_called()
|
||||
|
||||
def test_stale_deployment_does_not_pull_or_change_instance(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
with patch.object(deploy, 'INSTANCE', directory), patch.object(deploy, 'branch_head', return_value='b' * 40), \
|
||||
patch.object(deploy, 'validate_images') as validate:
|
||||
deploy.deploy({'commit': 'a' * 40}, directory)
|
||||
validate.assert_not_called()
|
||||
self.assertEqual({p.name for p in directory.iterdir()}, {'.deploy.lock'})
|
||||
|
||||
def test_download_failure_is_not_a_missing_manifest(self):
|
||||
from urllib.error import HTTPError
|
||||
failure = HTTPError('https://git.134333.xyz/v2/x', 503, 'Unavailable', {}, None)
|
||||
with patch('urllib.request.urlopen', side_effect=failure):
|
||||
with self.assertRaises(HTTPError):
|
||||
ci.registry_json('x')
|
||||
|
||||
def test_deployment_lock_is_shared_across_processes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
lock_path = Path(temporary) / '.deploy.lock'
|
||||
code = 'import fcntl,sys; f=open(sys.argv[1],"a"); fcntl.flock(f,fcntl.LOCK_EX); print("locked",flush=True); sys.stdin.read(1)'
|
||||
process = subprocess.Popen([sys.executable, '-c', code, str(lock_path)], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
try:
|
||||
self.assertEqual(process.stdout.readline().strip(), 'locked')
|
||||
with lock_path.open('a') as lock:
|
||||
with self.assertRaises(BlockingIOError):
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
finally:
|
||||
process.communicate('x', timeout=5)
|
||||
with lock_path.open('a') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
|
||||
class MirrorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.directory = Path(self.temporary.name)
|
||||
self.source = self.directory / 'source'
|
||||
self.target = self.directory / 'target'
|
||||
self.work = self.directory / 'work'
|
||||
self.work.mkdir()
|
||||
ci.run('git', 'init', '--bare', self.source)
|
||||
ci.run('git', 'init', '--bare', self.target)
|
||||
checkout = self.directory / 'checkout'
|
||||
ci.run('git', 'init', '--initial-branch=main', checkout)
|
||||
ci.run('git', '-C', checkout, 'config', 'user.name', 'CI')
|
||||
ci.run('git', '-C', checkout, 'config', 'user.email', '[email protected]')
|
||||
(checkout / 'fixture').write_text('synthetic\n')
|
||||
ci.run('git', '-C', checkout, 'add', 'fixture')
|
||||
ci.run('git', '-C', checkout, 'commit', '-m', 'Synthetic fixture')
|
||||
ci.run('git', '-C', checkout, 'push', str(self.source), 'main')
|
||||
self.sha = ci.run('git', '-C', checkout, 'rev-parse', 'HEAD')
|
||||
|
||||
def test_all_business_refs_updates_deletions_and_annotated_tags(self):
|
||||
ci.run('git', '--git-dir', self.source, 'branch', 'dev', 'main')
|
||||
ci.run('git', '-c', 'user.name=CI', '-c', '[email protected]', '--git-dir', self.source,
|
||||
'tag', '-a', 'fixture-v1', '-m', 'Synthetic tag', 'main')
|
||||
mirror.synchronize(self.work, str(self.source), str(self.target))
|
||||
self.assertEqual(mirror.refs(self.source), mirror.refs(self.target))
|
||||
ci.run('git', '--git-dir', self.target, 'update-ref', 'refs/pull/1/head', self.sha)
|
||||
ci.run('git', '--git-dir', self.source, 'branch', '-D', 'dev')
|
||||
ci.run('git', '--git-dir', self.source, 'tag', '-d', 'fixture-v1')
|
||||
mirror.synchronize(self.work, str(self.source), str(self.target))
|
||||
self.assertEqual(mirror.refs(self.source), mirror.refs(self.target))
|
||||
self.assertEqual(ci.run('git', '--git-dir', self.target, 'rev-parse', 'refs/pull/1/head'), self.sha)
|
||||
self.assertTrue((self.work / 'last-sync.json').exists())
|
||||
|
||||
def test_missing_source_main_never_deletes_target(self):
|
||||
mirror.synchronize(self.work, str(self.source), str(self.target))
|
||||
before = mirror.refs(self.target)
|
||||
ci.run('git', '--git-dir', self.source, 'branch', 'dev', 'main')
|
||||
ci.run('git', '--git-dir', self.source, 'branch', '-D', 'main')
|
||||
with self.assertRaisesRegex(RuntimeError, 'Source main is missing'):
|
||||
mirror.synchronize(self.work, str(self.source), str(self.target))
|
||||
self.assertEqual(before, mirror.refs(self.target))
|
||||
|
||||
def test_failed_source_fetch_preserves_target(self):
|
||||
mirror.synchronize(self.work, str(self.source), str(self.target))
|
||||
before = mirror.refs(self.target)
|
||||
with self.assertRaises(RuntimeError):
|
||||
mirror.synchronize(self.work, str(self.directory / 'missing'), str(self.target))
|
||||
self.assertEqual(before, mirror.refs(self.target))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user