From 4c3ce31fe116f66ac64c20c9158fb5b31e7738f1 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 3 Nov 2026 12:08:14 +0800 Subject: [PATCH] =?UTF-8?q?=E9=83=A8=E7=BD=B2=EF=BC=9Apush=20origin=20?= =?UTF-8?q?=E5=B0=B1=E7=94=B1=E5=BC=80=E5=8F=91=E6=9C=BA=E7=BC=96=E5=A5=BD?= =?UTF-8?q?=E3=80=81=E6=B5=8B=E8=BF=87=E5=86=8D=E4=BC=A0=E5=88=B0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=EF=BC=8C=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=87=AA=E5=B7=B1=E7=BC=96=EF=BC=8Cpush=20=E5=90=8E?= =?UTF-8?q?=E4=B8=80=E4=B8=A4=E5=88=86=E9=92=9F=E4=B8=8A=E7=BA=BF=E3=80=82?= =?UTF-8?q?=E7=85=A7=20OJ2=20=E7=9A=84=E5=81=9A=E6=B3=95=EF=BC=8C=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=9C=BA=E4=B8=8A=E7=9A=84=20gitea-runner=EF=BC=88?= =?UTF-8?q?=E5=92=8C=20OJ2=20=E5=85=B1=E7=94=A8=EF=BC=8C=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=20iot-deploy=EF=BC=8C=E4=B8=80=E6=AC=A1=E5=8F=AA=E8=B7=91?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E4=BB=BB=E5=8A=A1=EF=BC=89=E8=B7=91=20.gitea?= =?UTF-8?q?/workflows/deploy.yml=EF=BC=9A=E7=BC=96=20C#=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=EF=BC=88Release=EF=BC=89=E3=80=81=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E5=92=8C=20server.js=EF=BC=8C=E8=B7=91=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E6=B5=8B=E8=AF=95=EF=BC=88=E7=94=A8=E8=A6=81?= =?UTF-8?q?=E4=B8=8A=E7=BA=BF=E7=9A=84=20Release=20=E7=89=88=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=EF=BC=89=E3=80=81=E7=BD=91=E9=A1=B5=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=92=8C=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=EF=BC=8C=E4=BA=A7=E7=89=A9=20rsync=20=E5=88=B0=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E7=9A=84=20prebuilt/=EF=BC=88=E6=8C=89?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=AF=94=E3=80=81=E4=BC=A0=E5=AE=8C=E6=89=8D?= =?UTF-8?q?=E6=8D=A2=E4=B8=8A=E3=80=81=E6=96=AD=E4=BA=86=E9=87=8D=E8=AF=95?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=B8=8A=20deploy?= =?UTF-8?q?/update.sh=20--prebuilt=20=E7=94=A8=E7=8E=B0=E6=88=90=E4=BA=A7?= =?UTF-8?q?=E7=89=A9=E6=9E=84=E5=BB=BA=E9=95=9C=E5=83=8F=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E5=89=A9=E5=87=A0=E6=9D=A1=20COPY=E3=80=82=E4=BB=A5=E5=89=8D?= =?UTF-8?q?=20GitHub=20=E4=B8=8A=E6=B5=8B=E8=AF=95=E7=BA=A6=205=20?= =?UTF-8?q?=E5=88=86=E9=92=9F=E3=80=81=E6=9C=8D=E5=8A=A1=E5=99=A8=202=20?= =?UTF-8?q?=E6=A0=B8=E5=86=8D=E7=BC=96=E7=BA=A6=E4=B8=80=E5=88=86=E9=92=9F?= =?UTF-8?q?=EF=BC=88=E7=BC=96=E7=9A=84=E6=97=B6=E5=80=99=E5=AD=A6=E7=94=9F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E4=BC=9A=E5=8D=A1=EF=BC=89=EF=BC=8Cpush=20?= =?UTF-8?q?=E5=90=8E=206=20=E5=88=86=E9=92=9F=E5=A4=9A=E6=89=8D=E4=B8=8A?= =?UTF-8?q?=E7=BA=BF=E3=80=82host=20=E6=A8=A1=E5=BC=8F=E7=94=A8=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E6=9C=BA=E8=87=AA=E5=B7=B1=E7=9A=84=20ssh=20=E5=92=8C?= =?UTF-8?q?=20git=20=E8=AE=BF=E9=97=AE=EF=BC=8C=E4=B8=8D=E9=9C=80=E8=A6=81?= =?UTF-8?q?=20secret=E3=80=82Dockerfile=20=E5=8A=A0=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=8F=82=E6=95=B0=20ARTIFACTS=EF=BC=88build=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=9C=A8=E9=95=9C=E5=83=8F=E9=87=8C=E7=BC=96=20/=20pr?= =?UTF-8?q?ebuilt=20=E7=94=A8=E7=8E=B0=E6=88=90=E4=BA=A7=E7=89=A9=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=87=AA=E5=B7=B1=E7=BC=96?= =?UTF-8?q?=E7=9A=84=E8=83=BD=E5=8A=9B=E4=BF=9D=E7=95=99=EF=BC=9Bprebuilt/?= =?UTF-8?q?COMMIT=20=E5=92=8C=E8=A6=81=E9=83=A8=E7=BD=B2=E7=9A=84=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E5=AF=B9=E4=B8=8D=E4=B8=8A=E6=97=B6=20update.sh=20?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=A4=B1=E8=B4=A5=E3=80=82=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E7=AB=AF=E6=B5=8B=E8=AF=95=E5=90=8C=E6=97=B6=E8=B7=91=EF=BC=9A?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B9=8B=E9=97=B4=20--parallel=EF=BC=8C?= =?UTF-8?q?=E8=AF=84=E5=88=86=E6=B5=8B=E8=AF=95=E6=A0=87=20describe.concur?= =?UTF-8?q?rent=EF=BC=88--max-concurrency=3D8=EF=BC=89=EF=BC=8C=E6=9C=AC?= =?UTF-8?q?=E6=9C=BA=204=20=E5=88=86=E9=92=9F=E9=99=8D=E5=88=B0=E5=8D=8A?= =?UTF-8?q?=E5=88=86=E9=92=9F=EF=BC=8C=E8=BF=9E=E8=B7=91=E4=B8=89=E9=81=8D?= =?UTF-8?q?=E9=83=BD=E9=80=9A=E8=BF=87=E3=80=82GitHub=20=E7=9A=84=20ci.yml?= =?UTF-8?q?=20=E6=94=B9=E6=88=90=20PR=20=E8=B7=91=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E3=80=81=E6=89=8B=E5=8A=A8=E8=A7=A6=E5=8F=91=E6=89=8D=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=EF=BC=88=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E7=BC=96=EF=BC=89=EF=BC=8C=E5=BC=80=E5=8F=91=E6=9C=BA=E4=B8=8D?= =?UTF-8?q?=E5=9C=A8=E6=97=B6=E7=94=A8=EF=BC=8Cpush=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E4=B8=A4=E8=BE=B9=E5=90=84=E9=83=A8=E7=BD=B2=E4=B8=80=E9=81=8D?= =?UTF-8?q?=E3=80=82=E9=A1=BA=E6=89=8B=EF=BC=9Aupdate.sh=20=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E9=83=A8=E7=BD=B2=E6=8B=89=E4=B8=80=E4=B8=8B=20caddy:?= =?UTF-8?q?2-alpine=EF=BC=88up=20=E4=B8=8D=E4=BC=9A=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E6=8B=89=EF=BC=8C=E4=B8=80=E7=9B=B4=E6=98=AF=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E9=83=A8=E7=BD=B2=E6=97=B6=E9=82=A3=E4=BB=BD=EF=BC=89?= =?UTF-8?q?=EF=BC=9Bapp=20=E5=92=8C=20caddy=20=E7=9A=84=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E9=99=90=2010MB=20=C3=97=203=E3=80=82?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E8=AF=B4=E6=98=8E=E3=80=81README=E3=80=81?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E6=96=B9=E6=A1=88=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/deploy.yml | 118 ++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 17 +++--- .gitignore | 3 + Dockerfile | 26 +++++++- README.md | 4 +- compose.yaml | 14 ++++- deploy/README.md | 51 +++++++++++----- deploy/update.sh | 42 +++++++++++-- docs/设计方案.md | 62 +++++++++---------- server/test/grading.test.ts | 6 +- 10 files changed, 274 insertions(+), 69 deletions(-) create mode 100644 .gitea/workflows/deploy.yml diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..e70fe67 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,118 @@ +# push 到 origin(git.xuyue.cc)的 main 就部署:在开发机上编好产物、跑测试 → 产物 rsync 到服务器的 prebuilt/ → +# 服务器上 deploy/update.sh --prebuilt(拉代码、用现成产物构建镜像、重启)。 +# +# 跑在哪:开发机上的 gitea-runner(host 模式,标签 iot-deploy;和 OJ2 共用一个 runner,一次只跑一个任务, +# 两边的部署排队,不会同时挤服务器)。runner 的装法见 deploy/README.md「自动部署」。 +# - 服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满。 +# - 以前在 GitHub 上测试(约 5 分钟)再 SSH 到服务器编,push 后 6 分钟多才上线。 +# 代价:**push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑; +# 急着上线又不在开发机旁时,用 GitHub 上手动触发的备用部署(.github/workflows/ci.yml,服务器自己编)。 +# +# host 模式直接用开发机自己的东西:PATH 里的 bun / dotnet / bwrap、~/.ssh/config 里的 `debian`(root@服务器) +# 和 git.xuyue.cc 的 SSH 访问。所以这里**不需要配任何 secret**,也不用 actions/checkout。 +# +# 有了 .gitea/workflows 之后,Gitea 不再读 .github/workflows。 +name: Deploy + +on: + push: + branches: [main] + # 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后) + workflow_dispatch: + +concurrency: + group: deploy + cancel-in-progress: false + +env: + REMOTE: debian # 开发机 ~/.ssh/config 里的主机名 + REMOTE_DIR: /root/IOT_SIM + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + +jobs: + deploy: + runs-on: iot-deploy + timeout-minutes: 20 + steps: + # 工作目录可能是上一次留下的,所以 fetch 之后强制切过去再清掉没跟踪的文件(node_modules 留着,bun install 会对齐它) + - name: 取代码 + env: + SHA: ${{ gitea.sha }} + REPO: ${{ gitea.repository }} + run: | + git init -q . + git fetch -q --depth 1 "git@git.xuyue.cc:$REPO.git" "$SHA" + git checkout -q --force FETCH_HEAD + git clean -ffdxq -e node_modules + git log --oneline -1 + + # server.js 是开发机的 bun 打包的,到服务器上由镜像里的 bun(Dockerfile 里写死的版本)运行。 + # 打包出来是普通 JS,不像 OJ2 的单二进制那样把 bun 本身带过去,版本差一点一般没事,所以只提醒不拦。 + - name: 看 bun 版本 + run: | + want=$(sed -nE 's|^FROM oven/bun:([0-9.]+).*|\1|p' Dockerfile | head -1) + have=$(bun --version) + echo "开发机 bun $have,镜像里 bun $want" + [ "$have" = "$want" ] || echo "::warning::开发机的 bun 是 $have,镜像里是 $want,升级 Dockerfile 和 ci.yml 里的版本,或者确认没有用到新版本才有的功能" + + - name: 装依赖 + run: bun install --frozen-lockfile + + - name: 编 C# 运行时 + run: dotnet build runtime/Runtime.slnx -c Release -v q + + - name: 编网页、打包服务端 + run: | + (cd web && bun run build) + bun build server/src/index.ts --target=bun --outfile=server.js + + # 有一项不过就不部署。服务端测试用上一步编的 Release 版运行时,测的就是要上线的那份。 + # --parallel 让 6 个测试文件同时跑;grading 里的测试标了 describe.concurrent, + # --max-concurrency 限制同时跑几个(太多时定时器来不及触发会误判)。挨个跑约 4 分钟,这样半分钟左右 + - name: 测试 + env: + RUNNER_DLL: runtime/Sim.Runner/bin/Release/net10.0/Sim.Runner.dll + run: | + (cd server && bun test --parallel --max-concurrency=8) + (cd web && bun test src && bunx vue-tsc --noEmit) + + # 目录结构就是 Dockerfile 里 artifacts-prebuilt 阶段要的;COMMIT 让服务器确认产物和要部署的代码是同一个提交 + - name: 收集产物 + env: + SHA: ${{ gitea.sha }} + run: | + rm -rf prebuilt + mkdir -p prebuilt/runtime prebuilt/web + cp -a runtime/Sim.Runner/bin/Release/net10.0/. prebuilt/runtime/ + cp -a web/dist/. prebuilt/web/ + cp server.js prebuilt/ + echo "$SHA" > prebuilt/COMMIT + du -sh prebuilt + + # 只传产物,源码由服务器自己从 origin 拉(git.xuyue.cc 就在那台服务器上)。 + # -c 按内容比:每次编出来的 mtime 都是「刚才」,按 mtime 比会把没变的文件也传一遍 + # --delay-updates 全部传完才一起换上去,中途断了不会留下半新半旧的产物(反正 COMMIT 对不上也会被 update.sh 拦下) + # --timeout 60 秒没有数据进出就算断了,交给循环重试,半截文件接着用 + - name: 同步到服务器 + timeout-minutes: 10 + run: | + for attempt in 1 2 3; do + rsync -a -c --no-owner --no-group --delete-delay --delay-updates \ + --compress --compress-choice=zstd \ + --timeout=60 --stats --human-readable \ + -e "ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4" \ + prebuilt/ "$REMOTE:$REMOTE_DIR/prebuilt/" && exit 0 + echo "::warning::rsync 第 $attempt 次没传完,10 秒后重试" + sleep 10 + done + exit 1 + + # 先把服务器上的代码切到这个提交再跑 update.sh:不然跑的是上一版的 update.sh,改了它要晚一次部署才生效。 + # 重启会结束正在运行的学生程序,上课时间尽量不要推送 main。 + - name: 起服务 + env: + SHA: ${{ gitea.sha }} + run: | + ssh -o BatchMode=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=8 "$REMOTE" \ + "cd $REMOTE_DIR && git fetch --quiet origin && git reset --quiet --hard $SHA && deploy/update.sh $SHA --prebuilt" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91febda..68a7670 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,13 @@ -# 每次推送和 PR 都跑测试;推送到 main 且测试通过后,SSH 到服务器执行 deploy/update.sh(服务器从 origin 拉代码、构建、重启)。 -# 部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。 -# 只在 github.com 上运行;仓库同时推到 git.xuyue.cc,那边的 Gitea Actions 也会读这个目录,用 if 跳过。 +# 备用路线。平时 push origin(git.xuyue.cc)后,由开发机上的 gitea-runner 测试、编好、部署(.gitea/workflows/deploy.yml), +# 这里 push 不再触发,免得每次推送在两边各测一遍、各部署一遍。 +# - PR:只跑测试。 +# - 手动触发(Actions → CI/CD → Run workflow):测试通过后 SSH 到服务器执行 deploy/update.sh,服务器从 origin 拉代码、 +# **自己编**、重启。开发机不在、又急着上线时用。部署需要的 Secrets 见 deploy/README.md 的"自动部署"一节。 +# 只在 github.com 上运行(Gitea 有了 .gitea/workflows 后不再读这个目录,if 再保一道)。 name: CI/CD on: - push: - branches: [main] pull_request: - # 在 Actions 页面手动重新部署(比如改了服务器上的 .env 之后) workflow_dispatch: jobs: @@ -37,9 +37,10 @@ jobs: - name: 编译 C# 运行时 run: bun run build:runtime + # 文件之间同时跑,grading 里的测试也同时跑(describe.concurrent),见 .gitea/workflows/deploy.yml 同名一步 - name: 服务端测试(编译并运行示例、沙箱、编译检查) working-directory: server - run: bun test + run: bun test --parallel --max-concurrency=8 - name: 网页端测试和类型检查 working-directory: web @@ -49,7 +50,7 @@ jobs: deploy: needs: test - if: github.server_url == 'https://github.com' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + if: github.server_url == 'https://github.com' && github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 # 服务器构建约几分钟;卡住时及时失败,不要一直占着 timeout-minutes: 20 diff --git a/.gitignore b/.gitignore index 03f0ad3..e916075 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ obj/ # 服务器上 deploy/backup.sh 做的备份 /backups/ + +# 开发机上的 gitea-runner 编好传到服务器的产物(.gitea/workflows/deploy.yml) +/prebuilt/ diff --git a/Dockerfile b/Dockerfile index f803088..a548a8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,11 @@ # 物联网编程仿真平台:一个镜像里包含 Bun 服务端、打包好的网页、.NET 运行时和 bubblewrap 沙箱。 # 构建:docker compose build;部署说明见 deploy/README.md +# +# 产物(C# 运行时、网页、server.js)有两种来源,由构建参数 ARTIFACTS 选: +# build (默认)在镜像里编。只要有 docker 就能构建,但服务器只有 2 核,编一次约一分钟,期间学生程序会卡 +# prebuilt 用 prebuilt/ 里现成的产物(开发机上的 gitea-runner 编好、测过后 rsync 过来,见 .gitea/workflows/deploy.yml), +# 镜像构建只剩几条 COPY。buildx 不会去跑用不到的 runtime-build / web-build 阶段 +ARG ARTIFACTS=build # ---- C# 运行时(编译服务 + 仿真库) ---- FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime-build @@ -22,6 +28,20 @@ RUN cd web && bun run build # 服务端打包成单个文件,运行镜像里不需要 node_modules RUN bun build server/src/index.ts --target=bun --outfile=server.js +# ---- 产物:在镜像里编的 ---- +FROM scratch AS artifacts-build +COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ /runtime/ +COPY --from=web-build /src/web/dist/ /web/ +COPY --from=web-build /src/server.js /server.js + +# ---- 产物:现成的(目录结构和上面一样,名字里不带 dist / bin,免得被 .dockerignore 挡掉) ---- +FROM scratch AS artifacts-prebuilt +COPY prebuilt/runtime/ /runtime/ +COPY prebuilt/web/ /web/ +COPY prebuilt/server.js /server.js + +FROM artifacts-${ARTIFACTS} AS artifacts + # ---- 运行镜像 ---- FROM mcr.microsoft.com/dotnet/runtime:10.0 RUN apt-get update \ @@ -29,9 +49,9 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* COPY --from=oven/bun:1.4.4 /usr/local/bin/bun /usr/local/bin/bun WORKDIR /app -COPY --from=runtime-build /src/runtime/Sim.Runner/bin/Release/net10.0/ runtime/ -COPY --from=web-build /src/web/dist/ web/dist/ -COPY --from=web-build /src/server.js ./ +COPY --from=artifacts /runtime/ runtime/ +COPY --from=artifacts /web/ web/dist/ +COPY --from=artifacts /server.js ./ COPY samples/ samples/ ENV APP_ROOT=/app \ RUNNER_DLL=runtime/Sim.Runner.dll \ diff --git a/README.md b/README.md index 17f215d..b0e2967 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ BASE=http://localhost:3000 bun test/classroom.ts 45 # 全班模拟:45 个学 ## 部署 -Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 GitHub 的 main 后,GitHub Actions 跑完测试,SSH 到服务器执行 `deploy/update.sh`:服务器从 git.xuyue.cc 拉代码、构建镜像、重启。详见 [deploy/README.md](deploy/README.md)。 +Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。push 到 origin(git.xuyue.cc)的 main 后,开发机上的 gitea-runner 跑完测试、编好产物传到服务器,服务器拉代码、用现成产物构建镜像、重启(`deploy/update.sh --prebuilt`);GitHub 上手动触发是备用路线,由服务器自己编。详见 [deploy/README.md](deploy/README.md)。 ## 运行器的四种模式 @@ -74,7 +74,7 @@ Docker Compose,HTTPS 由服务器上的 Nginx Proxy Manager 负责。推送到 - 窗体设计器(仿 VS,界面中文):工具箱、拖放与改大小、属性窗口、事件窗口、双击生成事件处理方法、撤销 / 重做;读写 VS 格式的 Designer.cs。 - 多窗体:新建、删除窗体;运行时多个窗口可切换前后。 - 导出 EXE:学生的程序照教材工程(.NET Framework 4.5、真机版设备类库)编译成一个 exe,教材 dll 嵌在里面,在机房电脑上双击就能控制真的实训台(`runtime/Sim.Runner/ExeExporter.cs`)。教材 dll 有版权,不进仓库:本机放在 `textbook-libs/`,服务器上的放法见 [deploy/README.md](deploy/README.md)。 -- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、GitHub Actions 自动部署。 +- 部署:bubblewrap 沙箱(断网、只读、看不到其他进程,限制内存、CPU 时间和运行时长)、编译时禁止网络 / 反射 / 启动进程等 API、Docker Compose、push origin 自动部署(开发机上的 gitea-runner 测试、编好再传到服务器)。 - 第 2 阶段(进行中):账号登录(本机开发时老师账号为 admin / admin)、班级管理与 CSV 名单导入、学生作品保存在服务器上;自动评分(每个示例的 `checks.yaml`);实验页"评价项"可以自己检查(不计分);布置作业、学生提交后自动评分;老师评阅(看代码和界面截图、运行学生的程序、老师评分项打分、评语)、AI 评阅建议(DeepSeek,老师采纳后生效)、全班成绩表与 CSV 导出。 - 第 3 阶段(教材项目 4–8 已全部完成):教材项目 5 用到的旧版线程 API(`Thread.Suspend` / `Resume` / `Abort`)编译时改写成平台的协作式实现(`runtime/Sim.Runner/LegacyThreadRewriter.cs`、`runtime/Sim.WinForms/Host/LegacyThreads.cs`);网络摄像机(画面帧由 `runtime/Sim.Devices/Camera/build-frames.ts` 从 `scene.svg` 生成,需要 rsvg-convert 和 ImageMagick)。 diff --git a/compose.yaml b/compose.yaml index d9df98f..8e017ef 100644 --- a/compose.yaml +++ b/compose.yaml @@ -5,7 +5,11 @@ services: init-data: image: iot-sim:latest # 和 app 同一个镜像(构建有缓存,不会重复构建);写上 build,新服务器上就不会去 Docker Hub 拉这个镜像 - build: . + build: &build + context: . + # build:在镜像里编(默认);prebuilt:用 prebuilt/ 里开发机编好的产物,见 Dockerfile 开头 + args: + ARTIFACTS: ${ARTIFACTS:-build} user: root command: ['sh', '-c', 'chown -R "$$APP_UID" /data'] volumes: @@ -15,8 +19,13 @@ services: app: image: iot-sim:latest - build: . + build: *build restart: unless-stopped + logging: &logging # 默认的 json-file 日志不限大小,会一直涨 + driver: json-file + options: + max-size: 10m + max-file: '3' depends_on: init-data: condition: service_completed_successfully @@ -60,6 +69,7 @@ services: caddy: image: caddy:2-alpine restart: unless-stopped + logging: *logging depends_on: [app] expose: - 80 diff --git a/deploy/README.md b/deploy/README.md index e66078c..af3d69d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -66,15 +66,34 @@ 数据库(账号、班级、学生作品)在 Docker 数据卷 `data` 里,重新部署不会丢;`docker compose down -v` 会删除数据卷,不要加 `-v`。 -## 自动部署(GitHub Actions) +## 自动部署(push origin 就部署) -`.github/workflows/ci.yml`: +`.gitea/workflows/deploy.yml`:push 到 origin(git.xuyue.cc)的 main 时,**开发机上的 gitea-runner** 编 C# 运行时、网页和 `server.js`,跑服务端测试(含沙箱)、网页端测试和类型检查(有一项不过就不部署),把产物 rsync 到服务器的 `~/IOT_SIM/prebuilt/`,再在服务器上执行 `deploy/update.sh <提交号> --prebuilt`:拉代码、用现成产物构建镜像(只剩几条 COPY)、重启,等到"编译服务已就绪"才算成功。push 后一两分钟上线,进度在 git.xuyue.cc/xuyue/IOT_SIM/actions。 -- 每次推送和 PR:编译 C# 运行时,跑服务端测试(含沙箱)、网页端测试和类型检查。 -- 推送到 GitHub 的 main 且测试通过:SSH 到服务器执行 `deploy/update.sh <提交号>`,服务器从 origin 拉代码、构建、重启,等到"编译服务已就绪"才算成功。**重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。 -- 服务器从 origin 拉代码,所以**两个仓库都要推送**(`git push origin main`、`git push github main`)。GitHub 先到时,脚本会等 origin 最多约 100 秒。 -- 在 GitHub 的 Actions 页面点 Run workflow 可以手动重新部署。 -- 只在 github.com 上运行;推到 git.xuyue.cc 不会触发。 +- **重启会结束正在运行的学生程序**,上课时间尽量不要推送 main。 +- **push 时开发机得开着。** 没开的话任务在 Gitea 里排队,开机后 runner 连上就接着跑;排队超过 24 小时会被作废,要在 Actions 页面重跑。 +- 在 Gitea 的 Actions 页面点 Run workflow 可以手动重新部署(比如改了服务器上的 `.env` 之后)。 +- `prebuilt/COMMIT` 记着产物是哪个提交编的,和要部署的提交对不上时 `update.sh` 直接失败,不会把旧产物配新代码。 + +为什么不在服务器或 GitHub 上编:服务器只有 2 核,学生程序也在上面跑,以前服务器自己编约一分钟,dotnet 和 vite 把两个核占满;GitHub 上测试约 5 分钟,push 后 6 分钟多才上线。开发机 16 核,服务端测试同时跑(`bun test --parallel`,评分测试标了 `describe.concurrent`)约半分钟。 + +**服务器自己编的能力还在**:`deploy/update.sh` 不带 `--prebuilt` 就是在镜像里编(`Dockerfile` 的构建参数 `ARTIFACTS` 默认 `build`),只要有 docker 就能部署,不依赖开发机。 + +### 部署 runner + +和 OJ2 共用开发机上的同一个 runner(host 模式,注册在 git.xuyue.cc 的 `xuyue` 用户下,标签 `oj2-deploy`、`iot-deploy`,一次只跑一个任务,两边的部署排队)。装法、配置文件位置、换机器重新注册见 OJ2 仓库 `docs/deploy.md` 的"部署 runner"。 + +host 模式直接用开发机自己的东西,所以**不需要配任何 secret**: + +- PATH 里的 bun、dotnet 10 SDK、bubblewrap(测试里学生程序在沙箱中运行)、rsync; +- `~/.ssh/config` 里的 `debian`(root@服务器,服务器目录 `/root/IOT_SIM`); +- 能 `git fetch git@git.xuyue.cc:xuyue/IOT_SIM.git`(都不能要密码)。 + +开发机的 bun 版本和 `Dockerfile` 里镜像的 bun 不一样时,工作流会给出警告(`server.js` 是开发机打包、镜像里的 bun 运行的)。 + +### 备用:GitHub 手动触发 + +`.github/workflows/ci.yml` 只在 PR 时跑测试,push 不触发(不然每次推送两边各部署一遍)。开发机不在、又急着上线时,在 github.com 的 Actions → CI/CD → Run workflow:测试通过后 SSH 到服务器执行 `deploy/update.sh <提交号>`,**服务器自己编**。这个提交要已经推到 origin(服务器从 origin 拉代码,没到时脚本会等最多约 100 秒)。 一次性配置(在本机执行,需要已登录的 `gh`): @@ -96,15 +115,15 @@ 在服务器的 `~/IOT_SIM` 目录下: -| 做什么 | 命令 | -| ------------ | --------------------------------------------------------------------------------------- | -| 更新到新版本 | 推送到两个仓库的 main(GitHub 测试通过后自动部署),或在服务器上执行 `deploy/update.sh` | -| 改老师密码 | 修改 `.env` 后 `docker compose up -d` | -| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 | -| 看日志 | `docker compose logs -f app` | -| 看资源占用 | `docker stats` | -| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" | -| 重启 / 停止 | `docker compose restart` / `docker compose down` | +| 做什么 | 命令 | +| ------------ | --------------------------------------------------------------------------------------------------------- | +| 更新到新版本 | `git push origin main`(开发机测试、编好后自动部署),或在服务器上执行 `deploy/update.sh`(服务器自己编) | +| 改老师密码 | 修改 `.env` 后 `docker compose up -d` | +| 学生账号 | 老师登录后在"班级管理"里导入名单、查看和重置密码 | +| 看日志 | `docker compose logs -f app` | +| 看资源占用 | `docker stats` | +| 备份数据 | `deploy/backup.sh`,见下文"备份与恢复" | +| 重启 / 停止 | `docker compose restart` / `docker compose down` | ## 导出 EXE 用的教材类库 diff --git a/deploy/update.sh b/deploy/update.sh index 3238d16..59d3d09 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -1,14 +1,27 @@ #!/usr/bin/env bash # 在服务器上执行:拉取代码、构建镜像、重启,等到编译服务就绪才算成功。 -# GitHub Actions 测试通过后通过 SSH 调用它,也可以登录服务器手动执行。 -# 用法:deploy/update.sh [提交号,默认 origin/main] +# 用法:deploy/update.sh [提交号,默认 origin/main] [--prebuilt] +# +# 不带 --prebuilt:服务器自己编(只要有 docker 就能跑,不依赖开发机;2 核编一次约一分钟,期间学生程序会卡)。 +# GitHub 上手动触发的备用部署、登录服务器手动部署都走这条。 +# --prebuilt: 用 prebuilt/ 里现成的产物,镜像构建只剩几条 COPY。push origin 后开发机上的 gitea-runner +# 编好、测过、rsync 过来再调用(.gitea/workflows/deploy.yml)。prebuilt/COMMIT 必须就是要部署的提交, +# 对不上说明产物不是这次编的,直接失败,不会把旧产物配新代码。 +# # 第一次部署前,按 deploy/README.md 在服务器上克隆仓库、准备好 .env set -euo pipefail # 整个脚本包在函数里:git reset 会在运行中替换本文件,bash 先读完函数再执行,不受影响 main() { cd "$(dirname "$0")/.." - local ref=${1:-origin/main} + local ref=origin/main prebuilt=0 arg + for arg in "$@"; do + case "$arg" in + --prebuilt) prebuilt=1 ;; + -*) echo "未知参数:$arg(可用:--prebuilt)" >&2; exit 2 ;; + *) ref=$arg ;; + esac + done if [[ ! -f .env ]]; then echo '还没有 .env:cp .env.example .env 后设置 TEACHER_PASSWORD' @@ -16,8 +29,7 @@ main() { fi echo "== 拉取代码" - # 代码从 origin(git.xuyue.cc)拉取。Actions 由推送到 GitHub 触发, - # 两个仓库推送有先后,这个提交可能还没到 origin,多等一会儿 + # 代码从 origin(git.xuyue.cc)拉取。GitHub 上手动触发时,这个提交可能还没推到 origin,多等一会儿 for attempt in $(seq 10); do git fetch --quiet origin if git rev-parse --quiet --verify "$ref^{commit}" >/dev/null; then break; fi @@ -33,7 +45,27 @@ main() { git reset --quiet --hard "$ref" git log --oneline -1 + if [[ $prebuilt == 1 ]]; then + local head built + head=$(git rev-parse HEAD) + built=$(cat prebuilt/COMMIT 2>/dev/null || true) + if [[ $built != "$head" ]]; then + echo "prebuilt/ 里的产物是 ${built:-(没有)} 编的,要部署的是 $head。" + echo '重新跑一次开发机上的部署,或者去掉 --prebuilt 让服务器自己编' + exit 1 + fi + for f in prebuilt/runtime/Sim.Runner.dll prebuilt/web/index.html prebuilt/server.js; do + [[ -e $f ]] || { echo "prebuilt/ 不完整,缺 $f"; exit 1; } + done + export ARTIFACTS=prebuilt + echo "== 用开发机编好的产物($(du -sh prebuilt | cut -f1))" + else + export ARTIFACTS=build + fi + echo "== 构建镜像并重启" + # caddy 用的是现成镜像,up 不会去拉新版,不拉的话永远是第一次部署时那份 + docker compose pull --quiet caddy docker compose up -d --build --remove-orphans docker image prune -f >/dev/null diff --git a/docs/设计方案.md b/docs/设计方案.md index 1b619d7..1a7efce 100644 --- a/docs/设计方案.md +++ b/docs/设计方案.md @@ -154,37 +154,37 @@ IOT/ 网站主体用 TypeScript;只有编译和运行学生 C# 代码的部分用 .NET。标"计划"的尚未引入。 -| 层 | 用途 | 选择 | -| --------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 前端 | 框架 | Vue 3 + TypeScript + Vite | -| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router | -| 前端 | 组件库(网站外壳) | Naive UI | -| 前端 | 代码编辑器 | Monaco Editor | -| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) | -| 前端 | 虚拟实训台场景 | HTML + CSS 动画 | -| 前端 | 图表 | 计划:Chart.js | -| 前端 | 实时通信 | 浏览器原生 WebSocket | -| 后端 | 运行时 | Bun | -| 后端 | Web 框架 | Hono | -| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM | -| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` | -| 后端 | 参数校验 | 计划:Zod | -| C# 运行时 | 平台 | .NET 10 LTS | -| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) | -| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 | -| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 | -| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 | -| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 | -| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp | -| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 | -| 工程 | 仓库 | Bun workspaces 单仓库 | -| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright | -| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB | -| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 | -| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 | -| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 | -| 部署 | 进程管理 | Docker Compose;GitHub Actions 测试通过后 SSH 到服务器,服务器从 git.xuyue.cc 拉代码并构建(`deploy/update.sh`)。不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 | -| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 | +| 层 | 用途 | 选择 | +| --------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 前端 | 框架 | Vue 3 + TypeScript + Vite | +| 前端 | 状态管理 / 路由 | 计划:Pinia / Vue Router | +| 前端 | 组件库(网站外壳) | Naive UI | +| 前端 | 代码编辑器 | Monaco Editor | +| 前端 | 窗体设计器、虚拟 WinForms 控件 | 自研(Vue 组件 + CSS,外观模仿 Windows) | +| 前端 | 虚拟实训台场景 | HTML + CSS 动画 | +| 前端 | 图表 | 计划:Chart.js | +| 前端 | 实时通信 | 浏览器原生 WebSocket | +| 后端 | 运行时 | Bun | +| 后端 | Web 框架 | Hono | +| 后端 | 数据库 | SQLite(`bun:sqlite`,WAL),直接写 SQL;表结构用编号迁移(`server/src/db.ts`)。数据量小,不引入 ORM | +| 后端 | 登录 | 计划:Cookie 会话 + `Bun.password` | +| 后端 | 参数校验 | 计划:Zod | +| C# 运行时 | 平台 | .NET 10 LTS | +| C# 运行时 | 编译 | Roslyn,常驻编译服务,内存编译,中文报错;编译时改写旧版线程 API(Suspend / Resume / Abort) | +| C# 运行时 | 数据库 | 仿冒 System.Data.SqlClient(`Sim.Data`):Microsoft.Data.Sqlite + ScriptDom(T-SQL 解析);常驻 SQL 服务供数据库页面使用 | +| C# 运行时 | 运行器 | .NET 控制台程序,模拟界面线程和消息循环 | +| C# 运行时 | 仿真类库 | 自研:`System.Windows.Forms` 子集 + 教材设备类库 + `Font` 等少量 System.Drawing 类型 | +| C# 运行时 | 数据库仿真 | 计划:仿冒 `System.Data.SqlClient`,底层 Microsoft.Data.Sqlite;T-SQL 用 ScriptDom 解析后转写 | +| C# 运行时 | 图片编解码(如需) | 计划:SkiaSharp | +| C# 运行时 | 语言服务 | Roslyn `CompletionService`、`QuickInfoService`(和 VS 的 IntelliSense 同一套引擎),参数提示用语义模型自己实现;常驻进程 `language-server`,和编译服务分开 | +| 工程 | 仓库 | Bun workspaces 单仓库 | +| 工程 | 测试 | `bun test`(端到端、单元、压力测试);计划:用教材参考工程做回归测试、Playwright | +| 部署 | 服务器 | 一台 Linux 云服务器,2 核 8 GB | +| 部署 | 沙箱 | bubblewrap:每次运行一个独立命名空间(断网、只读、看不到其他进程),加 .NET 堆上限、CPU 时间上限 | +| 部署 | HTTPS | Nginx Proxy Manager(服务器上已有),经外部网络 `npm_proxy` 转发 | +| 部署 | 反向代理 / 压缩 | Caddy(只收 HTTP),网页文件由 Bun 直接提供 | +| 部署 | 进程管理 | Docker Compose;push origin 后开发机上的 gitea-runner 测试、编好产物(运行时 dll、网页、server.js)rsync 到服务器,服务器从 git.xuyue.cc 拉代码、用现成产物构建镜像(`deploy/update.sh --prebuilt`)。服务器只有 2 核,不在上面编;不从国外传镜像:跨境上传 130 MB 镜像 40 分钟传不完 | +| 部署 | 备份 | 计划:定时 `sqlite3 .backup` + 异地拷贝 | ## 安全与服务器资源 diff --git a/server/test/grading.test.ts b/server/test/grading.test.ts index 7c7be01..69260a9 100644 --- a/server/test/grading.test.ts +++ b/server/test/grading.test.ts @@ -33,7 +33,9 @@ async function prepare(id: string, name: string, edit?: { file: string; from: st return dir } -describe('参考答案全部通过', async () => { +// 每个检查大部分时间在等学生程序的定时器,挨个跑要 3 分钟;测试之间各用各的临时目录,可以同时跑(30 秒左右)。 +// 同时跑的个数由 bun test 的 --max-concurrency 管,CI 里给的是 8:太多时 CPU 跟不上,定时器来不及触发会误判 +describe.concurrent('参考答案全部通过', async () => { const ids = (await Array.fromAsync(new Bun.Glob('*/checks.yaml').scan({ cwd: SAMPLES }))).map((p) => p.split('/')[0]).sort() test('每个示例都有评分规则', () => expect(ids.length).toBe(23)) for (const id of ids) { @@ -49,7 +51,7 @@ describe('参考答案全部通过', async () => { } }) -describe('改坏的代码', () => { +describe.concurrent('改坏的代码', () => { test('编译不通过时所有项都不通过', async () => { const id = 'p2-t1-LED显示屏' const dir = await prepare(id, 'compile-error', { file: 'Form1.cs', from: 'led.setLedText(res);', to: 'led.setLedText(res)' })