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

+83
View File
@@ -0,0 +1,83 @@
name: Development CI
on:
push:
branches: [dev]
permissions:
contents: read
concurrency:
group: alphapulse-dev
cancel-in-progress: false
jobs:
check-and-build:
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
runs-on: docker
timeout-minutes: 55
container:
image: docker:29-cli
volumes:
- /docker/gitea/mirrors/AlphaPulse:/docker/gitea/mirrors/AlphaPulse
outputs:
backend: ${{ steps.publish.outputs.backend }}
web: ${{ steps.publish.outputs.web }}
version: ${{ steps.publish.outputs.version }}
commit: ${{ steps.publish.outputs.commit }}
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
CI_RESOURCE_ID: dev-${{ gitea.run_id }}-${{ gitea.run_attempt }}
DOCKER_CONFIG: /tmp/alphapulse-docker-auth
steps:
- name: Install container tools
run: apk add --no-cache python3 nodejs git docker-cli-buildx docker-cli-compose
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
fetch-depth: 0
persist-credentials: false
- name: Test CI control behavior
run: python3 -m unittest discover -s scripts/tests -v
- name: Check, build and verify isolated services
run: python3 scripts/ci.py check
- name: Publish verified development images
id: publish
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/dev'
env:
ALPHAPULSE_GITEA_TOKEN: ${{ secrets.ALPHAPULSE_GITEA_TOKEN }}
run: |
mkdir -p "$DOCKER_CONFIG"
chmod 700 "$DOCKER_CONFIG"
printf '%s' "$ALPHAPULSE_GITEA_TOKEN" | docker login git.134333.xyz --username eezd --password-stdin
python3 scripts/ci.py publish
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: build-receipt
path: build.json
if-no-files-found: error
retention-days: 14
- name: Clean this attempt only
if: always()
run: |
python3 scripts/ci.py cleanup
rm -rf "$DOCKER_CONFIG"
deploy-development:
needs: check-and-build
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/dev'
runs-on: docker
timeout-minutes: 15
container:
image: docker:29-cli
volumes:
- /docker/gitea/dev/AlphaPulse:/docker/gitea/dev/AlphaPulse
steps:
- name: Install container tools
run: apk add --no-cache python3 nodejs git docker-cli-compose
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
- uses: https://github.com/actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333
with:
name: build-receipt
path: .
- name: Deploy the current accepted dev commit
run: python3 scripts/deploy.py update
+44
View File
@@ -0,0 +1,44 @@
name: Main CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: alphapulse-main
cancel-in-progress: false
jobs:
check-and-build:
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository
runs-on: docker
timeout-minutes: 55
env:
GITEA_SHA: ${{ gitea.sha }}
GITEA_REF: ${{ gitea.ref }}
GITEA_EVENT_NAME: ${{ gitea.event_name }}
CI_RESOURCE_ID: main-${{ gitea.run_id }}-${{ gitea.run_attempt }}
DOCKER_CONFIG: /tmp/alphapulse-docker-auth
steps:
- name: Install container tools
run: apk add --no-cache python3 nodejs git docker-cli-buildx docker-cli-compose
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
fetch-depth: 0
persist-credentials: false
- name: Test CI control behavior
run: python3 -m unittest discover -s scripts/tests -v
- name: Check, build and verify isolated services
run: python3 scripts/ci.py check
- uses: https://github.com/actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
with:
name: build-receipt
path: build.json
if-no-files-found: error
retention-days: 14
- name: Clean this attempt only
if: always()
run: |
python3 scripts/ci.py cleanup
rm -rf "$DOCKER_CONFIG"
+34
View File
@@ -0,0 +1,34 @@
name: GitHub Git mirror
on:
push:
branches: ['**']
tags: ['**']
create:
delete:
schedule:
- cron: '29 * * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: alphapulse-github-mirror
cancel-in-progress: false
jobs:
mirror:
runs-on: docker
timeout-minutes: 15
container:
image: docker:29-cli
volumes:
- /docker/gitea/mirrors/AlphaPulse:/docker/gitea/mirrors/AlphaPulse
steps:
- name: Install mirror tools
run: apk add --no-cache python3 nodejs git git-lfs
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
ref: main
persist-credentials: false
- name: Reconcile the current source references
env:
ALPHAPULSE_GITHUB_SSH_KEY: ${{ secrets.ALPHAPULSE_GITHUB_SSH_KEY }}
run: python3 scripts/mirror.py
+1
View File
@@ -4,3 +4,4 @@ backups/
.venv/ .venv/
__pycache__/ __pycache__/
/docs/ /docs/
/build.json
+2
View File
@@ -87,3 +87,5 @@
- 后端验证:`cd backend && uv run pytest`、`uvx ruff check --select F,E9,B src tests`。前端验证:`cd web && pnpm lint:check && pnpm typecheck && pnpm test && pnpm build`;测试使用现有 pytest、Node 内置测试及 TS/Vue 编译器,不另加测试框架。 - 后端验证:`cd backend && uv run pytest`、`uvx ruff check --select F,E9,B src tests`。前端验证:`cd web && pnpm lint:check && pnpm typecheck && pnpm test && pnpm build`;测试使用现有 pytest、Node 内置测试及 TS/Vue 编译器,不另加测试框架。
- Redis DB 14 与其他应用共用,只操作 `ap:*`,禁止 FLUSHDB/FLUSHALL。测试隔离数据库、Redis、模型和交易 I/O,不读取生产凭据,不 Reset 用户 Chat 或改真实 Skills;不自动启动采集或交易。 - Redis DB 14 与其他应用共用,只操作 `ap:*`,禁止 FLUSHDB/FLUSHALL。测试隔离数据库、Redis、模型和交易 I/O,不读取生产凭据,不 Reset 用户 Chat 或改真实 Skills;不自动启动采集或交易。
- 生产部署、重启、数据迁移执行、提交和推送均需本任务单独授权;不新增项目定时备份。本文件是唯一规范来源,目前没有其他工具规则入口;以后新增入口应链接本文件。 - 生产部署、重启、数据迁移执行、提交和推送均需本任务单独授权;不新增项目定时备份。本文件是唯一规范来源,目前没有其他工具规则入口;以后新增入口应链接本文件。
- CI/CD:Gitea 为唯一源,GitHub 单向镜像全部业务分支/TAG/历史;dev push 通过隔离检查后自动部署唯一实例,main 仅独立检查,不部署或正式发版。DEV 按用户决定复用现有业务数据与配置,域名为 `alpha.134333.xyz`,实例目录 `/docker/gitea/dev/AlphaPulse`;测试仍必须隔离。部署、恢复和回退统一使用 `scripts/deploy.py` 与实例 `.deploy.lock`,不直接绕过锁更新容器;数据库迁移继续独立授权。操作说明见 `deploy/README.md`。
+4
View File
@@ -1,3 +1,7 @@
.venv .venv
__pycache__ __pycache__
*.pyc *.pyc
.env*
.pytest_cache
.ruff_cache
dist
+4 -1
View File
@@ -1,3 +1,5 @@
import os
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -31,7 +33,8 @@ app.include_router(calendar.router)
@app.get("/api/health", include_in_schema=False) @app.get("/api/health", include_in_schema=False)
async def health() -> ApiResponse[dict[str, str]]: async def health() -> ApiResponse[dict[str, str]]:
return success({"status": "ok"}) return success({"status": "ok", "version": os.environ.get("APP_VERSION", "source"),
"commit": os.environ.get("APP_COMMIT", "unknown")})
@app.get("/api/openapi.json", include_in_schema=False, dependencies=[Depends(require_admin)]) @app.get("/api/openapi.json", include_in_schema=False, dependencies=[Depends(require_admin)])
+8 -1
View File
@@ -238,7 +238,14 @@ def test_protocol_status_validation_redaction_openapi(auth_app, monkeypatch):
monkeypatch.setattr(market, "tradable_symbols", AsyncMock(return_value=[{"symbol": "BTCUSDT", "base": "BTC", "quote": "USDT", "price_precision": 2, "volume_precision": 5}])) monkeypatch.setattr(market, "tradable_symbols", AsyncMock(return_value=[{"symbol": "BTCUSDT", "base": "BTC", "quote": "USDT", "price_precision": 2, "volume_precision": 5}]))
async def check(): async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client: async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client:
assert (await client.get("/api/health")).json() == {"code": 0, "data": {"status": "ok"}, "message": ""} assert (await client.get("/api/health")).json() == {
"code": 0, "data": {"status": "ok", "version": "source", "commit": "unknown"}, "message": "",
}
monkeypatch.setenv("APP_VERSION", "20261029-aaaaaaaa")
monkeypatch.setenv("APP_COMMIT", "a" * 40)
assert (await client.get("/api/health")).json()["data"] == {
"status": "ok", "version": "20261029-aaaaaaaa", "commit": "a" * 40,
}
invalid = await client.post("/api/auth/login", json={"password": {"sensitive": "do-not-echo"}}) invalid = await client.post("/api/auth/login", json={"password": {"sensitive": "do-not-echo"}})
assert invalid.status_code == 422 and invalid.json()["code"] == 422 assert invalid.status_code == 422 and invalid.json()["code"] == 422
assert "do-not-echo" not in invalid.text and "input" not in invalid.text assert "do-not-echo" not in invalid.text and "input" not in invalid.text
+13 -3
View File
@@ -1,8 +1,18 @@
FROM python:3.13-slim FROM python:3.13-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /usr/local/bin/uv COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /uvx /usr/local/bin/
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PROJECT_ENVIRONMENT=/opt/venv PATH=/opt/venv/bin:$PATH ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_NO_CACHE=1 UV_PROJECT_ENVIRONMENT=/opt/venv PATH=/opt/venv/bin:$PATH
WORKDIR /app WORKDIR /app
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project RUN uv sync --locked --no-dev --no-install-project
COPY . . COPY . .
RUN uv sync --locked --no-dev RUN uv sync --locked --no-dev
FROM base AS check
RUN uv sync --locked && uv run pytest && uvx ruff==0.14.14 check --select F,E9,B src tests
FROM base AS runtime
ARG APP_VERSION=source
ARG APP_COMMIT=unknown
ENV APP_VERSION=$APP_VERSION APP_COMMIT=$APP_COMMIT
LABEL org.opencontainers.image.version=$APP_VERSION org.opencontainers.image.revision=$APP_COMMIT
LABEL org.opencontainers.image.source=https://git.134333.xyz/eezd/AlphaPulse
+15 -4
View File
@@ -1,11 +1,22 @@
FROM node:24-alpine AS build FROM node:24-alpine AS check
RUN corepack enable RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm build RUN pnpm lint:check && pnpm typecheck && pnpm test
FROM nginx:alpine FROM check AS build
RUN pnpm build
ARG APP_VERSION=source
ARG APP_COMMIT=unknown
ENV APP_VERSION=$APP_VERSION APP_COMMIT=$APP_COMMIT
RUN node -e 'require("fs").writeFileSync("dist/version.json", JSON.stringify({version:process.env.APP_VERSION,commit:process.env.APP_COMMIT}))'
FROM nginx:alpine AS runtime
ARG APP_VERSION=source
ARG APP_COMMIT=unknown
LABEL org.opencontainers.image.version=$APP_VERSION org.opencontainers.image.revision=$APP_COMMIT
LABEL org.opencontainers.image.source=https://git.134333.xyz/eezd/AlphaPulse
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
+45
View File
@@ -0,0 +1,45 @@
# AlphaPulse CI/CD
Gitea (`https://git.134333.xyz/eezd/AlphaPulse`) is the source repository; GitHub is a public, one-way mirror of all branches, tags and history. Develop on `dev`. `main` has independent checks and no deployed instance or release workflow.
## Checks and images
Both branch workflows run Python 3.13/uv checks, Node 24/pnpm 12.11.2 checks, Linux amd64 image builds and acceptance against temporary PostgreSQL/Redis. Acceptance runs on an internal Docker network with synthetic data; no worker or external model/trading service starts there. Foreign PRs do not enter the shared Docker runner.
Only a `dev` push publishes the accepted images:
- `git.134333.xyz/eezd/dev/backend/alphapulse:YYYYMMDD-xxxxxxxx`
- `git.134333.xyz/eezd/dev/web/alphapulse:YYYYMMDD-xxxxxxxx`
The date comes from the commit's committer timestamp in UTC, and the suffix is the first eight characters of its full SHA. Existing versions are immutable. Images are public on Gitea only; private configuration and data are excluded. `/api/health` and `/version.json` identify the deployed API and web revision. `build.json` is the CI receipt, not a tracked source file.
## The single data-bearing instance
`/docker/gitea/dev/AlphaPulse` contains the runtime Compose configuration, `.env`, digest selection, deployment receipts and `.deploy.lock`. The repository-root `.env` points to that sole private configuration. The Compose project is `alphapulse-dev`; `AlphaPulse-dev` is the web container, and the other five service names append `-api`, `-crawler`, `-market`, `-calendar` or `-trader`.
This project's DEV intentionally uses the existing database, Redis DB 14 (`ap:*` only), model credentials and Demo trading configuration. The user authorized restoring all six services. It is the only instance, served at `https://alpha.134333.xyz` through `127.0.0.1:21005`. CI never uses these data for testing. Do not start a second Compose project against this configuration.
Automatic and manual updates use the same command and persistent lock:
```sh
python3 scripts/deploy.py update --build /path/to/accepted/build.json
```
The command checks image identities, current dev SHA and database migration compatibility before updating. It waits for active Chat/trading analysis, records pending state, then starts API/web before the workers. An outdated dev build is skipped. A pending deployment blocks updates. Do not invoke `docker compose up` directly to bypass the lock.
A failed update with a previous deployment restores the prior images/configuration while retaining data. With no previous deployment, the pending marker remains for diagnosis. Recovery and rollback are explicit operator commands:
```sh
python3 scripts/deploy.py recover
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.
## Credentials and mirroring
Gitea secrets are `ALPHAPULSE_GITEA_TOKEN` (image publication) and `ALPHAPULSE_GITHUB_SSH_KEY` (a write-enabled deploy key restricted to the GitHub mirror). Ordinary checks and the deployment job do not receive publication secrets. Credentials are removed from job-local files on exit.
Git mirroring is independent of build results. Push/create/delete events, hourly reconciliation and manual dispatch use the same `.mirror.lock` in `/docker/gitea/mirrors/AlphaPulse`. Only business heads/tags are managed; internal PR references are excluded. Missing or incomplete source reads fail rather than imply deletion. GitHub is not an independent development source. GitHub Actions is disabled on the mirror.
Only resources labeled for the current CI attempt are cleaned; there is no global Docker prune. The existing runner registration, capacity and other projects' mount allowances are preserved.
+25 -12
View File
@@ -1,43 +1,56 @@
name: alphapulse # Run through scripts/deploy.py so every deployment shares the instance lock.
name: alphapulse-dev
x-backend: &backend x-backend: &backend
build: image: ${ALPHAPULSE_BACKEND_IMAGE:?Set a verified backend digest}
context: ../backend env_file: .env
dockerfile: ../deploy/Dockerfile.backend
image: alphapulse-backend
env_file: ../.env
restart: unless-stopped restart: unless-stopped
stop_grace_period: 60s
networks: [dev_default] networks: [dev_default]
services: services:
api: api:
<<: *backend <<: *backend
container_name: AlphaPulse-dev-api
command: [uvicorn, alphapulse.api.main:app, --host, 0.0.0.0, --port, "8000", --proxy-headers, --forwarded-allow-ips, "172.18.0.0/16"] command: [uvicorn, alphapulse.api.main:app, --host, 0.0.0.0, --port, "8000", --proxy-headers, --forwarded-allow-ips, "172.18.0.0/16"]
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3)"]
interval: 5s
timeout: 4s
retries: 12
crawler: crawler:
<<: *backend <<: *backend
container_name: AlphaPulse-dev-crawler
command: [python, -m, alphapulse.crawler.manager] command: [python, -m, alphapulse.crawler.manager]
# 留时间给 scrapy 子进程优雅退出并刷完日志
stop_grace_period: 30s
market: market:
<<: *backend <<: *backend
container_name: AlphaPulse-dev-market
command: [python, -m, alphapulse.market.worker] command: [python, -m, alphapulse.market.worker]
trader: trader:
<<: *backend <<: *backend
container_name: AlphaPulse-dev-trader
command: [python, -m, alphapulse.trading.engine] command: [python, -m, alphapulse.trading.engine]
calendar: calendar:
<<: *backend <<: *backend
container_name: AlphaPulse-dev-calendar
command: [python, -m, alphapulse.calendar.worker] command: [python, -m, alphapulse.calendar.worker]
web: web:
build: image: ${ALPHAPULSE_WEB_IMAGE:?Set a verified web digest}
context: ../web container_name: AlphaPulse-dev
dockerfile: ../deploy/Dockerfile.web
restart: unless-stopped restart: unless-stopped
depends_on: [api] depends_on:
api:
condition: service_healthy
healthcheck:
test: [CMD, wget, -q, -O, /dev/null, "http://127.0.0.1/api/health"]
interval: 5s
timeout: 4s
retries: 12
ports: ports:
- 127.0.0.1:21005:80 - 127.0.0.1:21005:80
networks: [dev_default] networks: [dev_default]
+26 -6
View File
@@ -1,10 +1,30 @@
#!/bin/sh #!/bin/sh
# 迁移前先备份:只有一套环境,迁移即生产操作 # Migration remains an explicitly authorized operator action; never call this from CI.
set -eu set -eu
cd "$(dirname "$0")" umask 077
mkdir -p ../backups instance=/docker/gitea/dev/AlphaPulse
dump="../backups/alphapulse-premigrate-$(date +%Y%m%d-%H%M%S).dump" exec 9>"$instance/.deploy.lock"
flock -x 9
if [ -e "$instance/deployment.pending.json" ]; then
echo '存在未完成部署,请先处理部署状态' >&2
exit 1
fi
if [ -z "${ALPHAPULSE_BACKEND_IMAGE:-}" ]; then
echo '请通过 ALPHAPULSE_BACKEND_IMAGE 明确指定已验收的候选镜像摘要;迁移须独立授权' >&2
exit 1
fi
case "$ALPHAPULSE_BACKEND_IMAGE" in
git.134333.xyz/eezd/dev/backend/alphapulse@sha256:*) ;;
*) echo '迁移必须使用开发镜像摘要' >&2; exit 1 ;;
esac
if [ -n "$(docker ps -q --filter label=com.docker.compose.project=alphapulse-dev)" ]; then
echo '迁移前须在维护窗口内停止应用服务;不要并发运行采集或交易' >&2
exit 1
fi
# Keep migration backups explicit; no periodic backup is introduced.
backup_dir="$(dirname "$0")/../backups"
mkdir -p "$backup_dir"
dump="$backup_dir/alphapulse-premigrate-$(date +%Y%m%d-%H%M%S).dump"
docker exec postgres pg_dump -U alphapulse -Fc alphapulse > "$dump" docker exec postgres pg_dump -U alphapulse -Fc alphapulse > "$dump"
echo "已备份:$dump" echo "已备份:$dump"
docker compose build api docker run --rm --network dev_default --env-file "$instance/.env" "$ALPHAPULSE_BACKEND_IMAGE" alembic upgrade head
docker compose run --rm -T api alembic upgrade head
+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()
+1
View File
@@ -1,2 +1,3 @@
node_modules node_modules
dist dist
.env*
+4 -2
View File
@@ -1,11 +1,13 @@
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ { location /api/ {
proxy_pass http://api:8000; set $api_upstream api:8000;
proxy_pass http://$api_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
# SSE 不能被缓冲;WebSocket 需要升级头 # Preserve SSE delivery and WebSocket upgrades.
proxy_buffering off; proxy_buffering off;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;