diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a1844de --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +** +!go.mod +!go.sum +!webserver.go +!backend/ +!backend/** +!cli/ +!cli/** +!config/ +!config/** +!frontend/ +!frontend/** +!utils/ +!utils/** +backend/data/ +frontend/node_modules/ +frontend/dist/ +frontend/.env* diff --git a/.gitea/workflows/container.yml b/.gitea/workflows/container.yml new file mode 100644 index 0000000..343bd9b --- /dev/null +++ b/.gitea/workflows/container.yml @@ -0,0 +1,35 @@ +name: Build and test website container + +on: + push: + branches: [main, container-ci] + workflow_dispatch: + +jobs: + build: + runs-on: linux-amd64-docker-small + timeout-minutes: 35 + container: + image: docker:29.1.3-cli + steps: + - name: Install checkout tools + shell: sh + run: apk add --no-cache bash git + + - name: Check out the pushed commit + uses: builtin:checkout + with: + ref: ${{ gitea.sha }} + persist-credentials: false + + - name: Build and test the image + shell: bash + env: + RELEASE_SHA: ${{ gitea.sha }} + run: | + set -euo pipefail + docker info --format '{{range .SecurityOptions}}{{println .}}{{end}}' | grep -Fx 'name=rootless' + image="autolibrary-web:${RELEASE_SHA}" + trap 'docker image rm "$image" >/dev/null || true' EXIT + docker build -t "$image" . + bash deploy/smoke-container.sh "$image" diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 7d44508..298dcc0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,12 +1,11 @@ -name: Deploy AutoLibrary Website +name: Deploy native AutoLibrary service on: - push: - branches: [main] + workflow_dispatch: jobs: deploy: - runs-on: linux-amd64-docker-small + runs-on: autolibrary-native-deploy steps: - name: Check out the pushed commit uses: https://github.com/actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0c64476 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM node:24.22.2-bookworm-slim AS frontend-build +WORKDIR /src/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +FROM golang:1.26.1-bookworm AS backend-build +WORKDIR /src +ENV CGO_ENABLED=1 +COPY go.mod go.sum ./ +RUN go mod download +COPY webserver.go ./ +COPY backend/ ./backend/ +COPY cli/ ./cli/ +COPY config/ ./config/ +COPY frontend/frontend.go ./frontend/frontend.go +COPY frontend/handlers/ ./frontend/handlers/ +COPY utils/ ./utils/ +RUN go test -p 1 -trimpath ./... && go build -p 1 -trimpath -o /out/webserver . + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl tzdata \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=backend-build /out/webserver ./webserver +COPY --from=frontend-build /src/frontend/dist ./frontend/dist +COPY backend/templates/ ./backend/templates/ +RUN install -d -o 10001 -g 10001 /app/backend/data /app/logs +ENV AUTOLIBRARY_HTTP_PORT=3000 +USER 10001:10001 +HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \ + CMD curl -fsS --noproxy '*' --max-time 2 -o /dev/null "http://127.0.0.1:${AUTOLIBRARY_HTTP_PORT}/" \ + && curl -fsS --noproxy '*' --max-time 2 -o /dev/null "http://127.0.0.1:${AUTOLIBRARY_HTTP_PORT}/api/health" +ENTRYPOINT ["/app/webserver"] +CMD ["start", "--config", "/app/config.json"] diff --git a/README.md b/README.md index a047705..2156cdb 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ go build -o webserver . ## 发布与维护 -网站源码推送到 Gitea 的 `main` 后,由生产 Runner 构建、部署并检查服务。首次接入见 [Gitea 自动部署](docs/gitea-deployment.md)。 +网站源码推送到 Gitea 的 `main` 后,由专用 Runner 构建并验证网站镜像。镜像运行、数据挂载和切换步骤见 [容器部署说明](docs/container-deployment.md)。使用原生 systemd 发布时,见 [Gitea 原生部署](docs/gitea-deployment.md)。 完整部署、备份和回滚步骤见 [Ubuntu 部署说明](docs/migration-to-ubuntu.md)。配置和运行时数据与前端构建分开保存;本地现有数据库、主题文件和 Casdoor 配置不需要重建。 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c01d5f9 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,33 @@ +services: + website: + image: ${AUTOLIBRARY_IMAGE:?Set AUTOLIBRARY_IMAGE} + user: "${AUTOLIBRARY_UID:-10001}:${AUTOLIBRARY_GID:-10001}" + network_mode: host + environment: + AUTOLIBRARY_HTTP_PORT: "${AUTOLIBRARY_HTTP_PORT:-3003}" + restart: unless-stopped + stop_grace_period: 20s + read_only: true + tmpfs: + - /tmp:mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + volumes: + - type: bind + source: ${AUTOLIBRARY_CONFIG:?Set AUTOLIBRARY_CONFIG} + target: /app/config.json + read_only: true + bind: + create_host_path: false + - type: bind + source: ${AUTOLIBRARY_DATA_DIR:?Set AUTOLIBRARY_DATA_DIR} + target: /app/backend/data + bind: + create_host_path: false + - type: bind + source: ${AUTOLIBRARY_LOG_DIR:?Set AUTOLIBRARY_LOG_DIR} + target: /app/logs + bind: + create_host_path: false diff --git a/deploy/smoke-container.sh b/deploy/smoke-container.sh new file mode 100644 index 0000000..158788e --- /dev/null +++ b/deploy/smoke-container.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +image=${1:?Usage: bash deploy/smoke-container.sh IMAGE} +container= +fixture= +cleanup() { + local status=$? + if [[ -n $container ]]; then + if ((status != 0)); then + docker logs "$container" >&2 || true + fi + docker rm -f "$container" >/dev/null || true + fi + if [[ -n $fixture ]]; then + docker volume rm "$fixture" >/dev/null || true + fi +} +trap cleanup EXIT + +fixture=$(docker volume create) +docker run --rm -i --network none --user 0:0 --cap-drop ALL \ + --mount "type=volume,source=$fixture,target=/fixture" \ + --entrypoint /bin/sh "$image" \ + -c 'umask 022; cat > /fixture/config.json' < config.example.json +container=$(docker create --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges \ + --mount "type=volume,source=$fixture,target=/fixture,readonly" \ + --tmpfs /app/backend/data:uid=10001,gid=10001,mode=0700 \ + --tmpfs /app/logs:uid=10001,gid=10001,mode=0700 \ + --tmpfs /tmp:mode=1777 "$image" start --config /fixture/config.json) +docker start "$container" >/dev/null + +for ((attempt = 0; attempt < 30; attempt++)); do + if [[ $(docker inspect -f '{{.State.Health.Status}}' "$container") == healthy ]]; then + for path in /marketplace /assets/favicon.svg /api/download/latest; do + docker exec "$container" curl -fsS --noproxy '*' --max-time 3 -o /dev/null \ + "http://127.0.0.1:3000$path" + done + docker stop --time 20 "$container" >/dev/null + [[ $(docker inspect -f '{{.State.ExitCode}}' "$container") == 0 ]] + echo 'Image passed frontend, SQLite API, and graceful shutdown checks.' + exit 0 + fi + if [[ $(docker inspect -f '{{.State.Running}}' "$container") != true ]]; then + break + fi + sleep 2 +done +echo 'Container did not become healthy.' >&2 +exit 1 diff --git a/docs/container-deployment.md b/docs/container-deployment.md new file mode 100644 index 0000000..5572a05 --- /dev/null +++ b/docs/container-deployment.md @@ -0,0 +1,36 @@ +# 网站容器 + +镜像在独立构建阶段编译 Vue 和启用 CGO 的 Go 程序。最终镜像运行单个 `webserver` 进程,同时提供静态资源和 API。容器配置、SQLite、上传文件和日志由部署机挂载。 + +## 构建和验证 + +需要 Linux Docker Engine 和 Buildx。在源码根目录运行: + +```sh +docker build -t autolibrary-web:local . +bash deploy/smoke-container.sh autolibrary-web:local +``` + +冒烟测试使用临时配置和空数据库,在无外部网络的容器里验证首页、后端健康检查、深层路由、静态资源、SQLite API 和 SIGTERM 退出。 + +Gitea Actions 在 `main` 和 `container-ci` 推送时执行同一套镜像构建和冒烟测试。专用 Runner `hk-vps-runner-02` 使用 `linux-amd64-docker-small` 标签,作业在 Docker CLI 容器中运行。给构建作业提供专用 rootless Docker daemon,工作流会检查 daemon 的 rootless 标志。镜像仓库推送和生产切换需要单独接入。 + +## 使用现网配置 + +生产机运行 Compose v2。复制 `/home/autolibrary/config.json` 到独立文件,将 `frontend.static.path` 设置为 `./frontend/dist`。数据库路径使用 `./backend/data/` 下的现有相对路径。保留前端端口 `3003`、后端端口 `8080` 和 Casdoor 地址 `http://127.0.0.1:8000`。Compose 使用 Linux 宿主机网络,让 Nginx 和 Casdoor 继续通过原有地址连接。 + +从部署机设置以下环境变量。镜像可以用提交 SHA 标签或不可变的镜像摘要引用: + +```dotenv +AUTOLIBRARY_IMAGE=autolibrary-web:<提交SHA> +AUTOLIBRARY_CONFIG=/绝对路径/网站容器配置.json +AUTOLIBRARY_DATA_DIR=/home/autolibrary/backend/data +AUTOLIBRARY_LOG_DIR=/home/autolibrary/logs +AUTOLIBRARY_UID=996 +AUTOLIBRARY_GID=987 +AUTOLIBRARY_HTTP_PORT=3003 +``` + +配置文件对容器运行身份可读,数据目录及日志目录可写。Compose 会检查挂载源是否存在。 + +正式切换前,先用独立端口和数据库副本验证容器,并备份数据库和上传文件。切换时停止占用相同端口的 `autolibrary-webserver.service`,运行 `docker compose up -d --wait`,然后验证 Nginx 入口、登录及上传。若首次切换失败,停止网站容器,再启动原网站服务。后续发布可将 `AUTOLIBRARY_IMAGE` 指回上一个镜像,再运行 `docker compose up -d --wait`。程序回退继续使用同一份持久数据。 diff --git a/docs/gitea-deployment.md b/docs/gitea-deployment.md index 44ca5ee..fda9762 100644 --- a/docs/gitea-deployment.md +++ b/docs/gitea-deployment.md @@ -1,6 +1,6 @@ -# 通过 Gitea Actions 部署官网 +# 通过 Gitea Actions 部署原生网站服务 -推送到网站仓库的 `main` 后,Gitea 把任务交给 Ubuntu 部署机上的专用 Runner。Runner 检出该次提交,使用锁文件安装前端依赖,执行前端构建、`go test ./...` 和启用 CGO 的 Go 编译。每次发布生成独立目录,原子切换 `current`,重启 systemd 服务并检查首页和 `/api/health`。检查失败时自动切回上一发布,并将本次任务标记为失败。 +本页用于手动触发原生网站发布。推送触发的镜像构建见 [容器部署说明](container-deployment.md)。在 Gitea Actions 手动运行 `Deploy native AutoLibrary service` 时,任务由具备 `autolibrary-native-deploy` 标签的宿主机 Runner 接收。每次发布生成独立目录,原子切换 `current`,重启 systemd 服务并检查首页和 `/api/health`。检查失败时自动切回上一发布,并将本次任务标记为失败。 ## 1. 准备 Ubuntu 部署机 @@ -52,7 +52,7 @@ autolibrary-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart autolibrary-w sudo -u autolibrary-deploy -H sh -c 'cd "$HOME" && gitea-runner register' ``` -输入实例地址和仓库级令牌,名称填写 `hk-vps-runner-02`,标签只填写 `linux-amd64-docker-small:host`。冒号前是工作流使用的调度标签,`:host` 使发布脚本能操作宿主机上的网站服务。以同一账号和工作目录运行 `gitea-runner daemon`。可用下面的 systemd unit 持久运行,将 `ExecStart` 与 `PATH` 调整到实际安装位置。 +输入实例地址和仓库级令牌,使用独立的原生发布 Runner 名称,标签只填写 `autolibrary-native-deploy:host`。以同一账号和工作目录运行 `gitea-runner daemon`。可用下面的 systemd unit 持久运行,将 `ExecStart` 与 `PATH` 调整到实际安装位置。 ```ini [Unit] @@ -84,7 +84,7 @@ git remote add origin git push -u origin main ``` -在 Gitea 的 Actions 页确认部署任务完成。之后日常发布只需推送 `main`。发布脚本在切换前读取远端 `main`,已过时的任务会跳过部署。生产密钥、数据库、上传目录和 Casdoor 实例数据由部署机保存。 +在 Gitea 的 Actions 页手动运行 `Deploy native AutoLibrary service`,选择 `main`,然后确认部署任务完成。发布脚本在切换前读取远端 `main`,已过时的任务会跳过部署。生产密钥、数据库、上传目录和 Casdoor 实例数据由部署机保存。 ## 排查和回滚