From 2cad6d7533fa454ca49efe6d451cad8862ec408a Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 9 Oct 2026 20:42:31 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20=E9=85=8D=E7=BD=AE=20master=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=AA=8C=E8=AF=81=E4=B8=8E=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 15 +++++ .gitea/workflows/ci-cd.yml | 90 ++++++++++++++++++++++++++++ .github/workflows/check.yml | 4 +- .gitignore | 4 ++ AGENTS.md | 2 + Dockerfile | 43 ++++++++++++++ README.md | 24 ++++++-- deploy/README.md | 41 +++++++++++++ deploy/bootstrap-config.sh | 12 ++++ deploy/check.sh | 26 ++++++++ deploy/docker-compose.yml | 25 ++++++++ deploy/release.sh | 51 ++++++++++++++++ deploy/tests/test_release.py | 112 +++++++++++++++++++++++++++++++++++ docs/development-state.md | 64 ++++++++++++++++++-- 14 files changed, 504 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/ci-cd.yml create mode 100644 Dockerfile create mode 100644 deploy/README.md create mode 100644 deploy/bootstrap-config.sh create mode 100644 deploy/check.sh create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/release.sh create mode 100644 deploy/tests/test_release.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b247b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.github +.gitea +**/node_modules +**/dist +**/.expo +**/.env +**/.env.* +**/*.log +bin +logs +.cache +.idea +.DS_Store +**/__pycache__ diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..54737ee --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,90 @@ +name: Veyra CI/CD +on: + push: + branches: [master] +env: + REMOTE_DIR: /home/workspace/sundynix-veyra +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout exact commit from Gitea + env: + GITEA_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \ + "https://git.sundynix.cn/${REPOSITORY}/archive/${COMMIT_SHA}.tar.gz" \ + | tar -xz --strip-components=1 + - name: Check App, embedded admin, Go and PostgreSQL integration + run: bash deploy/check.sh + deploy: + needs: verify + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - name: Checkout verified commit from Gitea + env: + GITEA_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \ + "https://git.sundynix.cn/${REPOSITORY}/archive/${COMMIT_SHA}.tar.gz" \ + | tar -xz --strip-components=1 + - name: Build and package immutable image + env: + COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + docker build -t "sundynix/veyra:${COMMIT_SHA}" . + docker save "sundynix/veyra:${COMMIT_SHA}" | gzip > image.tar.gz + sha256sum image.tar.gz > image.tar.gz.sha256 + - name: Install SSH tools + run: | + set -eu + if command -v sshpass >/dev/null && command -v ssh >/dev/null; then exit 0; fi + if command -v apk >/dev/null; then + apk add --no-cache sshpass openssh-client + elif command -v apt-get >/dev/null; then + apt-get update -qq + apt-get install -y -qq --no-install-recommends sshpass openssh-client + else + echo 'Runner requires ssh and sshpass' >&2 + exit 1 + fi + - name: Transfer and deploy + env: + SSHPASS: ${{ secrets.DEPLOY_PASSWORD }} + DEPLOY_HOST: 192.168.100.132 + DEPLOY_USER: root + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + VEYRA_ENV_FILE: ${{ secrets.VEYRA_ENV_FILE }} + COMMIT_SHA: ${{ github.sha }} + run: | + set -euo pipefail + : "${SSHPASS:?DEPLOY_PASSWORD is required}" + : "${DEPLOY_HOST:?DEPLOY_HOST is required}" + : "${DEPLOY_USER:?DEPLOY_USER is required}" + : "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS is required}" + [[ "$DEPLOY_HOST" =~ ^[a-zA-Z0-9.-]+$ && "$DEPLOY_USER" =~ ^[a-zA-Z0-9_-]+$ ]] || exit 2 + [[ "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]] || exit 2 + known_hosts="$(mktemp)" + trap 'rm -f "$known_hosts"' EXIT + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$known_hosts" + chmod 600 "$known_hosts" + options=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts" -o ConnectTimeout=15) + target="$DEPLOY_USER@$DEPLOY_HOST" + release="$REMOTE_DIR/releases/$COMMIT_SHA" + sshpass -e ssh "${options[@]}" "$target" "umask 077; mkdir -p '$release' '$REMOTE_DIR/configs'" + sshpass -e scp "${options[@]}" deploy/bootstrap-config.sh "$target:$release/" + # Bootstrap once via secret; never replace an existing server configuration. + if [[ -n "$VEYRA_ENV_FILE" ]]; then + printf '%s\n' "$VEYRA_ENV_FILE" | sshpass -e ssh "${options[@]}" "$target" \ + "bash '$release/bootstrap-config.sh' '$REMOTE_DIR'" + fi + sshpass -e scp "${options[@]}" image.tar.gz image.tar.gz.sha256 deploy/docker-compose.yml deploy/release.sh "$target:$release/" + sshpass -e ssh "${options[@]}" "$target" "bash '$release/release.sh' '$REMOTE_DIR' '$COMMIT_SHA'" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d7300ce..cfb7c64 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,5 +1,7 @@ name: Checks -on: [push, pull_request] +on: + push: + branches: [master] jobs: verify: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 83899e6..7285e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ internal/adminui/dist/* expo-env.d.ts /logs/ + +# CI 临时镜像归档和 Python 缓存 +/image.tar.gz* +**/__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index f6e06c9..b8d2935 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,5 @@ - 后端环境文件与模板位于 configs/;管理端和 App 分别维护自己的 npm 配置、锁文件与依赖,不在根目录建立 npm workspace。 - 后端日志使用 zap:控制台易读,当前运行目录 logs/ 下自动写文件与轮转;日志不提交,不记录密钥、口令、Cookie 或请求正文。 + +- 开发在 dev 分支进行,禁止直接提交或推送 master(管理员也须遵守);通过合并请求进入 master。CI/CD 仅监听 master 的 push,包含合并提交;dev 提交不触发部署。 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ab4f00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +FROM node:22-bookworm-slim AS admin +WORKDIR /src/web/admin +COPY web/admin/package*.json ./ +RUN npm ci +COPY web/admin/ ./ +RUN npm run build + +FROM node:22-bookworm-slim AS mobile-check +WORKDIR /src/apps/mobile +ENV CI=1 EXPO_PUBLIC_API_URL=https://example.invalid +COPY apps/mobile/package*.json ./ +RUN npm ci +COPY apps/mobile/ ./ +RUN npm run typecheck && npm run test && npm run build + +FROM golang:1.26.2-bookworm AS test +ENV GOPROXY=https://goproxy.cn,direct +WORKDIR /src +RUN apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=admin /src/internal/adminui/dist ./internal/adminui/dist +RUN test -z "$(gofmt -l main.go internal)" && go vet ./... +CMD ["go", "test", "-race", "./..."] + +FROM test AS build +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/veyra . + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata python3 curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 veyra && useradd --uid 10001 --gid 10001 --no-create-home veyra +WORKDIR /app +ENV TZ=Asia/Shanghai HTTP_ADDR=0.0.0.0:8080 PYTHON_EXECUTABLE=python3 PYTHONDONTWRITEBYTECODE=1 +COPY --from=build /out/veyra ./veyra +RUN mkdir logs && chown veyra:veyra logs +USER 10001:10001 +EXPOSE 8080 +HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \ + CMD curl --fail --silent --output /dev/null http://127.0.0.1:8080/readyz || exit 1 +ENTRYPOINT ["/app/veyra"] diff --git a/README.md b/README.md index ea46711..65e1745 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ npm --prefix apps/mobile run start ### 后台供应商配置 -「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,当前尚未实现供应商调用与连通性测试。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。 +「模型配置」填写主模型、备用模型和审核模型;「系统设置」填写行情源。供应商名称、HTTPS 地址、模型名称和密钥初始均为空,不预选供应商。未配置时保持关闭;模型启用需要名称、地址、模型名称和密钥,行情源启用需要名称和地址。保存或启用不代表连接已验证,模型调用与连通性测试尚未实现;行情适配器已接通,实际状态见数据健康页面。协议、模型目录、响应存储策略和认证开关均为后台可修改的配置,不预填厂家地址或模型。未填写模型时可先保存关闭状态的连接地址和密钥。模型目录需与接口同源,避免把认证信息指向另一个站点。 `SECRET_ENCRYPTION_KEY` 是部署层加密主密钥,使用 Base64 编码的 32 字节随机值,在本地 `configs/.env` 或部署秘密配置中持久保存。本机已生成此值,仓库模板留空。可用 `openssl rand -base64 32` 为新环境生成;主密钥必须与数据库一起备份,切勿在已有密文后直接替换。未配置主密钥时仍可保存无密钥的关闭配置,提交密钥会被拒绝。 @@ -144,9 +144,9 @@ go.mod / go.sum 根目录唯一 Go module ## 待完成 -2026-10-08 已验证 PostgreSQL、Redis 内网连接,应用全部四版迁移并初始化开发管理员。真实 PostgreSQL 集成测试已在临时 schema 中执行;测试不会修改开发账户或供应商配置。 +2026-10-09 已验证 PostgreSQL、Redis 内网连接,应用全部十四版迁移并初始化开发管理员。真实 PostgreSQL 集成测试已在临时 schema 中执行;测试不会修改开发账户或供应商配置。 -尚未接入候选审核、交易计划、成交账本、行情、AI、Worker 或回测。App 已有基础界面和登录/账户真实 API,偏好、机会、计划、持仓明细、提醒与复盘仍待相应业务接口。下一步建设候选修订、审核发布与计划闭环,再落实成交账本和风险监控。 +候选审核、计划预算、不可覆盖模拟成交与冲正、FIFO 持仓与账本复盘、站内提醒、公开行情、异步 Python 回测和自动日线筛选已接通。App 对应 API 与页面已实现,真机流程仍待验证。AI、偏好/关注、正式认证、远程推送与生产交易规则尚未完成;详细验证事实见 docs/development-state.md。 管理端基础组件按 [shadcn/ui 的 Vite 集成方式](https://ui.shadcn.com/docs/installation/vite)维护在 `src/components/ui`,Tailwind 使用[官方 Vite 插件](https://tailwindcss.com/docs/installation/using-vite)。 @@ -154,6 +154,22 @@ go.mod / go.sum 根目录唯一 Go module 后端使用 zap,默认在运行目录创建 `logs/veyra.log`,控制台易读、文件 JSON,自动轮转压缩。通过 `configs/.env` 的 `LOG_*` 配置目录、级别和保留规则。 -后台「系统设置」配置公开行情适配器与地址,启用后启动同步、每五分钟更新沪深报价;「数据健康」查询股票和同步结果。研究回测可直接按代码、交易所和日期获取不复权日线,无需逐只新建股票或导入 CSV。当前公开源为开发研究用途;供应商时间、历史快照与失败状态如实展示。策略筛选、正式行情授权和实际交易规则仍待接入。 +后台「系统设置」配置公开行情适配器与地址,启用后启动同步、每五分钟更新沪深报价;「数据健康」查询股票和同步结果。研究回测可直接按代码、交易所和日期获取不复权日线,无需逐只新建股票或导入 CSV。当前公开源为开发研究用途;供应商时间、历史快照与失败状态如实展示。可配置策略筛选已接通;正式行情授权和完整实际交易规则仍待接入。 App 在「机会」→「查看自动同步的 A 股行情」查看数据。当前预览 Metro `exp://192.168.100.2:8081`,API 为 `http://192.168.100.2:8083`,同一内网使用 Expo Go;开发身份读取后端本地 `DEV_USER_*` 配置。尚未完成真机界面验证。 + + +## 自动候选筛选与提醒 + +管理端「策略中心」修订研究版本,勾选自动候选筛选,从自动同步股票搜索选择沪深范围(最多 100 只),配置整数基点价格规则和有效时长,再选用此版本。已有策略默认不启用此功能,价格规则留空由后台配置。无需手工新建股票或逐条录入候选证据。 + +Worker 每轮最多处理 20 个目标,完成后间隔 5 分钟再次检查。仅使用完整未复权日线,最近报价观察不超过 24 小时,来源与日期必须一致。符合 SMA 上穿规则时自动提交候选待审核;没有信号或数据不足不生成候选。候选经人工审核发布后才进入 App「机会」。参数、日线快照、来源与版本摘要可在筛选记录中查看,不代表已验证策略收益。 + +暂停或切换策略版本会使其自动候选及未完成计划失效并释放保留预算;手工候选独立维护,成交事实与持仓保留。行情同步为有效计划生成近期报价事件,只允许最近一分钟的供应商报价触发一次站内提醒,重复报价及失效来源跳过。提醒不产生成交、不改变资金,五分钟轮询不保证捕获所有瞬时行情。 + + +管理端总览现以股票为核心,展示实际行情、证券代码、日线与待审核信号;“股票行情”提供股票池与历史走势,“候选审核”可立即请求自动筛选并查看运行状态。固定范围每批最多检查 20 只;重复点击运行中的任务不会另起 Worker。后台已启用开发基线的固定 96 只范围,并从真实日线生成两条待审核候选,尚未自动发布。参数仅用于开发研究,可在策略中心修订。 + +## CI/CD 自动部署 + +开发使用 `dev`,通过 PR 合并进入受保护的 `master`;仅 `master` push 触发 CI/CD。Gitea Runner 验证后,通过 SSH 部署内嵌管理端的 Go 镜像至 `192.168.100.132:/home/workspace/sundynix-veyra`,执行版本化迁移、依赖就绪检查与应用回滚。详见 [部署说明](deploy/README.md)。 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..65fc41a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,41 @@ +# Veyra 自动部署 + +开发在 `dev` 分支进行,不直接提交或推送 `master`。Gitea 中的 `master` 保护规则禁用直接推送、强制推送和管理员绕过;通过合并请求进入 `master`。流水线只监听 `master` 的 push,合并提交也会触发;不监听 `dev` 或 PR 事件。因此合并前由本地检查把关,流水线在合并后验证,验证失败不部署。 + +## 当前部署目标 + +- Gitea:`https://git.sundynix.cn/Blizzard/Veyra`,使用已有全局 Runner 的 `ubuntu-latest` 标签。 +- SSH:`root@192.168.100.132`;目录:`/home/workspace/sundynix-veyra`。 +- 默认管理端与 API:`http://192.168.100.132:8080`,同一个镜像、同源服务。 +- 应用配置:服务器 `configs/.env`,权限 600;日志:服务器 `logs/`,由容器 UID 10001 写入并轮转。 +- PostgreSQL、Redis 使用已有内网服务;不额外部署数据库容器,不覆盖数据、身份或加密主密钥。 + +仓库 Actions 已启用,部署使用 `DEPLOY_PASSWORD`、`DEPLOY_KNOWN_HOSTS` 两个仓库 Secret。服务器地址、用户和目录在工作流中明确配置;SSH 严格校验已知服务器公钥。可选 `VEYRA_ENV_FILE` 仅用于新服务器首次创建配置,已有文件永不覆盖。当前服务器已通过 SSH 安全初始化,无需设置此可选 Secret。 + +应用环境文件必须采用无外层引号的 `KEY=VALUE` 格式。Compose 使用 `env_file.format: raw`,保留密码中的 `$`、`#` 等字符,不把应用配置作为 Compose 插值文件。需要 Docker、Compose >=2.30、bash、flock、gzip、sha256sum;当前服务器 Compose 2.36.2 满足要求。 + +## 流水线 + +1. 使用 Gitea job token 下载当前提交归档,与参考项目保持一致,避免 Git HTTP pack 检出问题。 +2. Docker 内检查 App 类型、12 项客户端测试、iOS/Android Hermes 导出;管理端类型检查、构建、Go embed;gofmt、go vet 与 Go race 测试。构建阶段使用已验证可达的 `https://goproxy.cn,direct` 下载 Go 依赖,保留默认 checksum 校验;运行镜像不包含此构建设置。PostgreSQL 集成测试使用独立、用后清理的 CI 容器,不访问开发库。部署脚本也有成功、校验失败、迁移失败、健康失败回滚、配置保留测试。 +3. 所有检查通过才构建 `sundynix/veyra:<完整提交 SHA>`,打包压缩并生成 SHA-256 校验文件,通过 SSH 传入服务器的独立发布目录。 +4. 服务器加锁串行发布,校验压缩包、加载镜像,执行同一可执行文件的 `-command db-create` 和 `-command db-migrate`,再启动 Compose。 +5. `/readyz` 检查 PostgreSQL 与 Redis。新版本就绪后才更新 `.current-image`;失败则恢复上一版镜像与 Compose,首次部署失败则停止应用。迁移失败不替换运行中的服务。不会自动回退数据库迁移;后续迁移应保持与上一版应用兼容,破坏性迁移需单独设计。 + +回测 Python 引擎和管理端已包含在运行镜像中;App 导出仅用于 CI 检查,不是 TestFlight、IPA 发布或 Expo OTA 更新。 + +目前认证与行情、交易研究仍处于开发阶段,服务器沿用本地 `APP_ENV=local` 配置以支持现有开发身份。此部署不代表生产认证、安全与正式行情授权已完成;改为生产环境前需完成正式登录流程。 + +## 本地检查 + +```sh +bash deploy/check.sh # 需要运行中的 Docker;完成容器内全部检查 +python3 deploy/tests/test_release.py # 无需 Docker,检查发布决策与配置保留 +npm --prefix web/admin run build +go vet ./... +go build -trimpath -o bin/veyra . +``` + +服务器排查使用 `docker compose --env-file /dev/null -f docker-compose.yml`,同时将 `VEYRA_IMAGE` 设为 `.current-image` 中的值。不要将含凭据的 `docker compose config`、环境文件或容器完整 inspect 输出到日志;配置语法检查使用 `config --quiet`。 + +配置语义参考 [Gitea Actions](https://docs.gitea.com/usage/actions/) 与 [Docker Compose env_file](https://docs.docker.com/reference/compose-file/services/#env_file)。 diff --git a/deploy/bootstrap-config.sh b/deploy/bootstrap-config.sh new file mode 100644 index 0000000..1266b15 --- /dev/null +++ b/deploy/bootstrap-config.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +root="${1:?deployment directory required}" +[[ "$root" == /* && "$root" != / && "$root" != *[!a-zA-Z0-9_/-]* ]] || exit 2 +umask 077 +mkdir -p "$root/configs" +tmp="$(mktemp "$root/configs/.env.XXXXXX")" +trap 'rm -f "$tmp"' EXIT +cat > "$tmp" +[[ -s "$tmp" ]] || exit 1 +# Atomic create-if-absent. Existing configuration and encryption key win. +ln "$tmp" "$root/configs/.env" 2>/dev/null || test -s "$root/configs/.env" diff --git a/deploy/check.sh b/deploy/check.sh new file mode 100644 index 0000000..f409832 --- /dev/null +++ b/deploy/check.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +run_id="veyra-ci-$(date +%s)-${RANDOM}" +network="$run_id" +postgres="$run_id-postgres" +cleanup() { + docker rm -f "$postgres" >/dev/null 2>&1 || true + docker network rm "$network" >/dev/null 2>&1 || true + docker image rm "veyra-mobile-check:$run_id" "veyra-test:$run_id" >/dev/null 2>&1 || true +} +trap cleanup EXIT +docker build --target mobile-check -t "veyra-mobile-check:$run_id" . +docker build --target test -t "veyra-test:$run_id" . +docker run --rm --entrypoint python3 "veyra-test:$run_id" /src/deploy/tests/test_release.py +docker network create "$network" >/dev/null +docker run -d --name "$postgres" --network "$network" --network-alias postgres \ + -e POSTGRES_USER=veyra_test -e POSTGRES_PASSWORD=test_only -e POSTGRES_DB=veyra_test postgres:17 >/dev/null +ready=false +for ((i=0; i<60; i++)); do + if docker exec "$postgres" pg_isready -U veyra_test -d veyra_test >/dev/null 2>&1; then ready=true; break; fi + sleep 1 +done +if [[ "$ready" != true ]]; then echo 'CI PostgreSQL did not become ready' >&2; exit 1; fi +docker run --rm --network "$network" \ + -e VEYRA_TEST_DATABASE_URL='postgres://veyra_test:test_only@postgres:5432/veyra_test?sslmode=disable' \ + "veyra-test:$run_id" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..9850a24 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,25 @@ +name: veyra +services: + app: + image: ${VEYRA_IMAGE:?VEYRA_IMAGE is required} + restart: unless-stopped + init: true + env_file: + - path: ./configs/.env + format: raw + environment: + HTTP_ADDR: 0.0.0.0:8080 + LOG_DIR: logs + PYTHON_EXECUTABLE: python3 + ports: + - "${VEYRA_BIND_ADDR:-0.0.0.0}:${VEYRA_PORT:-8080}:8080" + volumes: + - ./logs:/app/logs + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + stop_grace_period: 30s diff --git a/deploy/release.sh b/deploy/release.sh new file mode 100644 index 0000000..cd0605f --- /dev/null +++ b/deploy/release.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Run on the deployment host: release.sh /home/workspace/veyra +set -euo pipefail +root="${1:?deployment directory required}" +sha="${2:?commit SHA required}" +[[ "$root" == /* && "$root" != / && "$root" != *[!a-zA-Z0-9_/-]* ]] || exit 2 +[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 2 +release="$root/releases/$sha" +image="sundynix/veyra:$sha" +cd "$root" +# Serialize releases even when several master commits queue up. +exec 9> .deploy.lock +flock -w 600 9 +if [[ ! -s configs/.env ]]; then echo 'Missing server configs/.env (or VEYRA_ENV_FILE bootstrap secret)' >&2; exit 1; fi +(cd "$release" && sha256sum -c image.tar.gz.sha256) +gunzip -c "$release/image.tar.gz" | docker load +mkdir -p logs +docker run --rm --user 0:0 --entrypoint sh -v "$root/logs:/app/logs" "$image" -c 'chown 10001:10001 /app/logs' +previous='' +if [[ -s .current-image ]]; then + previous="$(cat .current-image)" + [[ "$previous" =~ ^sundynix/veyra:[0-9a-f]{40}$ ]] || exit 2 +fi +# Keep the previous Compose file as well as the previously healthy image. +if [[ -f docker-compose.yml ]]; then cp docker-compose.yml "$release/previous-compose.yml"; fi +cp "$release/docker-compose.yml" docker-compose.yml +export VEYRA_IMAGE="$image" +# Use an empty interpolation file: application secrets are only read via raw env_file. +compose() { docker compose --env-file /dev/null -f "$root/docker-compose.yml" "$@"; } +if ! compose run --rm --no-deps app -command db-create || ! compose run --rm --no-deps app -command db-migrate; then + if [[ -f "$release/previous-compose.yml" ]]; then cp "$release/previous-compose.yml" docker-compose.yml; fi + echo 'Database preparation failed; existing application was not replaced' >&2 + exit 1 +fi +if compose up -d --no-build --wait --wait-timeout 120; then + printf '%s\n' "$image" > .current-image.tmp + mv .current-image.tmp .current-image + rm -f "$release/image.tar.gz" + echo "Veyra deployed: $sha" +else + echo 'New application failed readiness; restoring previous application' >&2 + if [[ -n "$previous" && -f "$release/previous-compose.yml" ]]; then + cp "$release/previous-compose.yml" docker-compose.yml + export VEYRA_IMAGE="$previous" + compose up -d --no-build --wait --wait-timeout 120 + else + compose stop app + fi + # Schema migrations are never automatically undone. + exit 1 +fi diff --git a/deploy/tests/test_release.py b/deploy/tests/test_release.py new file mode 100644 index 0000000..e02c03f --- /dev/null +++ b/deploy/tests/test_release.py @@ -0,0 +1,112 @@ +"""Verify deployment decisions without connecting to a server or Docker daemon.""" +import gzip +import hashlib +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPTS = Path(__file__).resolve().parents[1] +SHA = "a" * 40 +OLD = "sundynix/veyra:" + "b" * 40 + +class ReleaseTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="veyra-release-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + (self.root / "configs").mkdir() + (self.root / "configs/.env").write_text("TEST_SETTING=unchanged\n") + self.release = self.root / "releases" / SHA + self.release.mkdir(parents=True) + archive = gzip.compress(b"fake image") + (self.release / "image.tar.gz").write_bytes(archive) + digest = hashlib.sha256(archive).hexdigest() + (self.release / "image.tar.gz.sha256").write_text(digest + " image.tar.gz\n") + (self.release / "docker-compose.yml").write_text("new compose\n") + (self.root / "docker-compose.yml").write_text("old compose\n") + (self.root / ".current-image").write_text(OLD + "\n") + tools = self.root / "tools" + tools.mkdir() + mock = tools / "docker" + mock.write_text(r"""#!/usr/bin/env python3 +import os, sys +from pathlib import Path +args = sys.argv[1:] +with open(os.environ['MOCK_LOG'], 'a') as log: + log.write(os.environ.get('VEYRA_IMAGE', '') + ' ' + ' '.join(args) + '\n') +if args and args[0] == 'load': + sys.stdin.buffer.read() +if '-command' in args and args[-1] == 'db-migrate' and os.environ.get('FAIL_MIGRATION'): + sys.exit(1) +if 'up' in args and os.environ.get('FAIL_READINESS') and os.environ.get('VEYRA_IMAGE') != os.environ['OLD_IMAGE']: + sys.exit(1) +""") + mock.chmod(0o755) + # macOS has no flock; emulate it for this single-process decision test. + if not __import__('shutil').which('flock'): + (tools / 'flock').write_text('#!/bin/sh\nexit 0\n') + (tools / 'flock').chmod(0o755) + if not __import__('shutil').which('sha256sum'): + checksum = tools / 'sha256sum' + checksum.write_text("""#!/usr/bin/env python3 +import hashlib, sys +from pathlib import Path +for line in Path(sys.argv[2]).read_text().splitlines(): + digest, name = line.split(' ', 1) + if hashlib.sha256(Path(name).read_bytes()).hexdigest() != digest: + sys.exit(1) +""") + checksum.chmod(0o755) + self.env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'], + MOCK_LOG=str(self.root / 'commands'), OLD_IMAGE=OLD) + + def run_release(self, **flags): + return subprocess.run(['bash', str(SCRIPTS / 'release.sh'), str(self.root), SHA], + env=dict(self.env, **flags), capture_output=True, text=True) + + def test_success_promotes_exact_commit(self): + result = self.run_release() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.root / '.current-image').read_text().strip(), 'sundynix/veyra:' + SHA) + self.assertEqual((self.root / 'configs/.env').read_text(), 'TEST_SETTING=unchanged\n') + self.assertFalse((self.release / 'image.tar.gz').exists()) + calls = (self.root / 'commands').read_text() + self.assertLess(calls.index('db-migrate'), calls.index(' up ')) + + def test_failed_readiness_restores_previous_application(self): + self.assertNotEqual(self.run_release(FAIL_READINESS='1').returncode, 0) + self.assertEqual((self.root / '.current-image').read_text().strip(), OLD) + self.assertEqual((self.root / 'docker-compose.yml').read_text(), 'old compose\n') + self.assertIn(OLD + ' compose', (self.root / 'commands').read_text()) + + def test_failed_migration_does_not_replace_running_application(self): + self.assertNotEqual(self.run_release(FAIL_MIGRATION='1').returncode, 0) + self.assertNotIn(' up ', (self.root / 'commands').read_text()) + self.assertEqual((self.root / 'docker-compose.yml').read_text(), 'old compose\n') + + def test_invalid_archive_never_loads_image(self): + (self.release / 'image.tar.gz').write_bytes(b'corrupt') + self.assertNotEqual(self.run_release().returncode, 0) + self.assertFalse((self.root / 'commands').exists()) + + def test_missing_configuration_fails_before_image_load(self): + (self.root / 'configs/.env').unlink() + self.assertNotEqual(self.run_release().returncode, 0) + self.assertFalse((self.root / 'commands').exists()) + + def test_bootstrap_preserves_existing_configuration(self): + for _ in range(2): + result = subprocess.run(['bash', str(SCRIPTS / 'bootstrap-config.sh'), str(self.root)], + input='TEST_SETTING=new\n', capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.root / 'configs/.env').read_text(), 'TEST_SETTING=unchanged\n') + (self.root / 'configs/.env').unlink() + result = subprocess.run(['bash', str(SCRIPTS / 'bootstrap-config.sh'), str(self.root)], + input='TEST_SETTING=new\n', capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.root / 'configs/.env').stat().st_mode & 0o777, 0o600) + +if __name__ == '__main__': + unittest.main() diff --git a/docs/development-state.md b/docs/development-state.md index d2e0d4c..414893e 100644 --- a/docs/development-state.md +++ b/docs/development-state.md @@ -1,6 +1,62 @@ # 开发记忆与交接状态 -更新日期:2026-10-08。 +更新日期:2026-10-09。 + +## 最新 CI/CD 配置(2026-10-09) + +- 用户要求参考 sundynix-pets 接入自动部署,并指定先创建 dev、禁止直接提交 master、CI/CD 只由 master push(包括 PR 合并)触发。已创建 dev 并保留当天全部修改;AGENTS.md 已记录分支规则。 +- Gitea 仓库 Actions 已启用;全局 sundynix-global-128 Runner 在线,标签 ubuntu-latest。已在仓库网页保存 DEPLOY_PASSWORD 与 DEPLOY_KNOWN_HOSTS Secret,不在代码或文档记录凭据。 +- 已在 Gitea 创建 master 分支保护:禁用推送、禁用强制推送、管理员须遵守,无绕过白名单;保留通过 PR 合并。按用户仅 master 触发要求,不配置依赖 dev/PR 流水线的合并前状态检查。 +- 部署目标为 192.168.100.132:/home/workspace/sundynix-veyra,默认端口 8080;SSH 已验证。服务器 Docker 28.2.2、Compose 2.36.2、flock 可用,检查时 8080 未监听。 +- 服务器 configs/.env 已经通过 SSH 初始化为 600 权限,使用已有开发配置与加密主密钥,不覆盖已存在的服务器配置,不包含 SSH 部署字段。保持开发环境认证,不声称正式生产认证已实现。 +- 新增 .gitea/workflows/ci-cd.yml、根 Dockerfile/.dockerignore 和 deploy/ 脚本与说明。Gitea token 归档检出、Docker 内验证 App/管理端/Go/独立 PostgreSQL,成功后构建完整 SHA 镜像并 SSH 传输校验包。运行镜像包含管理端 embed、Python、CA、时区,UID 10001,日志 bind 至部署目录 logs/。 +- 发布脚本加锁,保留服务器配置,先建库/迁移,再等待 /readyz;健康失败回滚上一镜像与 Compose,迁移失败不替换原运行服务。数据库迁移不自动回退。脚本测试覆盖成功、迁移失败、健康失败、损坏包、缺配置与首次配置保留,共 6 项。 +- 本地脚本 6 项、Go race(真实 PostgreSQL 临时 schema)、go vet、gofmt、Go 构建、管理端类型/构建/embed、App 12 项测试/类型检查/双平台导出通过;工作流 YAML、内嵌 shell 与服务器 Compose config --quiet 已校验。服务器完整 Docker 检查与最终运行镜像构建也已通过,包含容器内发布测试和独立 PostgreSQL 17 的全部 Go race 测试。服务器 proxy.golang.org 超时、goproxy.cn 返回 200,构建阶段已切换 goproxy.cn 并验证成功,不关闭 checksum。 +- 运行镜像已在隔离网络、只读文件系统、临时日志目录中启动验证:内嵌管理端 200,缺少数据库/Redis 时 readyz 正确返回 503,Python 3.11.2 可执行。验证容器已清理,没有连接开发数据库或启动实际部署实例。 +- 本机 Docker daemon 未运行,使用指定部署主机的独立 /tmp/veyra-verify-* 构建目录验证。CI 文件在 dev,通过 PR 进入 master 后才触发首次自动部署。自动筛选与股票工作台已在 dev 提交 57882ff;后续 CI 配置提交不直接操作 master。 + +## 最新开发状态(2026-10-09,管理端重排与真实候选联调) + +- 用户要求配置并实际生成自动候选,以及重新规划管理端 UI,使其围绕股票而非服务状态。已完成可见的管理端重排与真实源验证;不再要求用户逐条录入候选。 +- 总览改为 A 股研究总览:实际已同步证券数量、近期待审核/有效发布候选数量、已启用策略数、股票名称/代码/价格/行情时间、真实未复权日线与待审核信号卡片。候选与策略统计明确为最近 100 条范围,不冒充全量;没有指数、涨跌幅、成交额和行业数据,不虚构相应指标。 +- 新增“股票行情”一级入口,支持代码/名称搜索、股票报价与真实日线图。导航将股票研究与运行配置分组,原 PostgreSQL/Redis/服务状态移至数据健康,模拟回放收进辅助开发区。管理端保持暖白、炭黑、焦糖橙。 +- 候选审核以自动筛选为主入口,手工补录为辅助。股票卡片突出名称、交易所/代码、参考区间、风险/目标价格;详情包含真实日线、来源、版本、依据、风险与审核记录。总览信号卡可直达对应候选详情。 +- 新增同源管理员 POST /api/admin/v1/strategy-screenings/run(202 异步)与 GET /api/admin/v1/strategy-screening-status。当前进程状态显示排队/运行/完成/失败、最近一批检查/生成/跳过/失败数量与时间;运行或排队中的重复请求复用现有任务,不创建新 Worker。进程状态不声称跨重启持久化,实际筛选记录与候选仍持久化。 +- 选定范围配置上限扩为 100 只,每批最多 20 只,定时轮转。后台已按用户本轮要求创建并启用均线交叉研究基线修订 3:5/20 日 SMA,上涨交叉;固定取行情列表按代码排序的前 100 个有效报价中剔除名称含 ST 的 96 只。参考区间 ±100 基点、风险观察 −500 基点、目标观察 +1000 基点、有效 24 小时。此为开发研究预览参数,已保存在不可覆盖的后台策略版本,可修订;不是经过验证的投资建议或全市场自动选股。 +- 真实新浪公开日线的 96 只已分五批全部完成,无跳过、无失败,生成两条 PENDING_REVIEW:中洲控股 000042、胜利股份 000407,信号日 2026-10-09。证券、依据、参考价格、来源及版本均自动生成;没有植入演示候选,没有自动审核发布。首批旧修订 2 的 20 只没有信号,记录如实保留。 +- 十三版 veyra_strategy_screening 保存不可覆盖的策略版本、来源/配置版本、日线输入快照和 SHA-256;按版本/证券/日期/来源唯一去重。自动创建、提交审核、筛选记录与审计同事务,并发重复不会留额外候选。 +- 只使用完整日线与实际观察时间,未复权;当日上海时间 16:00 前不筛选。报价观察须最近 24 小时,报价与日线接收时间在 10 分钟内,来源/版本与最后日线日期匹配;不把重新接收陈旧收盘价当新观察。 +- 自动候选及后续人工修订保留策略版本绑定。策略暂停/切换时不能发布旧自动候选或建立新计划,App 有效列表排除它,未完成计划失效并释放预算;手工候选独立,已记录成交与持仓保留。 +- 十四版公开行情报价与模拟回放分开保存;同步只为有效计划产生近期报价事件,最近一分钟实际观察可触发一次站内通知,重复时间/关闭来源/错误版本跳过。提醒不写账。五分钟同步不保证捕获瞬时变化,远程推送尚未接通。 +- 开发库已应用 14 版迁移。PostgreSQL 临时 schema race 测试覆盖并发去重、输入不可覆盖、审核前 App 隔离、公开提醒不改变账本、失效来源跳过、暂停后预算释放;新增手动触发权限、重复排队、Worker 完成与取消验证。Go vet 与管理端类型检查/构建/embed通过;App 前轮 12 项测试/类型检查/双平台 Hermes 导出通过,本轮未改 App 代码。 +- Chrome 已验证新版总览的 5213 条行情快照/2 条待审核信号、股票价格及 40 条实际日线;候选页已打开中洲控股,确认自动生成的依据、来源/策略版本、价格区间和审核发布/退回入口可见。页面保留在待审核详情,未执行审核发布。 +- 本次预览后端 0.0.0.0:8083,管理端 http://127.0.0.1:8083;Metro 8081,电脑内网 IP 192.168.100.2,App API 指向 8083。Expo 地址 exp://192.168.100.2:8081。App 真机 UI 未验证,APK/IPA 未生成;两条候选还待人工审核,App 有效候选仍为空。 +- 本段功能开发时尚未提交;后续分支与提交状态以最新 CI/CD 记录为准。昨晚交接修改保留;凭据只在私有配置,主密钥不重置。 + +### 接下来 + +1. 人工核验两条真实自动候选并决定是否发布,真机验证 App 候选 → 计划 → 提醒 → 成交及冲正。当前公开源与研究参数不替代正式市场规则。 +2. 补全交易日历、复权、证券及板块交易限制;推进全市场筛选的容量、授权与增量调度,不把当前 96 只范围称为全市场。 +3. AI 分析、用户偏好/关注、正式认证、远程推送及生产行情授权仍待开发。 + +## 2026-10-08 交接(历史,以下未实现项以最新记录为准) + +- 用户已要求今天停止开发,明天再继续;未创建自动任务,不在用户未继续指示时推进开发。 +- 今日代码已提交并推送至 origin/master,提交 92329dd(完整 SHA:92329ddce2860a77fc492e70c752b76799e94713);已核对远端一致。此次交接文档更新在该提交之后保存,本地尚未另行提交。 +- 已完成:单 Go module / 单 main 入口、独立前端依赖、管理端 embed、供应商加密配置、App 模拟交易闭环、候选审核、计划预算、不可覆盖成交及冲正、FIFO 持仓与复盘、站内提醒、zap 文件日志、研究策略版本、Python 异步回测、可配置 A 股公开行情自动同步。 +- 自动行情已实际成功同步 5213 条沪深有效报价;新浪为当前开发源,东方财富保留适配器但联调出现连接关闭。日线自动获取与回测已完成实际验证。十二版迁移已应用开发数据库。 +- 自动行情尚未驱动策略筛选、候选生成或计划条件提醒;现有候选仍由后台录入/审核,条件提醒仍由模拟价格回放驱动。研究版本选用和暂停也尚未联动候选/计划,不能描述为完整自动选股产品。 +- 已完成真实 PostgreSQL 临时 schema 的 race 测试、go vet、管理端类型检查/构建/embed、App 12 项测试及双平台 Hermes 导出;App 真机/模拟器界面未验证,未生成 APK/IPA。 +- 最近保留的预览:后端 8083,Metro 8081;App 本地 API 地址指向电脑内网 IP 的 8083。下次先检查进程和网卡地址,不假设这些进程隔夜仍存活;需要启动时使用同一 Go 可执行文件,不新增 main 或 Makefile。常规后端配置端口为 8080。 +- 配置与凭据保持本地 configs/.env;禁止写入代码、文档或提交。供应商加密主密钥不得重置。LLM 已保存连接地址和加密密钥,模型名称为空且关闭;AI 调用尚未接通。 + +### 下次继续顺序 + +1. 将已有自动行情/日线与后台策略版本接起来,生成带来源、参数版本和数据时间的候选草稿,进入审核队列;减少手工新建,不自动发布未经审核的候选。 +2. 联动有效行情、策略暂停、候选版本与计划时效,接通自动条件提醒;保持提醒不产生成交或现金变动,陈旧行情不重新触发。 +3. 验证 App 真机完整流程与公开数据展示,按实际验证修复问题;再推进后台配置的 AI 分析、偏好/关注和正式推送。 + +以上顺序为交接建议,用户明天的新要求优先。正式行情授权、完整交易规则与正式认证仍待完成。 ## 产品与依据 @@ -132,7 +188,7 @@ - 管理端候选审核、通知任务、数据健康中的模拟价格回放已替代占位;App 候选/证据/预算/计划/成交/持仓/冲正/站内通知及累计账本复盘已接通。 - 真实 PostgreSQL 临时 schema 下的 race 测试通过:候选并发审核、历史保护、发布与事件失败回滚、计划现金争用与取消、部分成交、偏离说明、冲正不重开计划、FIFO 舍入、重复请求、冲正失败回滚、不可覆盖成交、模拟可卖时间、过期快照、不重复提醒、提醒不改账本与复盘金额。 - 管理端类型检查/生产构建/embed、App 类型检查/12 项客户端测试/iOS 与 Android Hermes 导出、go vet/唯一二进制编译已通过;后续代码变更按需复验。 -- 本机 8080 由用户 IDE 的旧进程占用,没有关闭用户进程。当前自有验证服务运行 8081;新增管理端与 App HTTP 读接口及登录/退出已通过,不向开发账户插入候选、资金或成交。 +- 本机 8080 由用户 IDE 的旧进程占用,没有关闭用户进程。当时自有验证服务运行 8081,后续已改为 8083;新增管理端与 App HTTP 读接口及登录/退出已通过,不向开发账户插入候选、资金或成交。 - 本轮 Chrome 已可用,实际验证新版候选队列、空白录入表单、通知任务与模拟价格入口,未保存任何业务数据。先前无浏览器是历史环境事实;尚未验证 App 真机/模拟器。 - 待继续:策略版本管理、Python 历史回测、自动行情与供应商调用、偏好与关注、生产服务授权及正式认证/远程推送。本轮改动纳入此次用户授权的提交。 @@ -152,7 +208,7 @@ - 用户要求“做完这个功能就先停止”,本轮在 A 股自动接入验证后停止开发,预览服务保留。后续待自动筛选/审核、AI 分析、偏好与关注、生产授权、正式认证和远程推送;不自动继续扩展。本轮改动纳入此次用户授权的提交,原始两份设计文件未修改。 -## 本轮提交准备 +## 本轮代码提交记录 - 用户已明确要求提交并 push,本次提交包含工程目录整理、后台配置、App 模拟业务闭环、zap 日志、策略与回测及 A 股自动接入。验证状态和未完成能力以上文最新记录为准。 -- 本地环境、密钥、日志、依赖目录和构建产物不纳入提交;原始设计文件保持不变。目标分支为 master,远端为 origin。 +- 本地环境、密钥、日志、依赖目录和构建产物不纳入提交;原始设计文件保持不变。已推送至 origin/master,提交为 92329dd,远端 SHA 已核对一致。