Add Gitea CI and automatic dev deployment
Main CI / check-and-build (push) Successful in 1m18s
Development CI / check-and-build (push) Successful in 34s
GitHub Git mirror / mirror (push) Successful in 7s
Development CI / deploy-development (push) Successful in 25s

This commit is contained in:
root committed 2026-10-29 21:30:28 +08:00
1 parent 543564a29c
commit b4f29d4966
20 files changed
+1109 -29

No files matched your search

+294
View File
@@ -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()
+208
View File
@@ -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()
+65
View File
@@ -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()
+96
View File
@@ -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())
+137
View File
@@ -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()