diff --git a/.dockerignore b/.dockerignore index c46374570..102217d89 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,11 +9,16 @@ docs2 data backend/data backend/*.db +backend/cmd/server/server .serena .local web/.next +web/dist web/node_modules web/out +canvas-agent/dist +canvas-agent/node_modules +canvas-agent/pnpm-lock.yaml *.log *.db *.sqlite diff --git a/.env.example b/.env.example index 18b74ad2e..251f6f495 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,13 @@ ENABLE_PROVIDER_PLUGINS=false CANVAS_DATA_PATH=./data CANVAS_WORKER_CONCURRENCY=3 CANVAS_CHANNEL_CONCURRENCY=3 +# 收到 SIGTERM 后等待活动后台任务完成的最长时间;生产 Compose 的 stop_grace_period 使用同一值。 +CANVAS_SHUTDOWN_TIMEOUT=10m +# 开发和旧部署默认由后端启动时迁移;受管生产部署由独立 migrate 服务执行并将此项设为 false。 +CANVAS_AUTO_MIGRATE=true +# 生产在线更新由宿主机 Host Updater 提供;安装脚本生成随机 Token,不要手工提交真实值。 +CANVAS_UPDATER_TOKEN= +CANVAS_UPDATER_SOCKET_DIR=/run/open-ai-canvas-updater # 新服务器请直接运行 README 的一键部署命令;脚本会在 .env 中生成以下 PostgreSQL 配置。 # POSTGRES_DB=open_ai_canvas diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index 941505f08..05a456ff5 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write packages: write jobs: @@ -29,6 +29,18 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Resolve and validate build version + id: build-info + shell: bash + run: | + version="$(tr -d '\r\n' < VERSION)" + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + [[ "$version" == "$GITHUB_REF_NAME" ]] || { echo "VERSION=$version does not match tag $GITHUB_REF_NAME" >&2; exit 1; } + grep -Fqx "## $version" CHANGELOG.md || { echo "CHANGELOG.md is missing ## $version" >&2; exit 1; } + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -59,7 +71,67 @@ jobs: file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 push: true + build-args: | + BUILD_VERSION=${{ steps.build-info.outputs.version }} + BUILD_COMMIT=${{ github.sha }} + BUILD_TIME=${{ steps.build-info.outputs.build_time }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} cache-from: type=gha,scope=${{ matrix.image }} cache-to: type=gha,mode=max,scope=${{ matrix.image }} + + release: + name: Publish release assets + if: startsWith(github.ref, 'refs/tags/v') + needs: publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + + - name: Build Host Updater + working-directory: backend + shell: bash + run: | + mkdir -p ../dist + for arch in amd64 arm64; do + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags="-s -w" -o "../dist/open-ai-canvas-host-updater-linux-$arch" ./cmd/host-updater + done + cd ../dist + sha256sum open-ai-canvas-host-updater-linux-* > SHA256SUMS + + - name: Prepare release notes + shell: bash + run: | + version="$GITHUB_REF_NAME" + awk -v heading="## $version" ' + $0 == heading { found=1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md > dist/RELEASE_NOTES.md + test -s dist/RELEASE_NOTES.md || printf '影策 %s\n' "$version" > dist/RELEASE_NOTES.md + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + prerelease_args=() + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + prerelease_args+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" \ + dist/open-ai-canvas-host-updater-linux-amd64 \ + dist/open-ai-canvas-host-updater-linux-arm64 \ + dist/SHA256SUMS \ + docker-compose.deploy.yml \ + --verify-tag \ + --title "$GITHUB_REF_NAME" \ + --notes-file dist/RELEASE_NOTES.md \ + "${prerelease_args[@]}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4c23fe0..a902d7abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +## v1.2.2-preview.1 + +- 管理后台新增“系统配置 → 系统更新”,自动检查 GitHub Release、比较当前运行版本并展示发布时间、更新日志、前置检查、持久化进度、备份和回退状态。 +- 新增独立 Host Updater systemd 服务,通过带随机 Token 的 Unix Socket 接收管理员操作;业务 Backend 不挂载 Docker Socket。 +- 在线更新强制执行 PostgreSQL 与数据目录 ZIP 备份、ZIP 结构与 SHA-256 复核、目标 Compose 校验、镜像仓库摘要检查、优雅停服、独立数据库迁移、版本健康稳定窗口和更新器二进制自更新。 +- 停服后的迁移、启动或健康检查失败时自动恢复旧 Compose、旧镜像和已校验数据库备份;回退失败明确进入人工介入状态,不会伪造成功。 +- Release 工作流新增 Linux amd64/arm64 Host Updater、`SHA256SUMS`、Compose 和更新日志资产,并继续校验 Git Tag、`VERSION` 与 `CHANGELOG.md` 一致。 + - 新增平台与个人 S3 兼容存储,支持 AWS S3、Cloudflare R2、Backblaze B2、RustFS 和自定义 Endpoint,提供 Path Style、临时凭据、连接测试、历史位置绑定及私网资源 HTTPS 代理;个人配置启用时优先于平台存储。 - 新增用户诊断包导出:设置页和失败任务详情支持导出最近 15 分钟、30 分钟、1 小时或 24 小时内的脱敏 ZIP,并通过 `traceId`、`requestId`、`taskId` 和 `providerRequestId` 关联前端、任务日志与模型上游调用。 - 建立诊断数据安全边界:前端内存事件缓冲区最多保留 500 条,后端按当前用户校验任务和项目归属,导出时排除提示词、任务输入、请求/响应体、密钥、Cookie 和 URL 查询参数,并补充脱敏与越权测试。 diff --git a/Dockerfile b/Dockerfile index 013eaed5a..bfcf2fde4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,9 @@ FROM oven/bun:1.3.13 AS web-build WORKDIR /app/web ARG VITE_TLDRAW_LICENSE_KEY +ARG BUILD_VERSION ENV VITE_TLDRAW_LICENSE_KEY=${VITE_TLDRAW_LICENSE_KEY} +ENV CANVAS_BUILD_VERSION=${BUILD_VERSION} COPY web/package.json web/bun.lock ./ RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --cache-dir=/root/.bun/install/cache COPY VERSION /app/VERSION diff --git a/README.md b/README.md index 4976139a7..66b3f5b63 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,17 @@ sudo docker compose --env-file .env \ curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-server-image.sh | sudo bash ``` -容器包不可匿名拉取时,先通过 `GHCR_USERNAME` 和 `GHCR_TOKEN` 登录 GHCR。需要固定版本或端口时,在 `/opt/open-ai-canvas/.env` 中设置 `CANVAS_IMAGE_TAG`、`CANVAS_HTTP_PORT` 后重新启动。 +容器包不可匿名拉取时,先通过 `GHCR_USERNAME` 和 `GHCR_TOKEN` 登录 GHCR。生产环境应在 `/opt/open-ai-canvas/.env` 中把 `CANVAS_IMAGE_TAG` 固定为具体 Release(不要使用 `latest`),端口由 `CANVAS_HTTP_PORT` 配置。 + +固定版本的 GHCR 部署可安装宿主机在线更新器;安装后管理后台会出现“系统配置 → 系统更新”,更新器会在切换前强制生成并校验 PostgreSQL 与数据目录 ZIP 备份: + +```bash +cd /opt/open-ai-canvas +curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-host-updater.sh | sudo bash +sudo docker compose --env-file .env -f docker-compose.deploy.yml up -d --force-recreate backend web --wait +``` + +更新流程、数据库迁移、健康验证和异常回退说明见 [`docs/content/docs/backend/system-update.mdx`](docs/content/docs/backend/system-update.mdx)。 ### 公网必做事项 diff --git a/VERSION b/VERSION index ef77796c9..012c0f6d9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.2.0-preview.1 +v1.2.2-preview.1 diff --git a/backend/Dockerfile b/backend/Dockerfile index aaf43687e..17523da40 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,16 +3,24 @@ FROM golang:1.25-alpine AS backend-build ARG GOPROXY=https://proxy.golang.org,direct +ARG BUILD_VERSION +ARG BUILD_COMMIT=unknown +ARG BUILD_TIME=unknown ENV GOPROXY=$GOPROXY RUN apk add --no-cache build-base WORKDIR /src/backend +COPY VERSION /src/VERSION COPY backend/go.mod backend/go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY backend ./ -RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/infinite-canvas-backend ./cmd/server \ - && CGO_ENABLED=1 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/migrate-sqlite-postgres ./cmd/migrate-sqlite-postgres +RUN --mount=type=cache,target=/root/.cache/go-build set -eu; \ + version="${BUILD_VERSION:-$(tr -d '\r\n' /dev/null || exit 1 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:8080/api/health/ready >/dev/null || exit 1 CMD ["infinite-canvas-backend"] diff --git a/backend/cmd/host-updater/main.go b/backend/cmd/host-updater/main.go new file mode 100644 index 000000000..d615c65c4 --- /dev/null +++ b/backend/cmd/host-updater/main.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "infinite-canvas/backend/internal/hostupdate" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := run(ctx); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context) error { + socketPath := env("CANVAS_UPDATER_SOCKET", "/run/open-ai-canvas-updater/updater.sock") + token := strings.TrimSpace(os.Getenv("CANVAS_UPDATER_TOKEN")) + manager, err := hostupdate.NewManager(hostupdate.Config{ + Repository: env("CANVAS_UPDATER_REPOSITORY", "ddcat-ai/open-ai-canvas"), + InstallDir: env("CANVAS_UPDATER_INSTALL_DIR", "/opt/open-ai-canvas"), + ComposeFile: env("CANVAS_UPDATER_COMPOSE_FILE", "docker-compose.deploy.yml"), + EnvFile: env("CANVAS_UPDATER_ENV_FILE", ".env"), + StateDir: env("CANVAS_UPDATER_STATE_DIR", "/var/lib/open-ai-canvas-updater"), + BackupDir: env("CANVAS_UPDATER_BACKUP_DIR", "/opt/open-ai-canvas/backups"), + HealthURL: strings.TrimSpace(os.Getenv("CANVAS_UPDATER_HEALTH_URL")), + GitHubToken: strings.TrimSpace(os.Getenv("CANVAS_UPDATER_GITHUB_TOKEN")), + StableWindow: envDuration("CANVAS_UPDATER_STABLE_WINDOW", 30*time.Second), + StepTimeout: envDuration("CANVAS_UPDATER_STEP_TIMEOUT", 20*time.Minute), + BinaryPath: env("CANVAS_UPDATER_BINARY_PATH", "/usr/local/bin/open-ai-canvas-host-updater"), + ServiceName: env("CANVAS_UPDATER_SERVICE_NAME", "open-ai-canvas-updater.service"), + SelfUpdate: envBool("CANVAS_UPDATER_SELF_UPDATE", true), + }) + if err != nil { + return err + } + server, err := hostupdate.NewServer(manager, token) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { + return err + } + if err := removeStaleSocket(socketPath); err != nil { + return err + } + listener, err := net.Listen("unix", socketPath) + if err != nil { + return err + } + defer listener.Close() + defer os.Remove(socketPath) + if err := os.Chmod(socketPath, 0o666); err != nil { + return err + } + httpServer := &http.Server{Handler: server.Handler(), ReadHeaderTimeout: 5 * time.Second} + serveErr := make(chan error, 1) + go func() { serveErr <- httpServer.Serve(listener) }() + log.Printf("host updater listening on unix://%s", socketPath) + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return httpServer.Shutdown(shutdownCtx) + case err := <-serveErr: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +func removeStaleSocket(path string) error { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("拒绝删除非 Socket 路径 %s", path) + } + return os.Remove(path) +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func envDuration(key string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + log.Fatalf("%s 必须是正数时长", key) + } + return parsed +} + +func envBool(key string, fallback bool) bool { + value := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + if value == "" { + return fallback + } + if value == "true" || value == "1" || value == "yes" { + return true + } + if value == "false" || value == "0" || value == "no" { + return false + } + log.Fatalf("%s 必须是 true 或 false", key) + return fallback +} diff --git a/backend/cmd/migrate-schema/main.go b/backend/cmd/migrate-schema/main.go new file mode 100644 index 000000000..ed53db4a0 --- /dev/null +++ b/backend/cmd/migrate-schema/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "strings" + + "infinite-canvas/backend/internal/database" +) + +func main() { + command := "up" + if len(os.Args) > 1 { + command = strings.ToLower(strings.TrimSpace(os.Args[1])) + } + db, err := database.Open(database.Config{ + Driver: env("CANVAS_DATABASE_DRIVER", "sqlite"), + DSN: os.Getenv("DATABASE_URL"), + DataDir: env("CANVAS_BACKEND_DATA_DIR", "data"), + }) + if err != nil { + log.Fatal(err) + } + if err := database.ConfigurePool(db); err != nil { + log.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + log.Fatal(err) + } + defer sqlDB.Close() + + switch command { + case "up": + if err := database.MigrateSchema(db); err != nil { + log.Fatal(err) + } + case "status": + case "verify": + if err := database.RequireSchemaVersion(db); err != nil { + log.Fatal(err) + } + default: + log.Fatalf("未知命令 %q;可用命令:up、status、verify", command) + } + status, err := database.ReadSchemaStatus(db) + if err != nil { + log.Fatal(err) + } + encoded, err := json.Marshal(status) + if err != nil { + log.Fatal(err) + } + fmt.Println(string(encoded)) +} + +func env(key string, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 1388fd7c5..b22c95c28 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,26 +1,41 @@ package main import ( + "context" + "errors" "fmt" "log" + "net" "net/http" "net/url" "os" + "os/signal" + "strconv" "strings" + "syscall" "time" "infinite-canvas/backend/internal/database" "infinite-canvas/backend/internal/handler" "infinite-canvas/backend/internal/repository" "infinite-canvas/backend/internal/service" + "infinite-canvas/backend/internal/updaterclient" "github.com/gin-gonic/gin" ) func main() { + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + if err := run(ctx); err != nil { + log.Fatal(err) + } +} + +func run(ctx context.Context) error { dataDir := env("CANVAS_BACKEND_DATA_DIR", "data") if err := os.MkdirAll(dataDir, 0o755); err != nil { - log.Fatal(err) + return err } db, err := database.Open(database.Config{ Driver: env("CANVAS_DATABASE_DRIVER", "sqlite"), @@ -28,44 +43,60 @@ func main() { DataDir: dataDir, }) if err != nil { - log.Fatal(err) + return err } if err := database.ConfigurePool(db); err != nil { - log.Fatal(err) + return err } - if err := database.MigrateSchema(db); err != nil { - log.Fatal(err) + sqlDB, err := db.DB() + if err != nil { + return err + } + defer sqlDB.Close() + autoMigrate, err := envBool("CANVAS_AUTO_MIGRATE", true) + if err != nil { + return err + } + if autoMigrate { + err = database.MigrateSchema(db) + } else { + err = database.RequireSchemaVersion(db) + } + if err != nil { + return err } repo := repository.New(db) addr := env("CANVAS_BACKEND_ADDR", ":8080") capabilities := service.RuntimeCapabilitiesForDeployment(addr, os.Getenv("CANVAS_DESKTOP_LOCAL_CHANNELS_ENABLED")) svc := service.NewWithRuntimeCapabilities(repo, dataDir, capabilities) + if updaterToken := strings.TrimSpace(os.Getenv("CANVAS_UPDATER_TOKEN")); updaterToken != "" { + svc.ConfigureUpdateManager(updaterclient.New(env("CANVAS_UPDATER_SOCKET", "/run/open-ai-canvas-updater/updater.sock"), updaterToken)) + } + defer svc.Close() if err := svc.ValidateRuntime(); err != nil { - log.Fatal(err) + return err } if err := svc.EnsureSystemChannelModels(); err != nil { - log.Fatal(err) + return err } if err := svc.EnsureDefaultPromptTemplates(); err != nil { - log.Fatal(err) + return err } if err := svc.EnsureBuiltinProjectWorkflowTemplate(); err != nil { - log.Fatal(err) + return err } if err := svc.EnsureBuiltinSkills(); err != nil { - log.Fatal(err) + return err } if err := svc.EnsureSkillPackages(); err != nil { - log.Fatal(err) + return err } if summary, err := svc.MigrateLegacyStorage(); err != nil { log.Printf("storage migration skipped after error: %v", err) } else if summary.Backup != "" { log.Printf("storage migration completed: tasks=%d assets=%d projects=%d backup=%s", summary.Tasks, summary.Assets, summary.Projects, summary.Backup) } - svc.StartWorker() - r := gin.New() r.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { return fmt.Sprintf("%s - [%s] \"%s %s\" %d %s %s\n", param.ClientIP, param.TimeStamp.Format(time.RFC3339), param.Method, redactCanvasSharePath(param.Path), param.StatusCode, param.Latency, param.ErrorMessage) @@ -73,14 +104,13 @@ func main() { r.Use(handler.RequestCorrelationMiddleware()) corsMiddleware, err := cors() if err != nil { - log.Fatal(err) + return err } r.Use(corsMiddleware) handler.ConfigureRuntime(svc) api := r.Group("/api") - api.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{"code": 0, "data": gin.H{"status": "ok"}, "msg": "ok"}) - }) + status := newSystemStatus(db, svc) + registerSystemStatusRoutes(api, status) handler.RegisterOAuthCallbackRoutes(r, svc) handler.RegisterAuthRoutes(api, svc) handler.RegisterFeatureAvailabilityRoutes(api, svc) @@ -88,6 +118,7 @@ func main() { handler.RegisterAdminRoutes(api, svc) handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAdminStorageRoutes(api, svc) + handler.RegisterAdminUpdateRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) handler.RegisterLibTVRoutes(api, svc) @@ -112,10 +143,52 @@ func main() { handler.RegisterCanvasShareRoutes(api, svc) r.NoRoute(handler.SystemProxyNoRouteHandler(svc)) - log.Printf("影策 backend listening on %s", addr) - if err := r.Run(addr); err != nil { - log.Fatal(err) + listener, err := net.Listen("tcp", addr) + if err != nil { + return err } + workerTimeout, err := envDuration("CANVAS_SHUTDOWN_TIMEOUT", 10*time.Minute) + if err != nil { + _ = listener.Close() + return err + } + httpServer := &http.Server{Handler: r, ReadHeaderTimeout: 10 * time.Second} + svc.StartWorker() + status.markStarted() + serveErr := make(chan error, 1) + go func() { serveErr <- httpServer.Serve(listener) }() + log.Printf("影策 backend listening on %s", addr) + + var serveFailure error + select { + case <-ctx.Done(): + case err := <-serveErr: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + serveFailure = fmt.Errorf("HTTP 服务异常退出:%w", err) + } + } + + status.beginDrain() + var shutdownFailures []error + if serveFailure != nil { + shutdownFailures = append(shutdownFailures, serveFailure) + } + httpShutdownCtx, httpShutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer httpShutdownCancel() + if err := httpServer.Shutdown(httpShutdownCtx); err != nil { + _ = httpServer.Close() + shutdownFailures = append(shutdownFailures, fmt.Errorf("关闭 HTTP 服务:%w", err)) + } + workerCtx, workerCancel := context.WithTimeout(context.Background(), workerTimeout) + defer workerCancel() + if err := svc.StopWorker(workerCtx); err != nil { + shutdownFailures = append(shutdownFailures, fmt.Errorf("等待后台任务退出:%w", err)) + } + if err := errors.Join(shutdownFailures...); err != nil { + return err + } + log.Printf("影策 backend stopped gracefully") + return nil } func redactCanvasSharePath(path string) string { @@ -138,6 +211,30 @@ func env(key string, fallback string) string { return value } +func envBool(key string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s 必须是 true 或 false", key) + } + return parsed, nil +} + +func envDuration(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(value) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s 必须是正数时长,例如 10m", key) + } + return parsed, nil +} + const corsAllowedHeaders = "Accept, Content-Type, Authorization, X-Requested-With, X-Canvas-Scene, X-Idempotency-Key, X-Canvas-Trace-ID, X-Canvas-Upstream-URL, X-Canvas-Upstream-Format, X-Canvas-Allow-Local-Channel, X-Canvas-Upstream-Base-URL" const corsAllowedMethods = "GET, POST, PUT, PATCH, DELETE, OPTIONS" diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go index cf8b30a04..9b326148b 100644 --- a/backend/cmd/server/main_test.go +++ b/backend/cmd/server/main_test.go @@ -3,6 +3,7 @@ package main import ( "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" ) @@ -19,6 +20,19 @@ func TestAllowedOriginWildcard(t *testing.T) { } } +func TestEnvironmentParsers(t *testing.T) { + t.Setenv("CANVAS_AUTO_MIGRATE", "false") + value, err := envBool("CANVAS_AUTO_MIGRATE", true) + if err != nil || value { + t.Fatalf("envBool = %v, %v", value, err) + } + t.Setenv("CANVAS_SHUTDOWN_TIMEOUT", "45s") + duration, err := envDuration("CANVAS_SHUTDOWN_TIMEOUT", time.Minute) + if err != nil || duration != 45*time.Second { + t.Fatalf("envDuration = %v, %v", duration, err) + } +} + func TestAllowedOriginUsesForwardedHost(t *testing.T) { t.Setenv("CANVAS_CORS_ORIGINS", "") context, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/cmd/server/system_status.go b/backend/cmd/server/system_status.go new file mode 100644 index 000000000..283876902 --- /dev/null +++ b/backend/cmd/server/system_status.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "net/http" + "sync/atomic" + + "infinite-canvas/backend/internal/buildinfo" + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type systemStatus struct { + db *gorm.DB + service *service.Service + started atomic.Bool + draining atomic.Bool +} + +type systemStatusSnapshot struct { + Status string `json:"status"` + Ready bool `json:"ready"` + Started bool `json:"started"` + Draining bool `json:"draining"` + ActiveWorkerTasks int64 `json:"activeWorkerTasks"` + Build buildinfo.Info `json:"build"` + Schema database.SchemaStatus `json:"schema"` + Checks systemStatusChecks `json:"checks"` +} + +type systemStatusChecks struct { + Database bool `json:"database"` + Runtime bool `json:"runtime"` + Schema bool `json:"schema"` +} + +func newSystemStatus(db *gorm.DB, svc *service.Service) *systemStatus { + return &systemStatus{db: db, service: svc} +} + +func (s *systemStatus) markStarted() { s.started.Store(true) } + +func (s *systemStatus) beginDrain() { + s.draining.Store(true) + if s.service != nil { + s.service.BeginDrain() + } +} + +func (s *systemStatus) snapshot(ctx context.Context) systemStatusSnapshot { + snapshot := systemStatusSnapshot{ + Started: s.started.Load(), + Draining: s.draining.Load(), + Build: buildinfo.Current(), + Schema: database.SchemaStatus{Expected: database.CurrentSchemaVersion}, + } + if s.service != nil { + snapshot.ActiveWorkerTasks = s.service.ActiveWorkerTasks() + snapshot.Checks.Runtime = s.service.ValidateRuntime() == nil + } + if s.db != nil { + snapshot.Checks.Database = s.db.WithContext(ctx).Exec("SELECT 1").Error == nil + if schema, err := database.ReadSchemaStatus(s.db); err == nil { + snapshot.Schema = schema + snapshot.Checks.Schema = schema.Ready + } + } + snapshot.Ready = snapshot.Started && !snapshot.Draining && snapshot.Checks.Database && snapshot.Checks.Runtime && snapshot.Checks.Schema + switch { + case snapshot.Draining: + snapshot.Status = "draining" + case snapshot.Ready: + snapshot.Status = "ok" + case !snapshot.Started: + snapshot.Status = "starting" + default: + snapshot.Status = "unhealthy" + } + return snapshot +} + +func registerSystemStatusRoutes(api *gin.RouterGroup, status *systemStatus) { + api.GET("/health/live", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"status": "ok", "build": buildinfo.Current()}, "msg": "ok"}) + }) + startup := func(c *gin.Context) { + snapshot := status.snapshot(c.Request.Context()) + if !snapshot.Started { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "data": snapshot, "msg": "服务仍在启动"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": snapshot, "msg": "ok"}) + } + ready := func(c *gin.Context) { + snapshot := status.snapshot(c.Request.Context()) + if !snapshot.Ready { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "data": snapshot, "msg": "服务暂未就绪"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": 0, "data": snapshot, "msg": "ok"}) + } + api.GET("/health/startup", startup) + api.GET("/health/ready", ready) + api.GET("/health", ready) + api.GET("/system/version", func(c *gin.Context) { + snapshot := status.snapshot(c.Request.Context()) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"build": snapshot.Build, "schema": snapshot.Schema}, "msg": "ok"}) + }) +} diff --git a/backend/cmd/server/system_status_test.go b/backend/cmd/server/system_status_test.go new file mode 100644 index 000000000..0dffd2732 --- /dev/null +++ b/backend/cmd/server/system_status_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestSystemReadinessTracksStartupAndDrain(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.Open(database.Config{Driver: "sqlite", DSN: "file:system-status?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + t.Fatal(err) + } + svc := service.New(repository.New(db), t.TempDir()) + status := newSystemStatus(db, svc) + router := gin.New() + registerSystemStatusRoutes(router.Group("/api"), status) + + assertStatus(t, router, "/api/health/live", http.StatusOK) + assertStatus(t, router, "/api/health/ready", http.StatusServiceUnavailable) + status.markStarted() + assertStatus(t, router, "/api/health/ready", http.StatusOK) + status.beginDrain() + assertStatus(t, router, "/api/health/ready", http.StatusServiceUnavailable) + assertStatus(t, router, "/api/health/live", http.StatusOK) +} + +func TestSystemReadinessRejectsMissingSchemaVersion(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.Open(database.Config{Driver: "sqlite", DSN: "file:system-status-schema?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + svc := service.New(repository.New(db), t.TempDir()) + status := newSystemStatus(db, svc) + status.markStarted() + router := gin.New() + registerSystemStatusRoutes(router.Group("/api"), status) + assertStatus(t, router, "/api/health/ready", http.StatusServiceUnavailable) +} + +func assertStatus(t *testing.T, handler http.Handler, path string, want int) { + t.Helper() + request := httptest.NewRequest(http.MethodGet, path, nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + if recorder.Code != want { + t.Fatalf("GET %s status = %d, want %d; body=%s", path, recorder.Code, want, recorder.Body.String()) + } +} diff --git a/backend/internal/buildinfo/buildinfo.go b/backend/internal/buildinfo/buildinfo.go new file mode 100644 index 000000000..e4e291979 --- /dev/null +++ b/backend/internal/buildinfo/buildinfo.go @@ -0,0 +1,49 @@ +package buildinfo + +import ( + "runtime" + "runtime/debug" + "strings" +) + +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" +) + +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildTime string `json:"buildTime"` + GoVersion string `json:"goVersion"` +} + +func Current() Info { + version := normalized(Version, "dev") + commit := normalized(Commit, "unknown") + buildTime := normalized(BuildTime, "unknown") + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + switch setting.Key { + case "vcs.revision": + if commit == "unknown" { + commit = normalized(setting.Value, commit) + } + case "vcs.time": + if buildTime == "unknown" { + buildTime = normalized(setting.Value, buildTime) + } + } + } + } + return Info{Version: version, Commit: commit, BuildTime: buildTime, GoVersion: runtime.Version()} +} + +func normalized(value string, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/backend/internal/buildinfo/buildinfo_test.go b/backend/internal/buildinfo/buildinfo_test.go new file mode 100644 index 000000000..8ab862916 --- /dev/null +++ b/backend/internal/buildinfo/buildinfo_test.go @@ -0,0 +1,17 @@ +package buildinfo + +import "testing" + +func TestCurrentUsesInjectedValues(t *testing.T) { + previousVersion, previousCommit, previousBuildTime := Version, Commit, BuildTime + t.Cleanup(func() { Version, Commit, BuildTime = previousVersion, previousCommit, previousBuildTime }) + Version, Commit, BuildTime = " v1.2.3 ", " abc123 ", " 2026-08-30T00:00:00Z " + + info := Current() + if info.Version != "v1.2.3" || info.Commit != "abc123" || info.BuildTime != "2026-08-30T00:00:00Z" { + t.Fatalf("unexpected build info: %#v", info) + } + if info.GoVersion == "" { + t.Fatal("Go version should be populated") + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go new file mode 100644 index 000000000..937ec3bf6 --- /dev/null +++ b/backend/internal/database/migrations.go @@ -0,0 +1,133 @@ +package database + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +const CurrentSchemaVersion int64 = 1 + +const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" + +const postgresSchemaMigrationLockID int64 = 73123910420260830 + +type SchemaStatus struct { + Current int64 `json:"current"` + Expected int64 `json:"expected"` + Ready bool `json:"ready"` +} + +type schemaMigration struct { + Version int64 `gorm:"primaryKey"` + Name string `gorm:"size:160;not null"` + Checksum string `gorm:"size:96;not null"` + AppliedAt time.Time `gorm:"not null"` +} + +func (schemaMigration) TableName() string { return "schema_migrations" } + +type migration struct { + version int64 + name string + checksum string + apply func(*gorm.DB) error +} + +var schemaMigrations = []migration{ + {version: 1, name: "baseline_gorm_schema", checksum: baselineSchemaChecksum, apply: migrateSchemaV1}, +} + +func MigrateSchema(db *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { + if tx.Dialector.Name() == "postgres" { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", postgresSchemaMigrationLockID).Error; err != nil { + return fmt.Errorf("获取数据库迁移锁:%w", err) + } + } + if err := tx.AutoMigrate(&schemaMigration{}); err != nil { + return fmt.Errorf("初始化数据库迁移记录:%w", err) + } + for _, item := range schemaMigrations { + var applied schemaMigration + err := tx.First(&applied, "version = ?", item.version).Error + if err == nil { + if err := validateMigrationRecord(applied, item); err != nil { + return err + } + continue + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("读取数据库迁移 %d:%w", item.version, err) + } + if err := item.apply(tx); err != nil { + return fmt.Errorf("执行数据库迁移 %d(%s):%w", item.version, item.name, err) + } + record := schemaMigration{Version: item.version, Name: item.name, Checksum: item.checksum, AppliedAt: time.Now().UTC()} + if err := tx.Create(&record).Error; err != nil { + return fmt.Errorf("记录数据库迁移 %d:%w", item.version, err) + } + } + return RequireSchemaVersion(tx) + }) +} + +func ReadSchemaStatus(db *gorm.DB) (SchemaStatus, error) { + status := SchemaStatus{Expected: CurrentSchemaVersion} + if !db.Migrator().HasTable(&schemaMigration{}) { + return status, nil + } + if err := db.Model(&schemaMigration{}).Select("COALESCE(MAX(version), 0)").Scan(&status.Current).Error; err != nil { + return status, fmt.Errorf("读取数据库结构版本:%w", err) + } + if status.Current != status.Expected { + return status, nil + } + if err := validateMigrationRecords(db); err != nil { + return status, err + } + status.Ready = true + return status, nil +} + +func validateMigrationRecords(db *gorm.DB) error { + for _, item := range schemaMigrations { + var applied schemaMigration + if err := db.First(&applied, "version = ?", item.version).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("数据库缺少迁移记录 %d(%s)", item.version, item.name) + } + return fmt.Errorf("读取数据库迁移 %d:%w", item.version, err) + } + if err := validateMigrationRecord(applied, item); err != nil { + return err + } + } + return nil +} + +func validateMigrationRecord(applied schemaMigration, expected migration) error { + if applied.Name != expected.name { + return fmt.Errorf("数据库迁移 %d 名称不一致:记录为 %s,程序期望 %s", expected.version, applied.Name, expected.name) + } + if applied.Checksum != expected.checksum { + return fmt.Errorf("数据库迁移 %d 校验和不一致:记录为 %s,程序期望 %s", expected.version, applied.Checksum, expected.checksum) + } + return nil +} + +func RequireSchemaVersion(db *gorm.DB) error { + status, err := ReadSchemaStatus(db) + if err != nil { + return err + } + if status.Current < status.Expected { + return fmt.Errorf("数据库结构版本过旧:当前 %d,程序要求 %d,请先执行 migrate-schema up", status.Current, status.Expected) + } + if status.Current > status.Expected { + return fmt.Errorf("数据库结构版本 %d 高于程序支持的 %d,拒绝使用旧程序连接新数据库", status.Current, status.Expected) + } + return nil +} diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go new file mode 100644 index 000000000..8b8fcd82d --- /dev/null +++ b/backend/internal/database/migrations_test.go @@ -0,0 +1,96 @@ +package database + +import ( + "errors" + "strings" + "testing" + + "gorm.io/gorm" +) + +func TestMigrateSchemaRecordsAndValidatesVersion(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-version?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + status, err := ReadSchemaStatus(db) + if err != nil { + t.Fatal(err) + } + if !status.Ready || status.Current != CurrentSchemaVersion { + t.Fatalf("unexpected schema status: %#v", status) + } + if err := MigrateSchema(db); err != nil { + t.Fatalf("migration should be idempotent: %v", err) + } +} + +func TestMigrateSchemaRejectsChecksumMismatch(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-checksum?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + if err := db.Model(&schemaMigration{}).Where("version = ?", CurrentSchemaVersion).Update("checksum", "changed").Error; err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err == nil || !strings.Contains(err.Error(), "校验和不一致") { + t.Fatalf("expected checksum mismatch, got %v", err) + } + if err := RequireSchemaVersion(db); err == nil || !strings.Contains(err.Error(), "校验和不一致") { + t.Fatalf("schema verification must reject checksum mismatch, got %v", err) + } +} + +func TestMigrateSchemaRollsBackFailedMigration(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-rollback?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := MigrateSchema(db); err != nil { + t.Fatal(err) + } + + original := schemaMigrations + schemaMigrations = append(append([]migration(nil), original...), migration{ + version: CurrentSchemaVersion + 1, + name: "rollback_probe", + checksum: "sha256:rollback-probe", + apply: func(tx *gorm.DB) error { + if err := tx.Exec("CREATE TABLE migration_rollback_probe (id INTEGER PRIMARY KEY)").Error; err != nil { + return err + } + return errors.New("forced migration failure") + }, + }) + t.Cleanup(func() { schemaMigrations = original }) + + if err := MigrateSchema(db); err == nil || !strings.Contains(err.Error(), "forced migration failure") { + t.Fatalf("expected forced migration failure, got %v", err) + } + if db.Migrator().HasTable("migration_rollback_probe") { + t.Fatal("failed migration left a partial table behind") + } + var count int64 + if err := db.Model(&schemaMigration{}).Where("version = ?", CurrentSchemaVersion+1).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("failed migration was recorded: %d", count) + } +} + +func TestRequireSchemaVersionRejectsUninitializedDatabase(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:migration-uninitialized?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + if err := RequireSchemaVersion(db); err == nil || !strings.Contains(err.Error(), "请先执行 migrate-schema up") { + t.Fatalf("expected missing migration error, got %v", err) + } +} diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 8995a222d..96b0b120c 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -91,7 +91,7 @@ func Models() []any { } } -func MigrateSchema(db *gorm.DB) error { +func migrateSchemaV1(db *gorm.DB) error { // 旧表只保存 Updream 目录状态,与本地技能主键没有可迁移关系;首次升级时按产品要求清空重建。 if db.Migrator().HasColumn(&model.UserSkillState{}, "skill_dir") && !db.Migrator().HasColumn(&model.UserSkillState{}, "skill_id") { if err := db.Migrator().DropTable(&model.UserSkillState{}); err != nil { diff --git a/backend/internal/handler/admin_update.go b/backend/internal/handler/admin_update.go new file mode 100644 index 000000000..241d0c8b1 --- /dev/null +++ b/backend/internal/handler/admin_update.go @@ -0,0 +1,80 @@ +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAdminUpdateRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/system-update", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + status, err := svc.AdminUpdateStatus(c.Request.Context(), actor) + if err != nil { + failService(c, err) + return + } + ok(c, status) + }) + r.POST("/admin/system-update/check", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + status, err := svc.AdminCheckUpdate(c.Request.Context(), actor) + if err != nil { + failService(c, err) + return + } + ok(c, status) + }) + r.POST("/admin/system-update/start", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var request struct { + TargetVersion string `json:"targetVersion" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + status, err := svc.AdminStartUpdate(c.Request.Context(), actor, request.TargetVersion) + if err != nil { + failService(c, err) + return + } + ok(c, status) + }) + r.POST("/admin/system-update/rollback", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var request struct { + Reason string `json:"reason" binding:"required"` + } + if err := c.ShouldBindJSON(&request); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + status, err := svc.AdminRollbackUpdate(c.Request.Context(), actor, request.Reason) + if err != nil { + failService(c, err) + return + } + ok(c, status) + }) +} diff --git a/backend/internal/hostupdate/manager.go b/backend/internal/hostupdate/manager.go new file mode 100644 index 000000000..30f5d027e --- /dev/null +++ b/backend/internal/hostupdate/manager.go @@ -0,0 +1,550 @@ +package hostupdate + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +const maxReleaseResponseBytes = 4 << 20 + +type Config struct { + Repository string + InstallDir string + ComposeFile string + EnvFile string + StateDir string + BackupDir string + HealthURL string + GitHubToken string + StableWindow time.Duration + StepTimeout time.Duration + BinaryPath string + ServiceName string + SelfUpdate bool +} + +type commandRunner interface { + Run(context.Context, string, []string, []string, io.Writer, io.Writer) error +} + +type execRunner struct{ dir string } + +func (r execRunner) Run(ctx context.Context, name string, args, environment []string, stdout, stderr io.Writer) error { + command := exec.CommandContext(ctx, name, args...) + command.Dir = r.dir + command.Env = append(os.Environ(), environment...) + command.Stdout = stdout + command.Stderr = stderr + return command.Run() +} + +type persistedState struct { + LatestRelease *Release `json:"latestRelease,omitempty"` + LastBackup *Backup `json:"lastBackup,omitempty"` + RollbackVersion string `json:"rollbackVersion,omitempty"` + Operation Operation `json:"operation"` +} + +type Manager struct { + mu sync.Mutex + config Config + runner commandRunner + httpClient *http.Client + state persistedState +} + +func NewManager(config Config) (*Manager, error) { + config.Repository = strings.TrimSpace(config.Repository) + if config.Repository == "" { + config.Repository = "ddcat-ai/open-ai-canvas" + } + if config.InstallDir == "" { + config.InstallDir = "/opt/open-ai-canvas" + } + if config.ComposeFile == "" { + config.ComposeFile = "docker-compose.deploy.yml" + } + if config.EnvFile == "" { + config.EnvFile = ".env" + } + if config.StateDir == "" { + config.StateDir = "/var/lib/open-ai-canvas-updater" + } + if config.BackupDir == "" { + config.BackupDir = filepath.Join(config.InstallDir, "backups") + } + if config.StableWindow <= 0 { + config.StableWindow = 30 * time.Second + } + if config.StepTimeout <= 0 { + config.StepTimeout = 20 * time.Minute + } + if err := os.MkdirAll(config.StateDir, 0o700); err != nil { + return nil, fmt.Errorf("创建更新器状态目录:%w", err) + } + if err := os.MkdirAll(config.BackupDir, 0o700); err != nil { + return nil, fmt.Errorf("创建备份目录:%w", err) + } + manager := &Manager{ + config: config, + runner: execRunner{dir: config.InstallDir}, + httpClient: &http.Client{Timeout: 30 * time.Second}, + state: persistedState{Operation: Operation{Phase: PhaseIdle, Logs: []LogEntry{}}}, + } + if err := manager.loadState(); err != nil { + return nil, err + } + if manager.state.Operation.Phase.Active() { + manager.state.Operation.Phase = PhaseManualIntervention + manager.state.Operation.Error = "更新器进程在操作期间退出,请检查容器与数据库状态后手动处理" + manager.appendLogLocked(PhaseManualIntervention, manager.state.Operation.Error) + _ = manager.saveStateLocked() + } + return manager, nil +} + +func (m *Manager) Snapshot() Status { + m.mu.Lock() + defer m.mu.Unlock() + return m.snapshotLocked() +} + +func (m *Manager) Check(ctx context.Context) (Status, error) { + m.mu.Lock() + if m.state.Operation.Phase.Active() { + status := m.snapshotLocked() + m.mu.Unlock() + return status, errors.New("更新操作正在进行,暂时不能检查新版本") + } + m.state.Operation.Phase = PhaseChecking + m.state.Operation.Error = "" + m.appendLogLocked(PhaseChecking, "正在读取 GitHub Release") + _ = m.saveStateLocked() + m.mu.Unlock() + + release, err := m.latestRelease(ctx) + m.mu.Lock() + defer m.mu.Unlock() + if err != nil { + m.state.Operation.Phase = PhaseFailed + m.state.Operation.Error = err.Error() + m.appendLogLocked(PhaseFailed, "检查更新失败") + _ = m.saveStateLocked() + return m.snapshotLocked(), err + } + m.state.LatestRelease = release + current, currentErr := m.currentVersion() + if currentErr != nil { + m.state.Operation.Phase = PhaseFailed + m.state.Operation.Error = currentErr.Error() + _ = m.saveStateLocked() + return m.snapshotLocked(), currentErr + } + if CompareVersions(current, release.Version) < 0 { + m.state.Operation.Phase = PhaseReady + m.appendLogLocked(PhaseReady, fmt.Sprintf("发现新版本 %s", release.Version)) + } else { + m.state.Operation.Phase = PhaseNoUpdate + m.appendLogLocked(PhaseNoUpdate, "当前已是最新版本") + } + _ = m.saveStateLocked() + return m.snapshotLocked(), nil +} + +func (m *Manager) StartUpdate(targetVersion string) (Status, error) { + targetVersion = strings.TrimSpace(targetVersion) + m.mu.Lock() + defer m.mu.Unlock() + if m.state.Operation.Phase.Active() { + return m.snapshotLocked(), errors.New("已有更新操作正在进行") + } + if m.state.LatestRelease == nil || m.state.LatestRelease.Version != targetVersion { + return m.snapshotLocked(), errors.New("目标版本与最近一次检查结果不一致,请重新检查更新") + } + current, err := m.currentVersion() + if err != nil { + return m.snapshotLocked(), err + } + if CompareVersions(current, targetVersion) >= 0 { + return m.snapshotLocked(), errors.New("目标版本必须高于当前版本") + } + now := time.Now().UTC() + m.state.Operation = Operation{ + ID: randomID(), + Phase: PhasePreflight, + FromVersion: current, + TargetVersion: targetVersion, + StartedAt: &now, + Logs: []LogEntry{{At: now, Phase: PhasePreflight, Message: "开始更新前检查"}}, + } + _ = m.saveStateLocked() + go m.runUpdate(current, targetVersion) + return m.snapshotLocked(), nil +} + +func (m *Manager) StartRollback(reason string) (Status, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.state.Operation.Phase.Active() { + return m.snapshotLocked(), errors.New("已有更新操作正在进行") + } + if m.state.RollbackVersion == "" || m.state.LastBackup == nil { + return m.snapshotLocked(), errors.New("没有可用的回退版本或已校验备份") + } + current, err := m.currentVersion() + if err != nil { + return m.snapshotLocked(), err + } + now := time.Now().UTC() + target := m.state.RollbackVersion + m.state.Operation = Operation{ + ID: randomID(), + Phase: PhaseRollingBack, + FromVersion: current, + TargetVersion: target, + StartedAt: &now, + Logs: []LogEntry{{At: now, Phase: PhaseRollingBack, Message: "管理员发起人工回退:" + strings.TrimSpace(reason)}}, + } + _ = m.saveStateLocked() + backup := *m.state.LastBackup + go m.runRollback(target, backup, false) + return m.snapshotLocked(), nil +} + +func (m *Manager) runUpdate(fromVersion, targetVersion string) { + nextCompose, err := m.prepareTargetCompose(targetVersion) + if err != nil { + m.failWithoutRollback(PhaseFailed, err) + return + } + defer os.Remove(nextCompose) + if err := m.preflight(nextCompose, targetVersion); err != nil { + m.failWithoutRollback(PhaseFailed, err) + return + } + updaterBinary, err := m.prepareUpdaterBinary(targetVersion) + if err != nil { + m.failWithoutRollback(PhaseFailed, err) + return + } + if updaterBinary != "" { + defer os.Remove(updaterBinary) + } + + m.setPhase(PhaseBackingUp, "创建 PostgreSQL 与数据目录 ZIP 备份") + backup, err := m.createBackup(fromVersion) + if err != nil { + m.failWithoutRollback(PhaseFailed, err) + return + } + m.mu.Lock() + m.state.LastBackup = &backup + m.state.RollbackVersion = fromVersion + _ = m.saveStateLocked() + m.mu.Unlock() + if err := replaceFile(m.composePath(), m.previousComposePath(), 0o600); err != nil { + m.failWithoutRollback(PhaseFailed, fmt.Errorf("保存旧 Compose 配置:%w", err)) + return + } + + m.setPhase(PhasePulling, "拉取目标版本镜像") + if err := m.compose(nextCompose, targetVersion, m.config.StepTimeout, nil, "pull", "backend", "web"); err != nil { + m.failWithoutRollback(PhaseFailed, err) + return + } + if err := m.verifyImages(targetVersion); err != nil { + m.failWithoutRollback(PhaseFailed, err) + return + } + + m.setPhase(PhaseDraining, "停止 Web 与 Backend,等待后台任务优雅退出") + if err := m.compose(m.composePath(), fromVersion, m.config.StepTimeout, nil, "stop", "web", "backend"); err != nil { + m.failWithRollback(err, fromVersion, backup) + return + } + + m.setPhase(PhaseMigrating, "执行目标版本数据库迁移") + if err := m.compose(nextCompose, targetVersion, m.config.StepTimeout, nil, "run", "--rm", "migrate"); err != nil { + m.failWithRollback(err, fromVersion, backup) + return + } + + m.setPhase(PhaseSwitching, "切换 Compose 配置与镜像版本") + if err := setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(targetVersion, "v")); err != nil { + m.failWithRollback(err, fromVersion, backup) + return + } + if err := replaceFile(nextCompose, m.composePath(), 0o600); err != nil { + _ = setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(fromVersion, "v")) + m.failWithRollback(err, fromVersion, backup) + return + } + if err := m.compose(m.composePath(), targetVersion, m.config.StepTimeout, nil, "up", "-d", "--remove-orphans", "--wait", "--wait-timeout", "600"); err != nil { + m.failWithRollback(err, fromVersion, backup) + return + } + + m.setPhase(PhaseVerifying, "验证健康状态、运行版本和稳定窗口") + if err := m.verifyHealthy(targetVersion); err != nil { + m.failWithRollback(err, fromVersion, backup) + return + } + if updaterBinary != "" { + if err := replaceFile(updaterBinary, m.config.BinaryPath, 0o755); err != nil { + m.mu.Lock() + now := time.Now().UTC() + m.state.Operation.Phase = PhaseManualIntervention + m.state.Operation.FinishedAt = &now + m.state.Operation.Error = "应用已更新,但 Host Updater 自更新失败:" + safeOperationError(err) + m.appendLogLocked(PhaseManualIntervention, "应用版本已切换,更新器二进制需要人工更新") + _ = m.saveStateLocked() + m.mu.Unlock() + return + } + } + m.mu.Lock() + now := time.Now().UTC() + m.state.Operation.Phase = PhaseSucceeded + m.state.Operation.FinishedAt = &now + m.state.Operation.Error = "" + m.appendLogLocked(PhaseSucceeded, fmt.Sprintf("已成功更新到 %s", targetVersion)) + if updaterBinary != "" { + m.appendLogLocked(PhaseSucceeded, "Host Updater 二进制已同步到目标 Release") + } + _ = m.saveStateLocked() + m.mu.Unlock() + if updaterBinary != "" && m.config.ServiceName != "" { + go m.restartSelf() + } +} + +func (m *Manager) failWithoutRollback(phase Phase, err error) { + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now().UTC() + m.state.Operation.Phase = phase + m.state.Operation.Error = safeOperationError(err) + m.state.Operation.FinishedAt = &now + m.appendLogLocked(phase, "更新已中止,尚未修改运行版本") + _ = m.saveStateLocked() +} + +func (m *Manager) failWithRollback(cause error, targetVersion string, backup Backup) { + m.mu.Lock() + m.state.Operation.Error = safeOperationError(cause) + m.state.Operation.AutomaticRollback = true + m.state.Operation.Phase = PhaseRollingBack + m.appendLogLocked(PhaseRollingBack, "更新失败,正在自动恢复旧版本和数据库备份") + _ = m.saveStateLocked() + m.mu.Unlock() + m.runRollback(targetVersion, backup, true) +} + +func (m *Manager) runRollback(targetVersion string, backup Backup, automatic bool) { + var failures []error + if err := m.compose(m.composePath(), targetVersion, 5*time.Minute, nil, "stop", "web", "backend"); err != nil { + failures = append(failures, err) + } + if _, err := os.Stat(m.previousComposePath()); err == nil { + if err := replaceFile(m.previousComposePath(), m.composePath(), 0o600); err != nil { + failures = append(failures, fmt.Errorf("恢复旧 Compose 配置:%w", err)) + } + } + if err := m.restoreDatabase(backup); err != nil { + failures = append(failures, err) + } + if err := setEnvValue(m.envPath(), "CANVAS_IMAGE_TAG", strings.TrimPrefix(targetVersion, "v")); err != nil { + failures = append(failures, err) + } + started := true + if err := m.compose(m.composePath(), targetVersion, m.config.StepTimeout, nil, "up", "-d", "--remove-orphans", "--wait", "--wait-timeout", "600"); err != nil { + started = false + failures = append(failures, err) + } + if started { + if err := m.verifyHealthy(targetVersion); err != nil { + failures = append(failures, err) + } + } + rollbackErr := errors.Join(failures...) + m.mu.Lock() + defer m.mu.Unlock() + now := time.Now().UTC() + m.state.Operation.FinishedAt = &now + m.state.Operation.AutomaticRollback = automatic + if rollbackErr != nil { + m.state.Operation.Phase = PhaseManualIntervention + m.state.Operation.RollbackError = safeOperationError(rollbackErr) + m.appendLogLocked(PhaseManualIntervention, "自动回退未能完成,需要人工介入") + } else { + m.state.Operation.Phase = PhaseRolledBack + m.state.Operation.RollbackError = "" + m.appendLogLocked(PhaseRolledBack, fmt.Sprintf("已恢复到 %s", targetVersion)) + } + _ = m.saveStateLocked() +} + +func (m *Manager) snapshotLocked() Status { + current, err := m.currentVersion() + checks := m.checks(current, err) + status := Status{ + Supported: true, + Connected: true, + Repository: m.config.Repository, + Deployment: "docker-compose-host-updater", + CurrentVersion: current, + LatestRelease: cloneRelease(m.state.LatestRelease), + Checks: checks, + LastBackup: cloneBackup(m.state.LastBackup), + RollbackVersion: m.state.RollbackVersion, + Operation: cloneOperation(m.state.Operation), + } + if status.LatestRelease != nil && err == nil { + status.UpdateAvailable = CompareVersions(current, status.LatestRelease.Version) < 0 + } + return status +} + +func (m *Manager) checks(current string, currentErr error) []Check { + items := []Check{ + {Key: "updater", Label: "Host Updater", Status: "passed", Detail: runtime.GOOS + "/" + runtime.GOARCH, Blocking: true}, + {Key: "version", Label: "当前版本", Status: "passed", Detail: current, Blocking: true}, + {Key: "backup", Label: "数据库备份 ZIP", Status: "pending", Detail: "开始更新后自动创建并校验", Blocking: true}, + {Key: "images", Label: "目标镜像", Status: "pending", Detail: "拉取后校验仓库摘要", Blocking: true}, + {Key: "migration", Label: "数据库迁移", Status: "pending", Detail: "在旧服务完全停止后执行", Blocking: true}, + } + if currentErr != nil { + items[1].Status = "failed" + items[1].Detail = currentErr.Error() + } + if m.state.LastBackup != nil { + items[2].Status = "passed" + items[2].Detail = m.state.LastBackup.ID + " · " + m.state.LastBackup.Checksum + } + return items +} + +func (m *Manager) setPhase(phase Phase, message string) { + m.mu.Lock() + defer m.mu.Unlock() + m.state.Operation.Phase = phase + m.appendLogLocked(phase, message) + _ = m.saveStateLocked() +} + +func (m *Manager) appendLogLocked(phase Phase, message string) { + m.state.Operation.Logs = append(m.state.Operation.Logs, LogEntry{At: time.Now().UTC(), Phase: phase, Message: message}) + if len(m.state.Operation.Logs) > 200 { + m.state.Operation.Logs = append([]LogEntry(nil), m.state.Operation.Logs[len(m.state.Operation.Logs)-200:]...) + } +} + +func (m *Manager) loadState() error { + data, err := os.ReadFile(m.statePath()) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("读取更新器状态:%w", err) + } + if err := json.Unmarshal(data, &m.state); err != nil { + return fmt.Errorf("解析更新器状态:%w", err) + } + if m.state.Operation.Phase == "" { + m.state.Operation.Phase = PhaseIdle + } + return nil +} + +func (m *Manager) saveStateLocked() error { + data, err := json.MarshalIndent(m.state, "", " ") + if err != nil { + return err + } + temporary, err := os.CreateTemp(m.config.StateDir, ".state-*.json") + if err != nil { + return err + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryName, m.statePath()) +} + +func (m *Manager) statePath() string { return filepath.Join(m.config.StateDir, "state.json") } +func (m *Manager) composePath() string { + return filepath.Join(m.config.InstallDir, m.config.ComposeFile) +} +func (m *Manager) envPath() string { return filepath.Join(m.config.InstallDir, m.config.EnvFile) } +func (m *Manager) previousComposePath() string { + return filepath.Join(m.config.StateDir, "previous-compose.yml") +} + +func randomID() string { + buffer := make([]byte, 12) + if _, err := rand.Read(buffer); err != nil { + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return hex.EncodeToString(buffer) +} + +func safeOperationError(err error) string { + if err == nil { + return "" + } + message := strings.TrimSpace(err.Error()) + if len(message) > 1200 { + message = message[:1200] + } + return message +} + +func cloneRelease(value *Release) *Release { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +func cloneBackup(value *Backup) *Backup { + if value == nil { + return nil + } + cloned := *value + cloned.Path = "" + return &cloned +} + +func cloneOperation(value Operation) Operation { + value.Logs = append([]LogEntry(nil), value.Logs...) + return value +} diff --git a/backend/internal/hostupdate/manager_ops.go b/backend/internal/hostupdate/manager_ops.go new file mode 100644 index 000000000..1f659f7cb --- /dev/null +++ b/backend/internal/hostupdate/manager_ops.go @@ -0,0 +1,612 @@ +package hostupdate + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "syscall" + "time" +) + +type githubRelease struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` +} + +func (m *Manager) latestRelease(ctx context.Context) (*Release, error) { + url := "https://api.github.com/repos/" + m.config.Repository + "/releases?per_page=30" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", "open-ai-canvas-host-updater") + request.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if m.config.GitHubToken != "" { + request.Header.Set("Authorization", "Bearer "+m.config.GitHubToken) + } + response, err := m.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("请求 GitHub Release:%w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) + return nil, fmt.Errorf("GitHub Release 返回 HTTP %d", response.StatusCode) + } + var releases []githubRelease + decoder := json.NewDecoder(io.LimitReader(response.Body, maxReleaseResponseBytes)) + if err := decoder.Decode(&releases); err != nil { + return nil, fmt.Errorf("解析 GitHub Release:%w", err) + } + filtered := make([]githubRelease, 0, len(releases)) + for _, release := range releases { + if !release.Draft && strings.HasPrefix(release.TagName, "v") { + filtered = append(filtered, release) + } + } + if len(filtered) == 0 { + return nil, errors.New("GitHub 尚未发布可用 Release") + } + sort.SliceStable(filtered, func(i, j int) bool { return CompareVersions(filtered[i].TagName, filtered[j].TagName) > 0 }) + latest := filtered[0] + return &Release{Version: latest.TagName, Name: latest.Name, Body: latest.Body, URL: latest.HTMLURL, PublishedAt: latest.PublishedAt, Prerelease: latest.Prerelease}, nil +} + +func (m *Manager) currentVersion() (string, error) { + values, err := readEnvFile(m.envPath()) + if err != nil { + return "", err + } + version := strings.TrimSpace(values["CANVAS_IMAGE_TAG"]) + if version == "" || version == "latest" { + return "", errors.New("CANVAS_IMAGE_TAG 必须固定为发布版本,不能使用 latest") + } + if !strings.HasPrefix(version, "v") { + version = "v" + version + } + return version, nil +} + +func (m *Manager) prepareTargetCompose(targetVersion string) (string, error) { + url := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s", m.config.Repository, targetVersion, m.config.ComposeFile) + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := m.httpClient.Do(request) + if err != nil { + return "", fmt.Errorf("下载目标 Compose:%w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("下载目标 Compose 返回 HTTP %d", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, 2<<20)) + if err != nil { + return "", err + } + if len(data) == 0 { + return "", errors.New("目标 Compose 文件为空") + } + path := filepath.Join(m.config.StateDir, "compose-"+strings.TrimPrefix(targetVersion, "v")+".next.yml") + if err := os.WriteFile(path, data, 0o600); err != nil { + return "", fmt.Errorf("保存目标 Compose:%w", err) + } + return path, nil +} + +func (m *Manager) preflight(composePath, targetVersion string) error { + if _, err := os.Stat(m.composePath()); err != nil { + return fmt.Errorf("读取当前 Compose:%w", err) + } + if _, err := os.Stat(m.envPath()); err != nil { + return fmt.Errorf("读取部署环境:%w", err) + } + var disk syscall.Statfs_t + if err := syscall.Statfs(m.config.BackupDir, &disk); err != nil { + return fmt.Errorf("检查备份磁盘空间:%w", err) + } + available := int64(disk.Bavail) * int64(disk.Bsize) + if available < 2<<30 { + return fmt.Errorf("备份目录可用空间不足 2 GiB:当前 %d MiB", available>>20) + } + if err := m.compose(composePath, targetVersion, 2*time.Minute, nil, "config", "--quiet"); err != nil { + return fmt.Errorf("目标 Compose 校验失败:%w", err) + } + current, err := m.currentVersion() + if err != nil { + return err + } + if err := m.checkHealthOnce(m.healthURL(), current); err != nil { + return fmt.Errorf("当前运行版本与部署配置不一致或服务未就绪:%w", err) + } + return nil +} + +func (m *Manager) prepareUpdaterBinary(targetVersion string) (string, error) { + if !m.config.SelfUpdate { + return "", nil + } + if runtime.GOOS != "linux" || (runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64") { + return "", fmt.Errorf("Host Updater 自更新不支持 %s/%s", runtime.GOOS, runtime.GOARCH) + } + if strings.TrimSpace(m.config.BinaryPath) == "" { + return "", errors.New("未配置 Host Updater 二进制路径") + } + baseURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/", m.config.Repository, targetVersion) + asset := "open-ai-canvas-host-updater-linux-" + runtime.GOARCH + checksums, err := m.downloadReleaseAsset(baseURL+"SHA256SUMS", 1<<20) + if err != nil { + return "", fmt.Errorf("下载 Host Updater 校验清单:%w", err) + } + expected := "" + for _, line := range strings.Split(string(checksums), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[1] == asset { + expected = fields[0] + break + } + } + if len(expected) != 64 { + return "", fmt.Errorf("目标 Release 的 SHA256SUMS 缺少 %s", asset) + } + binary, err := m.downloadReleaseAsset(baseURL+asset, 128<<20) + if err != nil { + return "", fmt.Errorf("下载目标 Host Updater:%w", err) + } + hash := sha256.Sum256(binary) + actual := hex.EncodeToString(hash[:]) + if actual != expected { + return "", fmt.Errorf("Host Updater 校验失败:期望 %s,实际 %s", expected, actual) + } + path := filepath.Join(m.config.StateDir, asset+"-"+strings.TrimPrefix(targetVersion, "v")+".next") + if err := os.WriteFile(path, binary, 0o700); err != nil { + return "", err + } + return path, nil +} + +func (m *Manager) downloadReleaseAsset(url string, limit int64) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "open-ai-canvas-host-updater") + response, err := m.httpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", response.StatusCode) + } + reader := io.LimitReader(response.Body, limit+1) + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("Release 资产超过允许大小") + } + return data, nil +} + +func (m *Manager) restartSelf() { + time.Sleep(time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + _ = m.runner.Run(ctx, "systemctl", []string{"restart", m.config.ServiceName}, nil, io.Discard, io.Discard) +} + +func (m *Manager) compose(composePath, imageTag string, timeout time.Duration, stdout io.Writer, arguments ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + args := []string{"compose", "--env-file", m.envPath(), "-f", composePath} + args = append(args, arguments...) + var stderr bytes.Buffer + if stdout == nil { + stdout = io.Discard + } + err := m.runner.Run(ctx, "docker", args, []string{"CANVAS_IMAGE_TAG=" + strings.TrimPrefix(imageTag, "v")}, stdout, &stderr) + if err != nil { + message := strings.TrimSpace(stderr.String()) + if len(message) > 1000 { + message = message[len(message)-1000:] + } + if message != "" { + return fmt.Errorf("docker compose %s:%s", strings.Join(arguments, " "), message) + } + return fmt.Errorf("docker compose %s:%w", strings.Join(arguments, " "), err) + } + return nil +} + +func (m *Manager) verifyImages(targetVersion string) error { + owner := strings.SplitN(m.config.Repository, "/", 2)[0] + for _, image := range []string{"ghcr.io/" + owner + "/open-ai-canvas-backend:", "ghcr.io/" + owner + "/open-ai-canvas-web:"} { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + var output bytes.Buffer + var stderr bytes.Buffer + err := m.runner.Run(ctx, "docker", []string{"image", "inspect", image + strings.TrimPrefix(targetVersion, "v"), "--format", "{{json .RepoDigests}}"}, nil, &output, &stderr) + cancel() + if err != nil { + return fmt.Errorf("校验目标镜像摘要失败:%s", strings.TrimSpace(stderr.String())) + } + if !strings.Contains(output.String(), "@sha256:") { + return fmt.Errorf("目标镜像 %s 未包含仓库摘要", image+targetVersion) + } + } + return nil +} + +func (m *Manager) createBackup(version string) (Backup, error) { + now := time.Now().UTC() + id := "backup-" + now.Format("20060102-150405") + path := filepath.Join(m.config.BackupDir, id+".zip") + temporary := path + ".partial" + file, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + return Backup{}, fmt.Errorf("创建备份文件:%w", err) + } + removeTemporary := true + defer func() { + _ = file.Close() + if removeTemporary { + _ = os.Remove(temporary) + } + }() + hasher := sha256.New() + archive := zip.NewWriter(io.MultiWriter(file, hasher)) + metadata, _ := json.MarshalIndent(map[string]any{"id": id, "version": version, "createdAt": now, "format": 1}, "", " ") + if err := writeZipBytes(archive, "metadata.json", metadata); err != nil { + return Backup{}, err + } + databaseEntry, err := archive.CreateHeader(&zip.FileHeader{Name: "database.dump", Method: zip.Store}) + if err != nil { + return Backup{}, err + } + values, err := readEnvFile(m.envPath()) + if err != nil { + return Backup{}, err + } + postgresUser := firstNonEmpty(values["POSTGRES_USER"], "open_ai_canvas") + postgresDB := firstNonEmpty(values["POSTGRES_DB"], "open_ai_canvas") + if err := m.compose(m.composePath(), version, m.config.StepTimeout, databaseEntry, "exec", "-T", "postgres", "pg_dump", "-U", postgresUser, "-d", postgresDB, "-Fc"); err != nil { + return Backup{}, fmt.Errorf("备份 PostgreSQL:%w", err) + } + dataEntry, err := archive.CreateHeader(&zip.FileHeader{Name: "backend-data.tar", Method: zip.Store}) + if err != nil { + return Backup{}, err + } + if err := m.compose(m.composePath(), version, m.config.StepTimeout, dataEntry, "exec", "-T", "backend", "tar", "-C", "/data", "-cf", "-", "."); err != nil { + return Backup{}, fmt.Errorf("备份数据目录:%w", err) + } + if err := archive.Close(); err != nil { + return Backup{}, fmt.Errorf("完成 ZIP 备份:%w", err) + } + if err := file.Sync(); err != nil { + return Backup{}, fmt.Errorf("同步 ZIP 备份:%w", err) + } + if err := file.Close(); err != nil { + return Backup{}, err + } + if err := os.Rename(temporary, path); err != nil { + return Backup{}, fmt.Errorf("提交 ZIP 备份:%w", err) + } + removeTemporary = false + stat, err := os.Stat(path) + if err != nil { + return Backup{}, err + } + checksum := "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + if err := verifyZipBackup(path, checksum); err != nil { + return Backup{}, err + } + return Backup{ID: id, Path: path, Checksum: checksum, Size: stat.Size(), CreatedAt: now, Version: version}, nil +} + +func writeZipBytes(writer *zip.Writer, name string, data []byte) error { + entry, err := writer.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate}) + if err != nil { + return err + } + _, err = entry.Write(data) + return err +} + +func verifyZipBackup(path, expected string) error { + file, err := os.Open(path) + if err != nil { + return err + } + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + file.Close() + return err + } + if err := file.Close(); err != nil { + return err + } + actual := "sha256:" + hex.EncodeToString(hasher.Sum(nil)) + if actual != expected { + return fmt.Errorf("ZIP 备份校验失败:期望 %s,实际 %s", expected, actual) + } + archive, err := zip.OpenReader(path) + if err != nil { + return fmt.Errorf("ZIP 备份无法打开:%w", err) + } + defer archive.Close() + required := map[string]bool{"metadata.json": false, "database.dump": false, "backend-data.tar": false} + for _, entry := range archive.File { + if _, ok := required[entry.Name]; ok && entry.UncompressedSize64 > 0 { + required[entry.Name] = true + } + } + for name, present := range required { + if !present { + return fmt.Errorf("ZIP 备份缺少有效文件 %s", name) + } + } + return nil +} + +func (m *Manager) restoreDatabase(backup Backup) error { + if err := verifyZipBackup(backup.Path, backup.Checksum); err != nil { + return fmt.Errorf("拒绝恢复未通过校验的备份:%w", err) + } + archive, err := zip.OpenReader(backup.Path) + if err != nil { + return err + } + defer archive.Close() + var dump *zip.File + for _, entry := range archive.File { + if entry.Name == "database.dump" { + dump = entry + break + } + } + if dump == nil { + return errors.New("备份中不存在 database.dump") + } + reader, err := dump.Open() + if err != nil { + return err + } + defer reader.Close() + values, err := readEnvFile(m.envPath()) + if err != nil { + return err + } + postgresUser := firstNonEmpty(values["POSTGRES_USER"], "open_ai_canvas") + postgresDB := firstNonEmpty(values["POSTGRES_DB"], "open_ai_canvas") + if postgresDB == "postgres" || strings.HasPrefix(postgresDB, "template") { + return fmt.Errorf("拒绝覆盖 PostgreSQL 系统数据库 %q", postgresDB) + } + if err := m.compose(m.composePath(), backup.Version, 5*time.Minute, nil, "exec", "-T", "postgres", "dropdb", "-U", postgresUser, "--if-exists", "--force", postgresDB); err != nil { + return fmt.Errorf("删除待恢复数据库:%w", err) + } + if err := m.compose(m.composePath(), backup.Version, 5*time.Minute, nil, "exec", "-T", "postgres", "createdb", "-U", postgresUser, "-O", postgresUser, postgresDB); err != nil { + return fmt.Errorf("重建待恢复数据库:%w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), m.config.StepTimeout) + defer cancel() + args := []string{"compose", "--env-file", m.envPath(), "-f", m.composePath(), "exec", "-T", "postgres", "pg_restore", "-U", postgresUser, "-d", postgresDB, "--no-owner", "--no-privileges"} + var stderr bytes.Buffer + command := execCommandWithInput{runner: m.runner, input: reader} + if err := command.Run(ctx, "docker", args, []string{"CANVAS_IMAGE_TAG=" + strings.TrimPrefix(backup.Version, "v")}, io.Discard, &stderr); err != nil { + return fmt.Errorf("恢复 PostgreSQL:%s", strings.TrimSpace(stderr.String())) + } + return nil +} + +type execCommandWithInput struct { + runner commandRunner + input io.Reader +} + +func (c execCommandWithInput) Run(ctx context.Context, name string, args, environment []string, stdout, stderr io.Writer) error { + runner, ok := c.runner.(execRunner) + if !ok { + return errors.New("当前命令执行器不支持标准输入") + } + command := exec.CommandContext(ctx, name, args...) + command.Dir = runner.dir + command.Env = append(os.Environ(), environment...) + command.Stdin = c.input + command.Stdout = stdout + command.Stderr = stderr + return command.Run() +} + +func (m *Manager) verifyHealthy(targetVersion string) error { + healthURL := m.healthURL() + deadline := time.Now().Add(10 * time.Minute) + stableSince := time.Time{} + for time.Now().Before(deadline) { + if err := m.checkHealthOnce(healthURL, targetVersion); err != nil { + stableSince = time.Time{} + } else if stableSince.IsZero() { + stableSince = time.Now() + } else if time.Since(stableSince) >= m.config.StableWindow { + return nil + } + time.Sleep(2 * time.Second) + } + return fmt.Errorf("目标版本 %s 未在健康检查窗口内稳定就绪", targetVersion) +} + +func (m *Manager) healthURL() string { + if configured := strings.TrimSpace(m.config.HealthURL); configured != "" { + return configured + } + values, err := readEnvFile(m.envPath()) + if err != nil { + return "http://127.0.0.1:3000/api/health/ready" + } + return "http://127.0.0.1:" + firstNonEmpty(values["CANVAS_HTTP_PORT"], "3000") + "/api/health/ready" +} + +func (m *Manager) checkHealthOnce(healthURL, targetVersion string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) + response, err := m.httpClient.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("健康接口返回 HTTP %d", response.StatusCode) + } + var payload struct { + Code int `json:"code"` + Data struct { + Build struct { + Version string `json:"version"` + } `json:"build"` + } `json:"data"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 256<<10)).Decode(&payload); err != nil { + return err + } + if payload.Code != 0 { + return errors.New("健康接口业务状态异常") + } + if payload.Data.Build.Version != "" && CompareVersions(payload.Data.Build.Version, targetVersion) != 0 { + return fmt.Errorf("运行版本仍为 %s,期望 %s", payload.Data.Build.Version, targetVersion) + } + return nil +} + +func readEnvFile(path string) (map[string]string, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("读取 %s:%w", path, err) + } + values := make(map[string]string) + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if ok { + values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), "\"") + } + } + return values, nil +} + +func setEnvValue(path, key, value string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + found := false + for index, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, key+"=") { + lines[index] = key + "=" + value + found = true + } + } + if !found { + lines = append(lines, key+"="+value) + } + stat, err := os.Stat(path) + if err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".env-*.tmp") + if err != nil { + return err + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(stat.Mode().Perm()); err != nil { + temporary.Close() + return err + } + if _, err := temporary.WriteString(strings.Join(lines, "\n") + "\n"); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryName, path) +} + +func replaceFile(source, target string, mode os.FileMode) error { + data, err := os.ReadFile(source) + if err != nil { + return err + } + if mode == 0 { + if stat, statErr := os.Stat(target); statErr == nil { + mode = stat.Mode().Perm() + } else { + mode = 0o600 + } + } + temporary, err := os.CreateTemp(filepath.Dir(target), ".compose-*.tmp") + if err != nil { + return err + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(mode); err != nil { + temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryName, target) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/backend/internal/hostupdate/manager_ops_test.go b/backend/internal/hostupdate/manager_ops_test.go new file mode 100644 index 000000000..4794482b0 --- /dev/null +++ b/backend/internal/hostupdate/manager_ops_test.go @@ -0,0 +1,92 @@ +package hostupdate + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSetEnvValuePreservesOtherSettings(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, ".env") + if err := os.WriteFile(path, []byte("# keep\nCANVAS_IMAGE_TAG=1.0.0\nPOSTGRES_DB=canvas\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := setEnvValue(path, "CANVAS_IMAGE_TAG", "1.2.2-preview.1"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + value := string(data) + if !strings.Contains(value, "# keep\n") || !strings.Contains(value, "POSTGRES_DB=canvas\n") || !strings.Contains(value, "CANVAS_IMAGE_TAG=1.2.2-preview.1\n") { + t.Fatalf("unexpected env contents: %q", value) + } + stat, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if stat.Mode().Perm() != 0o640 { + t.Fatalf("mode=%o, want 640", stat.Mode().Perm()) + } +} + +func TestVerifyZipBackupRejectsCorruption(t *testing.T) { + path := filepath.Join(t.TempDir(), "backup.zip") + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + archive := zip.NewWriter(file) + for name, content := range map[string]string{ + "metadata.json": "{}", + "database.dump": "database", + "backend-data.tar": "data", + } { + entry, createErr := archive.Create(name) + if createErr != nil { + t.Fatal(createErr) + } + if _, writeErr := io.WriteString(entry, content); writeErr != nil { + t.Fatal(writeErr) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + hash := sha256.Sum256(data) + checksum := "sha256:" + hex.EncodeToString(hash[:]) + if err := verifyZipBackup(path, checksum); err != nil { + t.Fatalf("valid backup rejected: %v", err) + } + if err := os.WriteFile(path, append(data, byte(1)), 0o600); err != nil { + t.Fatal(err) + } + if err := verifyZipBackup(path, checksum); err == nil { + t.Fatal("corrupted backup was accepted") + } +} + +func TestCurrentVersionRejectsLatest(t *testing.T) { + installDir := t.TempDir() + if err := os.WriteFile(filepath.Join(installDir, ".env"), []byte("CANVAS_IMAGE_TAG=latest\n"), 0o600); err != nil { + t.Fatal(err) + } + manager := &Manager{config: Config{InstallDir: installDir, EnvFile: ".env"}} + if _, err := manager.currentVersion(); err == nil { + t.Fatal("latest tag was accepted") + } +} diff --git a/backend/internal/hostupdate/server.go b/backend/internal/hostupdate/server.go new file mode 100644 index 000000000..765e51d1b --- /dev/null +++ b/backend/internal/hostupdate/server.go @@ -0,0 +1,101 @@ +package hostupdate + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +type Server struct { + manager *Manager + token string +} + +func NewServer(manager *Manager, token string) (*Server, error) { + if manager == nil { + return nil, errors.New("更新管理器不能为空") + } + if len(strings.TrimSpace(token)) < 32 { + return nil, errors.New("CANVAS_UPDATER_TOKEN 至少需要 32 个字符") + } + return &Server{manager: manager, token: strings.TrimSpace(token)}, nil +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /v1/status", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, s.manager.Snapshot()) }) + mux.HandleFunc("POST /v1/check", func(w http.ResponseWriter, r *http.Request) { + status, err := s.manager.Check(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err, status) + return + } + writeJSON(w, http.StatusOK, status) + }) + mux.HandleFunc("POST /v1/update", func(w http.ResponseWriter, r *http.Request) { + var request StartRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err, nil) + return + } + status, err := s.manager.StartUpdate(request.TargetVersion) + if err != nil { + writeError(w, http.StatusConflict, err, status) + return + } + writeJSON(w, http.StatusAccepted, status) + }) + mux.HandleFunc("POST /v1/rollback", func(w http.ResponseWriter, r *http.Request) { + var request RollbackRequest + if err := decodeJSON(r, &request); err != nil { + writeError(w, http.StatusBadRequest, err, nil) + return + } + status, err := s.manager.StartRollback(request.Reason) + if err != nil { + writeError(w, http.StatusConflict, err, status) + return + } + writeJSON(w, http.StatusAccepted, status) + }) + return s.authorize(mux) +} + +func (s *Server) authorize(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if len(provided) != len(s.token) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.token)) != 1 { + writeError(w, http.StatusUnauthorized, errors.New("更新器认证失败"), nil) + return + } + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + +func decodeJSON(r *http.Request, target any) error { + defer r.Body.Close() + decoder := json.NewDecoder(io.LimitReader(r.Body, 64<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + return nil +} + +func writeError(w http.ResponseWriter, status int, err error, data any) { + writeJSONStatus(w, status, map[string]any{"error": err.Error(), "data": data}) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + writeJSONStatus(w, status, value) +} + +func writeJSONStatus(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/backend/internal/hostupdate/types.go b/backend/internal/hostupdate/types.go new file mode 100644 index 000000000..c70d0a1a8 --- /dev/null +++ b/backend/internal/hostupdate/types.go @@ -0,0 +1,100 @@ +package hostupdate + +import "time" + +type Phase string + +const ( + PhaseIdle Phase = "idle" + PhaseChecking Phase = "checking" + PhaseReady Phase = "ready" + PhaseNoUpdate Phase = "no_update" + PhasePreflight Phase = "preflight" + PhaseBackingUp Phase = "backing_up" + PhasePulling Phase = "pulling" + PhaseDraining Phase = "draining" + PhaseMigrating Phase = "migrating" + PhaseSwitching Phase = "switching" + PhaseVerifying Phase = "verifying" + PhaseSucceeded Phase = "succeeded" + PhaseRollingBack Phase = "rolling_back" + PhaseRolledBack Phase = "rolled_back" + PhaseFailed Phase = "failed" + PhaseManualIntervention Phase = "manual_intervention" +) + +func (p Phase) Active() bool { + switch p { + case PhasePreflight, PhaseBackingUp, PhasePulling, PhaseDraining, PhaseMigrating, PhaseSwitching, PhaseVerifying, PhaseRollingBack: + return true + default: + return false + } +} + +type Release struct { + Version string `json:"version"` + Name string `json:"name"` + Body string `json:"body"` + URL string `json:"url"` + PublishedAt time.Time `json:"publishedAt"` + Prerelease bool `json:"prerelease"` +} + +type Check struct { + Key string `json:"key"` + Label string `json:"label"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + Blocking bool `json:"blocking"` +} + +type Backup struct { + ID string `json:"id"` + Path string `json:"path"` + Checksum string `json:"checksum"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` + Version string `json:"version"` +} + +type LogEntry struct { + At time.Time `json:"at"` + Phase Phase `json:"phase"` + Message string `json:"message"` +} + +type Operation struct { + ID string `json:"id,omitempty"` + Phase Phase `json:"phase"` + FromVersion string `json:"fromVersion,omitempty"` + TargetVersion string `json:"targetVersion,omitempty"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + Error string `json:"error,omitempty"` + RollbackError string `json:"rollbackError,omitempty"` + AutomaticRollback bool `json:"automaticRollback"` + Logs []LogEntry `json:"logs"` +} + +type Status struct { + Supported bool `json:"supported"` + Connected bool `json:"connected"` + Repository string `json:"repository"` + Deployment string `json:"deployment"` + CurrentVersion string `json:"currentVersion"` + LatestRelease *Release `json:"latestRelease,omitempty"` + UpdateAvailable bool `json:"updateAvailable"` + Checks []Check `json:"checks"` + LastBackup *Backup `json:"lastBackup,omitempty"` + RollbackVersion string `json:"rollbackVersion,omitempty"` + Operation Operation `json:"operation"` +} + +type StartRequest struct { + TargetVersion string `json:"targetVersion"` +} + +type RollbackRequest struct { + Reason string `json:"reason"` +} diff --git a/backend/internal/hostupdate/version.go b/backend/internal/hostupdate/version.go new file mode 100644 index 000000000..94f369b61 --- /dev/null +++ b/backend/internal/hostupdate/version.go @@ -0,0 +1,79 @@ +package hostupdate + +import ( + "strconv" + "strings" +) + +type versionPart struct { + major, minor, patch int + pre []string +} + +func CompareVersions(left, right string) int { + a, aok := parseVersion(left) + b, bok := parseVersion(right) + if !aok || !bok { + return strings.Compare(strings.TrimSpace(left), strings.TrimSpace(right)) + } + for _, pair := range [][2]int{{a.major, b.major}, {a.minor, b.minor}, {a.patch, b.patch}} { + if pair[0] < pair[1] { + return -1 + } + if pair[0] > pair[1] { + return 1 + } + } + if len(a.pre) == 0 && len(b.pre) > 0 { + return 1 + } + if len(a.pre) > 0 && len(b.pre) == 0 { + return -1 + } + for index := 0; index < len(a.pre) || index < len(b.pre); index++ { + if index >= len(a.pre) { + return -1 + } + if index >= len(b.pre) { + return 1 + } + an, aerr := strconv.Atoi(a.pre[index]) + bn, berr := strconv.Atoi(b.pre[index]) + switch { + case aerr == nil && berr == nil && an < bn: + return -1 + case aerr == nil && berr == nil && an > bn: + return 1 + case aerr == nil && berr != nil: + return -1 + case aerr != nil && berr == nil: + return 1 + case a.pre[index] < b.pre[index]: + return -1 + case a.pre[index] > b.pre[index]: + return 1 + } + } + return 0 +} + +func parseVersion(raw string) (versionPart, bool) { + value := strings.TrimPrefix(strings.TrimSpace(raw), "v") + value = strings.SplitN(value, "+", 2)[0] + parts := strings.SplitN(value, "-", 2) + core := strings.Split(parts[0], ".") + if len(core) != 3 { + return versionPart{}, false + } + major, err1 := strconv.Atoi(core[0]) + minor, err2 := strconv.Atoi(core[1]) + patch, err3 := strconv.Atoi(core[2]) + if err1 != nil || err2 != nil || err3 != nil { + return versionPart{}, false + } + parsed := versionPart{major: major, minor: minor, patch: patch} + if len(parts) == 2 && parts[1] != "" { + parsed.pre = strings.FieldsFunc(parts[1], func(r rune) bool { return r == '.' || r == '-' }) + } + return parsed, true +} diff --git a/backend/internal/hostupdate/version_test.go b/backend/internal/hostupdate/version_test.go new file mode 100644 index 000000000..a09eba27a --- /dev/null +++ b/backend/internal/hostupdate/version_test.go @@ -0,0 +1,27 @@ +package hostupdate + +import "testing" + +func TestCompareVersions(t *testing.T) { + tests := []struct { + left, right string + want int + }{ + {"v1.2.0-preview.2", "v1.2.0-preview.3", -1}, + {"v1.2.0-preview.10", "v1.2.0-preview.3", 1}, + {"v1.2.0-preview.3", "v1.2.0", -1}, + {"v1.2.1", "v1.2.0", 1}, + {"v1.2.0", "v1.2.0", 0}, + } + for _, test := range tests { + got := CompareVersions(test.left, test.right) + if got < 0 { + got = -1 + } else if got > 0 { + got = 1 + } + if got != test.want { + t.Fatalf("CompareVersions(%q, %q)=%d, want %d", test.left, test.right, got, test.want) + } + } +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 4999224db..fcb0c8d1a 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -56,6 +56,12 @@ func (r *Repository) Dialect() string { return r.db.Dialector.Name() } +func (r *Repository) ReleaseTaskLease(id string, owner string) error { + return r.db.Model(&model.Task{}). + Where("id = ? AND status = ? AND lease_owner = ?", id, model.TaskStatusRunning, owner). + Updates(map[string]any{"lease_owner": "", "lease_expires_at": nil, "updated_at": time.Now()}).Error +} + // NextPrefixedID 在数据库事务中递增序列,避免 UUID/父子字符串拼接导致的不可读和不可排序 ID。 // prefix 只决定展示前缀,关联关系仍由独立外键维护。 func (r *Repository) NextPrefixedID(prefix string) (string, error) { diff --git a/backend/internal/service/admin_update.go b/backend/internal/service/admin_update.go new file mode 100644 index 000000000..6d0f1866e --- /dev/null +++ b/backend/internal/service/admin_update.go @@ -0,0 +1,94 @@ +package service + +import ( + "context" + "net/http" + "strings" + + "infinite-canvas/backend/internal/hostupdate" + "infinite-canvas/backend/internal/model" +) + +type UpdateManager interface { + Status(context.Context) (hostupdate.Status, error) + Check(context.Context) (hostupdate.Status, error) + Start(context.Context, string) (hostupdate.Status, error) + Rollback(context.Context, string) (hostupdate.Status, error) +} + +func (s *Service) ConfigureUpdateManager(manager UpdateManager) { + s.updateManager = manager +} + +func (s *Service) AdminUpdateStatus(ctx context.Context, actor *model.User) (hostupdate.Status, error) { + if err := s.RequireAdmin(actor); err != nil { + return hostupdate.Status{}, err + } + if s.updateManager == nil { + return unsupportedUpdateStatus("当前部署未安装 Host Updater"), nil + } + status, err := s.updateManager.Status(ctx) + if err != nil { + return unsupportedUpdateStatus("Host Updater 当前不可连接"), nil + } + return status, nil +} + +func (s *Service) AdminCheckUpdate(ctx context.Context, actor *model.User) (hostupdate.Status, error) { + if err := s.RequireAdmin(actor); err != nil { + return hostupdate.Status{}, err + } + if s.updateManager == nil { + return hostupdate.Status{}, NewAppError(http.StatusServiceUnavailable, "当前部署未安装 Host Updater") + } + status, err := s.updateManager.Check(ctx) + if err != nil { + return status, WrapAppError(http.StatusBadGateway, "检查更新失败,请查看更新器状态和日志", err) + } + return status, nil +} + +func (s *Service) AdminStartUpdate(ctx context.Context, actor *model.User, targetVersion string) (hostupdate.Status, error) { + if err := s.RequireAdmin(actor); err != nil { + return hostupdate.Status{}, err + } + if s.updateManager == nil { + return hostupdate.Status{}, NewAppError(http.StatusServiceUnavailable, "当前部署未安装 Host Updater") + } + targetVersion = strings.TrimSpace(targetVersion) + if targetVersion == "" { + return hostupdate.Status{}, NewAppError(http.StatusBadRequest, "目标版本不能为空") + } + status, err := s.updateManager.Start(ctx, targetVersion) + if err != nil { + return status, WrapAppError(http.StatusConflict, "无法开始更新,请刷新状态后重试", err) + } + return status, nil +} + +func (s *Service) AdminRollbackUpdate(ctx context.Context, actor *model.User, reason string) (hostupdate.Status, error) { + if err := s.RequireAdmin(actor); err != nil { + return hostupdate.Status{}, err + } + if s.updateManager == nil { + return hostupdate.Status{}, NewAppError(http.StatusServiceUnavailable, "当前部署未安装 Host Updater") + } + if strings.TrimSpace(reason) == "" { + return hostupdate.Status{}, NewAppError(http.StatusBadRequest, "请填写回退原因") + } + status, err := s.updateManager.Rollback(ctx, reason) + if err != nil { + return status, WrapAppError(http.StatusConflict, "无法开始回退,请检查备份和当前状态", err) + } + return status, nil +} + +func unsupportedUpdateStatus(detail string) hostupdate.Status { + return hostupdate.Status{ + Supported: false, + Connected: false, + Deployment: "unsupported", + Checks: []hostupdate.Check{{Key: "updater", Label: "Host Updater", Status: "failed", Detail: detail, Blocking: true}}, + Operation: hostupdate.Operation{Phase: hostupdate.PhaseIdle, Logs: []hostupdate.LogEntry{}}, + } +} diff --git a/backend/internal/service/billing_review.go b/backend/internal/service/billing_review.go index 8d7f3df51..5186b088b 100644 --- a/backend/internal/service/billing_review.go +++ b/backend/internal/service/billing_review.go @@ -1,6 +1,7 @@ package service import ( + "context" "log" "time" ) @@ -25,18 +26,23 @@ func (s *Service) AuditBillingReview() error { return nil } -func (s *Service) startBillingReviewAudit() { +func (s *Service) startBillingReviewAudit(ctx context.Context) { audit := func() { if err := s.AuditBillingReview(); err != nil { log.Printf("billing review audit failed: %v", err) } } - audit() - go func() { + s.runWorkerLoop(func(ctx context.Context) { + audit() ticker := time.NewTicker(time.Hour) defer ticker.Stop() - for range ticker.C { - audit() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + audit() + } } - }() + }) } diff --git a/backend/internal/service/coordination.go b/backend/internal/service/coordination.go index b390f2db4..ba5d1ee66 100644 --- a/backend/internal/service/coordination.go +++ b/backend/internal/service/coordination.go @@ -317,6 +317,13 @@ func (s *Service) ValidateRuntime() error { return s.runtimeErr } +func (s *Service) Close() error { + if s.coordinator == nil || s.coordinator.redis == nil { + return nil + } + return s.coordinator.redis.Close() +} + func (s *Service) AllowRequest(ctx context.Context, key string, limit int, window time.Duration) (bool, error) { if s.coordinator == nil { return false, errors.New("运行时协调器未初始化") diff --git a/backend/internal/service/provider_task_cancellation.go b/backend/internal/service/provider_task_cancellation.go index 0dff29d5e..c7cd7e4bd 100644 --- a/backend/internal/service/provider_task_cancellation.go +++ b/backend/internal/service/provider_task_cancellation.go @@ -31,22 +31,27 @@ const ( providerCancellationFailed providerCancellationOutcome = "failed" ) -func (s *Service) startProviderCancellationReconciliation() { - go func() { +func (s *Service) startProviderCancellationReconciliation(ctx context.Context) { + s.runWorkerLoop(func(ctx context.Context) { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() - for range ticker.C { - task, err := s.repo.ClaimNextTaskProviderCancellation("provider-cancel:"+s.workerID, providerCancellationLeaseDuration) - if err != nil || task == nil { - continue + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + task, err := s.repo.ClaimNextTaskProviderCancellation("provider-cancel:"+s.workerID, providerCancellationLeaseDuration) + if err != nil || task == nil { + continue + } + reconcileCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + if err := s.reconcileProviderCancellation(reconcileCtx, task); err != nil { + _ = s.log(task.UserID, task.ID, "error", "上游取消状态对账失败", err.Error()) + } + cancel() } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - if err := s.reconcileProviderCancellation(ctx, task); err != nil { - _ = s.log(task.UserID, task.ID, "error", "上游取消状态对账失败", err.Error()) - } - cancel() } - }() + }) } func (s *Service) requestProviderCancellation(ctx context.Context, task *model.Task) error { diff --git a/backend/internal/service/resource_deletion_worker.go b/backend/internal/service/resource_deletion_worker.go index f572f0a0b..1266b12b5 100644 --- a/backend/internal/service/resource_deletion_worker.go +++ b/backend/internal/service/resource_deletion_worker.go @@ -1,6 +1,7 @@ package service import ( + "context" "log" "math" "time" @@ -10,21 +11,26 @@ import ( const resourceDeletionLease = 2 * time.Minute -func (s *Service) startResourceDeletionWorker() { - go func() { +func (s *Service) startResourceDeletionWorker(ctx context.Context) { + s.runWorkerLoop(func(ctx context.Context) { s.drainResourceDeletionJobs(32) s.cleanupStaleAnnouncementImageDrafts() ticker := time.NewTicker(15 * time.Second) defer ticker.Stop() lastDraftCleanup := time.Now() - for range ticker.C { - s.drainResourceDeletionJobs(32) - if time.Since(lastDraftCleanup) >= time.Hour { - s.cleanupStaleAnnouncementImageDrafts() - lastDraftCleanup = time.Now() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.drainResourceDeletionJobs(32) + if time.Since(lastDraftCleanup) >= time.Hour { + s.cleanupStaleAnnouncementImageDrafts() + lastDraftCleanup = time.Now() + } } } - }() + }) } func (s *Service) drainResourceDeletionJobs(limit int) { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index adbeea47b..bb404db09 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -26,6 +26,7 @@ type Service struct { redeemBatchMu sync.Mutex storageMu sync.Mutex storageTestMu sync.Mutex + workerRuntimeMu sync.Mutex activeStorageTests map[string]bool characterTaskMu sync.Mutex activeCancels map[string]context.CancelFunc @@ -50,6 +51,8 @@ type Service struct { routeCatalogVersion int64 routeHealthMu sync.Mutex routeHealthBlocked map[string]time.Time + workers *workerRuntime + updateManager UpdateManager } const taskWorkerConcurrency = 3 @@ -124,9 +127,39 @@ func (s *Service) taskBilling() *taskBillingCoordinator { } func (s *Service) StartWorker() { - s.taskWorker().start() - s.startResourceDeletionWorker() - s.startSkillSyncWorker() + runtime := s.backgroundWorkers() + ctx, started := runtime.start() + if !started { + return + } + s.taskWorker().start(ctx) + s.startResourceDeletionWorker(ctx) + s.startSkillSyncWorker(ctx) +} + +func (s *Service) BeginDrain() { s.backgroundWorkers().beginDrain() } + +func (s *Service) StopWorker(ctx context.Context) error { return s.backgroundWorkers().stop(ctx) } + +func (s *Service) IsDraining() bool { return s.backgroundWorkers().isDraining() } + +func (s *Service) ActiveWorkerTasks() int64 { return s.backgroundWorkers().activeTaskCount() } + +func (s *Service) backgroundWorkers() *workerRuntime { + s.workerRuntimeMu.Lock() + defer s.workerRuntimeMu.Unlock() + if s.workers == nil { + s.workers = &workerRuntime{} + } + return s.workers +} + +func (s *Service) runWorkerLoop(fn func(context.Context)) bool { + return s.backgroundWorkers().goLoop(fn) +} + +func (s *Service) runWorkerTask(fn func()) bool { + return s.backgroundWorkers().goTask(fn) } func (s *Service) CreateSession(userID string, req CreateSessionRequest) (*SessionDetail, error) { diff --git a/backend/internal/service/skill_packages.go b/backend/internal/service/skill_packages.go index 5fb59d856..8d65a0847 100644 --- a/backend/internal/service/skill_packages.go +++ b/backend/internal/service/skill_packages.go @@ -208,23 +208,32 @@ func (s *Service) SyncGitHubSkill(userID string, skillID string) (*SkillItem, er return s.SkillDetail(userID, skill.ID) } -func (s *Service) startSkillSyncWorker() { - go func() { +func (s *Service) startSkillSyncWorker(ctx context.Context) { + s.runWorkerLoop(func(ctx context.Context) { ticker := time.NewTicker(6 * time.Hour) defer ticker.Stop() - for range ticker.C { - skills, err := s.repo.AutoUpdatingGitHubSkills() - if err != nil { - continue - } - for index := range skills { - _ = s.syncGitHubSkill(&skills[index]) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + skills, err := s.repo.AutoUpdatingGitHubSkills() + if err != nil { + continue + } + for index := range skills { + _ = s.syncGitHubSkillWithContext(ctx, &skills[index]) + } } } - }() + }) } func (s *Service) syncGitHubSkill(skill *model.Skill) error { + return s.syncGitHubSkillWithContext(context.Background(), skill) +} + +func (s *Service) syncGitHubSkillWithContext(ctx context.Context, skill *model.Skill) error { now := time.Now() spec, err := parseGitHubSkillURL(skill.SourceURL, skill.SourceRef, skill.SourceSubdir) if err != nil { @@ -234,7 +243,7 @@ func (s *Service) syncGitHubSkill(skill *model.Skill) error { _ = s.repo.SaveSkill(skill) return err } - archive, commit, canonicalURL, resolvedRef, err := fetchGitHubSkillArchive(context.Background(), spec) + archive, commit, canonicalURL, resolvedRef, err := fetchGitHubSkillArchive(ctx, spec) skill.LastCheckedAt = &now if err != nil { skill.SyncStatus = "failed" diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index 8a9e93ad1..42d86515a 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -13,6 +13,9 @@ import ( // CreateTask 收敛任务进入系统前的 admission 流程:输入标准化、逻辑模型路由、 // 能力/额度校验和持久化。执行阶段由 worker 与 provider 相关模块负责。 func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, error) { + if s.IsDraining() { + return nil, &AppError{Status: 503, Code: 503, Message: "服务正在维护,暂不接受新的生成任务", Retryable: true} + } prompt := strings.TrimSpace(req.Prompt) if prompt == "" { return nil, errors.New("prompt is required") diff --git a/backend/internal/service/task_lifecycle.go b/backend/internal/service/task_lifecycle.go index 4eec1e20c..273d1f498 100644 --- a/backend/internal/service/task_lifecycle.go +++ b/backend/internal/service/task_lifecycle.go @@ -31,6 +31,9 @@ func (s *Service) taskLifecycle() *taskLifecycleCoordinator { func (w *taskLifecycleCoordinator) retryTask(userID string, id string) (*model.Task, error) { s := w.service + if s.IsDraining() { + return nil, &AppError{Status: 503, Code: 503, Message: "服务正在维护,暂不接受任务重试", Retryable: true} + } task, err := s.repo.TaskForUser(userID, id) if err != nil { return nil, err @@ -156,13 +159,22 @@ func (w *taskLifecycleCoordinator) cancelTask(_ context.Context, userID string, // 上游取消可能需要轮询确认,不能阻塞取消接口;请求上下文也不能因 // 浏览器关闭而中断。后台对账会继续负责退款或费用核对。 cancelTask := *task - go func() { + started := s.runWorkerTask(func() { requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := s.requestProviderCancellation(requestCtx, &cancelTask); err != nil { _ = s.log(cancelTask.UserID, cancelTask.ID, "error", "发送上游取消请求失败", err.Error()) } - }() + }) + if !started { + // drain 后不能启动未登记 goroutine;当前 HTTP 请求同步完成首次取消, + // http.Server.Shutdown 会等待该请求,后续确认仍由持久化对账恢复。 + requestCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + if err := s.requestProviderCancellation(requestCtx, &cancelTask); err != nil { + _ = s.log(cancelTask.UserID, cancelTask.ID, "error", "发送上游取消请求失败", err.Error()) + } + cancel() + } } else { var billingErr error if originalStatus == model.TaskStatusQueued { diff --git a/backend/internal/service/task_worker.go b/backend/internal/service/task_worker.go index 001bda61f..f6f91889c 100644 --- a/backend/internal/service/task_worker.go +++ b/backend/internal/service/task_worker.go @@ -28,21 +28,24 @@ func (s *Service) taskWorker() *taskWorkerCoordinator { return newTaskWorkerCoordinator(s) } -func (w *taskWorkerCoordinator) start() { +func (w *taskWorkerCoordinator) start(ctx context.Context) { s := w.service - s.startTextReplayCleanup() - s.startProviderCancellationReconciliation() - s.startBillingReviewAudit() - go func() { + s.startTextReplayCleanup(ctx) + s.startProviderCancellationReconciliation(ctx) + s.startBillingReviewAudit(ctx) + s.runWorkerLoop(func(ctx context.Context) { slots := make(chan struct{}, maxChannelConcurrencyLimit) dispatch := func() { + if ctx.Err() != nil || s.IsDraining() { + return + } setting, err := s.runtimeConcurrencySetting() if err != nil { return } workerConcurrency := setting.WorkerConcurrency for len(slots) < workerConcurrency { - releaseGlobal, acquired, err := s.coordinator.acquire(context.Background(), "workers", workerConcurrency, 45*time.Minute) + releaseGlobal, acquired, err := s.coordinator.acquire(ctx, "workers", workerConcurrency, 45*time.Minute) if err != nil || !acquired { return } @@ -52,22 +55,33 @@ func (w *taskWorkerCoordinator) start() { return } slots <- struct{}{} - go func(task *model.Task) { + started := s.runWorkerTask(func() { defer func() { <-slots; releaseGlobal() }() if err := w.processClaimedTask(task); err != nil { _ = s.log(task.UserID, task.ID, "error", "后台任务处理失败", err.Error()) } - }(task) + }) + if !started { + <-slots + releaseGlobal() + _ = s.repo.ReleaseTaskLease(task.ID, s.workerID) + return + } } } dispatch() ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() - for range ticker.C { - dispatch() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + dispatch() + } } - }() + }) } func (w *taskWorkerCoordinator) processNextTask() error { diff --git a/backend/internal/service/text_replay.go b/backend/internal/service/text_replay.go index 135dfd9f4..45412b7f2 100644 --- a/backend/internal/service/text_replay.go +++ b/backend/internal/service/text_replay.go @@ -1,6 +1,7 @@ package service import ( + "context" "encoding/json" "errors" "log" @@ -156,18 +157,23 @@ func taskResultText(raw string) string { return result.Text } -func (s *Service) startTextReplayCleanup() { +func (s *Service) startTextReplayCleanup(ctx context.Context) { cleanup := func() { if _, err := s.CleanupTaskTextReplay(); err != nil { log.Printf("text replay cleanup failed: %v", err) } } - cleanup() - go func() { + s.runWorkerLoop(func(ctx context.Context) { + cleanup() ticker := time.NewTicker(time.Hour) defer ticker.Stop() - for range ticker.C { - cleanup() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + cleanup() + } } - }() + }) } diff --git a/backend/internal/service/worker_runtime.go b/backend/internal/service/worker_runtime.go new file mode 100644 index 000000000..ba92944d2 --- /dev/null +++ b/backend/internal/service/worker_runtime.go @@ -0,0 +1,95 @@ +package service + +import ( + "context" + "sync" + "sync/atomic" +) + +type workerRuntime struct { + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + started bool + draining atomic.Bool + activeTasks atomic.Int64 +} + +func (r *workerRuntime) start() (context.Context, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return r.ctx, false + } + r.ctx, r.cancel = context.WithCancel(context.Background()) + r.started = true + r.draining.Store(false) + return r.ctx, true +} + +func (r *workerRuntime) goLoop(fn func(context.Context)) bool { + r.mu.Lock() + if !r.started || r.draining.Load() { + r.mu.Unlock() + return false + } + ctx := r.ctx + r.wg.Add(1) + r.mu.Unlock() + go func() { + defer r.wg.Done() + fn(ctx) + }() + return true +} + +func (r *workerRuntime) goTask(fn func()) bool { + r.mu.Lock() + if !r.started || r.draining.Load() { + r.mu.Unlock() + return false + } + r.wg.Add(1) + r.activeTasks.Add(1) + r.mu.Unlock() + go func() { + defer r.wg.Done() + defer r.activeTasks.Add(-1) + fn() + }() + return true +} + +func (r *workerRuntime) beginDrain() { + r.mu.Lock() + r.draining.Store(true) + if r.cancel != nil { + r.cancel() + } + r.mu.Unlock() +} + +func (r *workerRuntime) stop(ctx context.Context) error { + r.beginDrain() + done := make(chan struct{}) + go func() { + r.wg.Wait() + close(done) + }() + select { + case <-ctx.Done(): + return ctx.Err() + case <-done: + r.mu.Lock() + r.started = false + r.ctx = nil + r.cancel = nil + r.mu.Unlock() + return nil + } +} + +func (r *workerRuntime) isDraining() bool { return r.draining.Load() } + +func (r *workerRuntime) activeTaskCount() int64 { return r.activeTasks.Load() } diff --git a/backend/internal/service/worker_runtime_test.go b/backend/internal/service/worker_runtime_test.go new file mode 100644 index 000000000..096b48b0e --- /dev/null +++ b/backend/internal/service/worker_runtime_test.go @@ -0,0 +1,73 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestWorkerRuntimeStopsLoopsAndWaitsForTasks(t *testing.T) { + runtime := &workerRuntime{} + ctx, started := runtime.start() + if !started { + t.Fatal("worker runtime should start") + } + loopStopped := make(chan struct{}) + if !runtime.goLoop(func(context.Context) { + <-ctx.Done() + close(loopStopped) + }) { + t.Fatal("worker loop should start") + } + taskRelease := make(chan struct{}) + if !runtime.goTask(func() { <-taskRelease }) { + t.Fatal("worker task should start") + } + + stopCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := runtime.stop(stopCtx); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("stop should wait for the active task, got %v", err) + } + select { + case <-loopStopped: + case <-time.After(time.Second): + t.Fatal("worker loop did not observe drain cancellation") + } + if runtime.activeTaskCount() != 1 { + t.Fatalf("active task count = %d, want 1", runtime.activeTaskCount()) + } + + close(taskRelease) + finalCtx, finalCancel := context.WithTimeout(context.Background(), time.Second) + defer finalCancel() + if err := runtime.stop(finalCtx); err != nil { + t.Fatal(err) + } + if runtime.activeTaskCount() != 0 { + t.Fatalf("active task count = %d, want 0", runtime.activeTaskCount()) + } + if runtime.goTask(func() {}) { + t.Fatal("drained runtime should reject new tasks") + } +} + +func TestTaskWritesAreRejectedDuringDrain(t *testing.T) { + runtime := &workerRuntime{} + runtime.start() + runtime.beginDrain() + svc := &Service{workers: runtime} + + if _, err := svc.CreateTask("user-1", CreateTaskRequest{}); !isAppErrorStatus(err, 503) { + t.Fatalf("CreateTask during drain error = %v, want 503", err) + } + if _, err := svc.RetryTask("user-1", "task-1"); !isAppErrorStatus(err, 503) { + t.Fatalf("RetryTask during drain error = %v, want 503", err) + } +} + +func isAppErrorStatus(err error, status int) bool { + var appErr *AppError + return errors.As(err, &appErr) && appErr.Status == status +} diff --git a/backend/internal/updaterclient/client.go b/backend/internal/updaterclient/client.go new file mode 100644 index 000000000..e6ce17e82 --- /dev/null +++ b/backend/internal/updaterclient/client.go @@ -0,0 +1,89 @@ +package updaterclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "infinite-canvas/backend/internal/hostupdate" +) + +type Client struct { + baseURL string + token string + http *http.Client +} + +func New(socketPath, token string) *Client { + dialer := &net.Dialer{Timeout: 3 * time.Second} + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "unix", socketPath) + }, + DisableKeepAlives: true, + } + return &Client{baseURL: "http://unix", token: strings.TrimSpace(token), http: &http.Client{Transport: transport, Timeout: 15 * time.Second}} +} + +func (c *Client) Status(ctx context.Context) (hostupdate.Status, error) { + return c.request(ctx, http.MethodGet, "/v1/status", nil) +} + +func (c *Client) Check(ctx context.Context) (hostupdate.Status, error) { + return c.request(ctx, http.MethodPost, "/v1/check", struct{}{}) +} + +func (c *Client) Start(ctx context.Context, targetVersion string) (hostupdate.Status, error) { + return c.request(ctx, http.MethodPost, "/v1/update", hostupdate.StartRequest{TargetVersion: targetVersion}) +} + +func (c *Client) Rollback(ctx context.Context, reason string) (hostupdate.Status, error) { + return c.request(ctx, http.MethodPost, "/v1/rollback", hostupdate.RollbackRequest{Reason: reason}) +} + +func (c *Client) request(ctx context.Context, method, path string, payload any) (hostupdate.Status, error) { + var body io.Reader + if payload != nil { + encoded, err := json.Marshal(payload) + if err != nil { + return hostupdate.Status{}, err + } + body = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return hostupdate.Status{}, err + } + request.Header.Set("Authorization", "Bearer "+c.token) + request.Header.Set("Accept", "application/json") + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := c.http.Do(request) + if err != nil { + return hostupdate.Status{}, fmt.Errorf("连接 Host Updater:%w", err) + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, 2<<20) + if response.StatusCode < 200 || response.StatusCode >= 300 { + var failure struct { + Error string `json:"error"` + Data hostupdate.Status `json:"data"` + } + if err := json.NewDecoder(limited).Decode(&failure); err == nil && failure.Error != "" { + return failure.Data, fmt.Errorf("Host Updater:%s", failure.Error) + } + return hostupdate.Status{}, fmt.Errorf("Host Updater 返回 HTTP %d", response.StatusCode) + } + var status hostupdate.Status + if err := json.NewDecoder(limited).Decode(&status); err != nil { + return hostupdate.Status{}, fmt.Errorf("解析 Host Updater 响应:%w", err) + } + return status, nil +} diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 4f0a49e7d..98b5c1d3b 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -1,4 +1,12 @@ services: + migrate: + image: open-ai-canvas-backend:server + build: + context: . + dockerfile: backend/Dockerfile + args: + GOPROXY: ${GOPROXY:-https://proxy.golang.org,direct} + backend: image: open-ai-canvas-backend:server build: diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 0b05a800a..cfe4d3fe3 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -26,11 +26,24 @@ services: retries: 10 restart: unless-stopped + migrate: + image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest} + command: ["migrate-schema", "up"] + environment: + CANVAS_BACKEND_DATA_DIR: /data + CANVAS_DATABASE_DRIVER: postgres + DATABASE_URL: ${DATABASE_URL:?请先运行一键部署脚本生成数据库连接} + depends_on: + postgres: + condition: service_healthy + backend: image: ghcr.io/ddcat-ai/open-ai-canvas-backend:${CANVAS_IMAGE_TAG:-latest} environment: CANVAS_BACKEND_ADDR: ":8080" CANVAS_BACKEND_DATA_DIR: /data + CANVAS_AUTO_MIGRATE: "false" + CANVAS_SHUTDOWN_TIMEOUT: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} CANVAS_DATABASE_DRIVER: postgres DATABASE_URL: ${DATABASE_URL:?请先运行一键部署脚本生成数据库连接} REDIS_URL: redis://redis:6379/0 @@ -40,15 +53,21 @@ services: CANVAS_ALLOW_PRIVATE_UPSTREAMS: ${CANVAS_ALLOW_PRIVATE_UPSTREAMS:-false} CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS: ${CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS:-} CANVAS_CORS_ORIGINS: ${CANVAS_CORS_ORIGINS:-} + CANVAS_UPDATER_SOCKET: /run/open-ai-canvas-updater/updater.sock + CANVAS_UPDATER_TOKEN: ${CANVAS_UPDATER_TOKEN:-} GIN_MODE: release volumes: - ${CANVAS_DATA_PATH:-backend-data}:/data + - ${CANVAS_UPDATER_SOCKET_DIR:-/run/open-ai-canvas-updater}:/run/open-ai-canvas-updater depends_on: postgres: condition: service_healthy redis: condition: service_healthy + migrate: + condition: service_completed_successfully restart: unless-stopped + stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} web: image: ghcr.io/ddcat-ai/open-ai-canvas-web:${CANVAS_IMAGE_TAG:-latest} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 3017c6589..e77cadb8e 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -9,6 +9,7 @@ services: environment: CANVAS_BACKEND_ADDR: ":8080" CANVAS_BACKEND_DATA_DIR: /data + CANVAS_SHUTDOWN_TIMEOUT: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-sqlite} DATABASE_URL: ${DATABASE_URL:-} REDIS_URL: ${REDIS_URL:-} @@ -23,6 +24,7 @@ services: volumes: - backend-data:/data restart: unless-stopped + stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} develop: watch: - action: rebuild diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 5c276229c..6ec284cf5 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -28,6 +28,23 @@ services: retries: 20 restart: unless-stopped + migrate: + container_name: open-ai-canvas-migrate + image: open-ai-canvas-backend:server + build: + context: . + dockerfile: backend/Dockerfile + args: + GOPROXY: ${GOPROXY:-https://goproxy.cn,direct} + command: ["migrate-schema", "up"] + environment: + CANVAS_BACKEND_DATA_DIR: /data + CANVAS_DATABASE_DRIVER: postgres + DATABASE_URL: ${DATABASE_URL:?必须配置 PostgreSQL 数据库连接} + depends_on: + postgres: + condition: service_healthy + backend: container_name: open-ai-canvas-backend image: open-ai-canvas-backend:server @@ -39,8 +56,10 @@ services: environment: CANVAS_BACKEND_ADDR: ":8080" CANVAS_BACKEND_DATA_DIR: /data - CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-postgres} - DATABASE_URL: ${DATABASE_URL:-} + CANVAS_AUTO_MIGRATE: "false" + CANVAS_SHUTDOWN_TIMEOUT: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} + CANVAS_DATABASE_DRIVER: postgres + DATABASE_URL: ${DATABASE_URL:?必须配置 PostgreSQL 数据库连接} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} CANVAS_WORKER_CONCURRENCY: ${CANVAS_WORKER_CONCURRENCY:-3} CANVAS_CHANNEL_CONCURRENCY: ${CANVAS_CHANNEL_CONCURRENCY:-3} @@ -56,7 +75,10 @@ services: condition: service_healthy redis: condition: service_healthy + migrate: + condition: service_completed_successfully restart: unless-stopped + stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} web: container_name: open-ai-canvas-web diff --git a/docker-compose.yml b/docker-compose.yml index 4777a0837..7fd36c5f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: environment: CANVAS_BACKEND_ADDR: ":8080" CANVAS_BACKEND_DATA_DIR: /data + CANVAS_SHUTDOWN_TIMEOUT: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} CANVAS_DATABASE_DRIVER: ${CANVAS_DATABASE_DRIVER:-sqlite} DATABASE_URL: ${DATABASE_URL:-} REDIS_URL: ${REDIS_URL:-} @@ -22,6 +23,7 @@ services: volumes: - backend-data:/data restart: unless-stopped + stop_grace_period: ${CANVAS_SHUTDOWN_TIMEOUT:-10m} web: image: open-ai-canvas-web:local diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 93879ed98..e73f12ee3 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -5,7 +5,15 @@ description: 影策后端核心持久化表与状态边界 # 后端数据库 -后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。服务启动时由 `backend/internal/database/schema.go` 执行迁移;业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 +后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。结构变化由 `migrate-schema` 按版本顺序执行并写入 `schema_migrations`;受管生产部署在 Backend 启动前运行独立迁移服务,Backend 只校验当前 Schema 是否与二进制兼容。业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 + +## 结构迁移与版本 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `schema_migrations` | 保存已成功应用的结构版本、名称、校验和与时间 | 已应用版本的校验和发生变化时拒绝启动;数据库版本高于当前二进制支持版本时拒绝旧程序连接 | + +容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 ## 技能包与版本 diff --git a/docs/content/docs/backend/meta.json b/docs/content/docs/backend/meta.json new file mode 100644 index 000000000..ad9affd2d --- /dev/null +++ b/docs/content/docs/backend/meta.json @@ -0,0 +1,13 @@ +{ + "title": "开发文档", + "root": true, + "defaultOpen": true, + "pages": [ + "local-development", + "code-map", + "backend-database", + "system-update", + "protocol-plugins", + "canvas-data-structure" + ] +} diff --git a/docs/content/docs/backend/system-update.mdx b/docs/content/docs/backend/system-update.mdx new file mode 100644 index 000000000..889d03aa0 --- /dev/null +++ b/docs/content/docs/backend/system-update.mdx @@ -0,0 +1,60 @@ +--- +title: 在线更新与安全回退 +description: Host Updater、Release 检查、数据库备份迁移、健康验证与异常回退机制。 +--- + +# 在线更新与安全回退 + +管理后台的“系统配置 → 系统更新”只负责管理员鉴权、展示状态和发起操作。真正的 Docker、数据库备份与服务切换由宿主机上的 `open-ai-canvas-host-updater` 执行,业务后端不挂载 Docker Socket。 + +```text +管理员浏览器 → Backend 管理 API → 带 Token 的 Unix Socket → Host Updater → Docker Compose +``` + +## 安全更新顺序 + +1. 从 GitHub Releases 读取包含预览版在内的最高语义版本,并与 `.env` 中固定的 `CANVAS_IMAGE_TAG` 比较。 +2. 下载目标版本的 `docker-compose.deploy.yml`,执行 `docker compose config --quiet`,检查部署文件、磁盘空间和当前版本。 +3. 创建 `backup-YYYYMMDD-HHMMSS.zip.partial`,其中包含 PostgreSQL custom-format dump、后端数据目录 tar 和元数据;写入完成后 `fsync`、原子改名,并复核 ZIP 结构和 SHA-256。 +4. 拉取目标 backend/web 镜像并要求存在仓库 `sha256` 摘要。任何失败都在停服前中止。 +5. 停止 web/backend,等待 Backend 的 SIGTERM drain 完成;PostgreSQL 和 Redis 保持运行。 +6. 用目标 backend 镜像独立执行 `migrate-schema up`。整批迁移在事务和 PostgreSQL advisory lock 内完成,迁移名称与 checksum 必须匹配。 +7. 原子更新 `CANVAS_IMAGE_TAG` 和 Compose 文件,启动目标版本。 +8. 连续检查 `/api/health/ready` 的数据库、运行时、schema 和 build version,并经过稳定窗口后才标记成功。 + +更新状态写入 `/var/lib/open-ai-canvas-updater/state.json`。Backend 或浏览器在切换期间断线不会终止宿主机操作,恢复后可继续读取日志。 + +## 自动回退 + +停服后的迁移、启动或健康验证失败会触发自动回退: + +1. 停止目标 web/backend; +2. 恢复更新前保存的 Compose 文件; +3. 再次校验备份 ZIP 和 SHA-256; +4. 使用 `pg_restore --clean --if-exists` 恢复 PostgreSQL; +5. 恢复旧 `CANVAS_IMAGE_TAG`,启动旧服务并重新进行稳定窗口验证。 + +回退任一步失败时状态变为 `manual_intervention`,不伪造成功。管理后台仅在存在旧版本和已校验备份时开放人工回退按钮。 + +## 安装与升级约束 + +GHCR 镜像部署必须把 `CANVAS_IMAGE_TAG` 固定到具体 Release,不可使用 `latest`。安装更新器: + +```bash +cd /opt/open-ai-canvas +curl -fsSL https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/main/scripts/install-host-updater.sh | sudo bash +sudo docker compose --env-file .env -f docker-compose.deploy.yml up -d --force-recreate backend web --wait +``` + +脚本会下载与当前 Release 匹配的 Linux amd64/arm64 二进制和 `SHA256SUMS`,生成随机 `CANVAS_UPDATER_TOKEN`,创建 systemd 服务、Socket 目录、状态目录和 `backups/`。Token 只保存在服务器 `.env` 与 root-only 更新器环境文件中。 + +## 运维检查 + +```bash +sudo systemctl status open-ai-canvas-updater +sudo journalctl -u open-ai-canvas-updater -n 200 --no-pager +sudo ls -lh /opt/open-ai-canvas/backups +sudo sha256sum /opt/open-ai-canvas/backups/*.zip +``` + +备份文件包含业务数据,应限制为 root 访问并纳入异机备份策略。数据卷本身不是备份。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 81b110be2..8967943d3 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,15 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## 服务升级运行基础 + +- 完整架构、状态机、Release 清单、备份和回退边界见 `docs/design/dynamic-update.md`。 +- [x] 后端新增权威 BuildInfo、`schema_migrations`、独立 `migrate-schema` 命令、分层健康检查以及可停止 Worker;专项 Go 测试通过。 +- [x] 生产 Compose 在 Backend 前运行一次性迁移服务,并使用 `CANVAS_AUTO_MIGRATE=false` 阻止业务进程隐式修改 Schema;Backend 收到 SIGTERM 后进入 drain、停止领取任务并等待活动任务。 +- [ ] 使用 PostgreSQL 部署执行一次无迁移启动和一次跨版本迁移,确认 migrate 成功后 Backend 才启动,checksum 漂移和高版本 Schema 均会阻止旧 Backend 就绪。 +- [ ] 在存在图片、视频和文本长任务时发送 SIGTERM,确认 readiness 立即返回 503、新任务被拒绝、已领取任务在宽限期内完成,超时后租约可由重启实例恢复。 +- [ ] 构建带版本参数的前后端镜像,确认 `/api/system/version`、管理后台版本、OCI Commit 和 Release Tag 一致。 + ## 素材库网格密度 - [x] 素材库桌面端缩小卡片并改为每行 6 张;新增素材占首格,默认每页 35 项,可切换 70/105 项,首屏正好组成 6×6 完整网格;中窄屏按 4/3/1 列收敛,前端生产构建通过。 @@ -501,6 +510,19 @@ description: 影策已实现功能的手工验证清单与未确认边界 - [ ] 确认浅色/深色主题下标题、分类选中态、搜索焦点环、键帽和滚动区域清晰可见。 - [ ] 在窄窗口确认分类可横向滚动、快捷键列表改为单列,弹窗不超出视口且长内容仅在列表区滚动。 +## 管理后台在线更新 + +管理后台已新增“系统配置 → 系统更新”,并通过宿主机 Host Updater 执行强制备份、迁移、切换、健康验证和失败回退;以下场景需要在真实 Linux Docker Compose 服务器完成最终确认: + +- [x] 后端 Host Updater、Unix Socket 客户端、管理员 API、版本比较和状态持久化相关测试通过。 +- [x] 前端 TypeScript 检查、生产构建、侧栏入口、更新器未安装空态、更新器连接态、GitHub Release 日志、浅色/深色主题浏览器验收通过。 +- [x] Compose 配置校验、systemd 安装脚本语法和 Release 二进制构建命令通过。 +- [ ] 在生产更新前生成 PostgreSQL + 数据目录 ZIP,确认 `.partial` 不会被当作有效备份,最终 ZIP 可打开且 SHA-256、元数据、`database.dump`、`backend-data.tar` 均有效。 +- [ ] 从首个在线更新版本升级到包含 schema v2 的测试版本,确认旧服务先 drain,迁移服务独占执行,目标 build version 和 schema version 在稳定窗口内一致。 +- [ ] 人为让目标版本健康检查失败,确认自动恢复旧 Compose、旧镜像和数据库备份;旧版本因 schema 过高拒绝启动时,必须在数据库恢复完成后才能重新健康。 +- [ ] 在拉取镜像、创建备份、迁移、启动和健康检查阶段分别模拟失败,确认停服前失败不会改变运行版本,停服后失败进入回退,回退失败明确显示“需人工介入”。 +- [ ] 更新期间刷新页面、关闭浏览器和 Backend 短暂不可用,确认宿主机操作不中断,恢复后日志与最终状态完整。 + ## 管理后台存储资源 管理后台已新增资源列表、筛选、去重容量统计、预览下载和安全批量删除;以下场景仍需使用真实资源数据确认: diff --git a/docs/design/dynamic-update.md b/docs/design/dynamic-update.md new file mode 100644 index 000000000..4e92ccb1f --- /dev/null +++ b/docs/design/dynamic-update.md @@ -0,0 +1,127 @@ +# 服务动态更新设计 + +## 目标与边界 + +动态更新面向使用官方 Docker Compose 部署的自托管实例。管理员可在后台查看当前版本、GitHub Release 新版本和更新日志,并发起一次受控升级。升级必须满足:同一时刻只有一个更新任务、数据库和数据目录有可恢复备份、迁移失败不启动新服务、健康检查失败自动恢复旧镜像、业务 Backend 不直接持有 Docker Socket。 + +源码部署、Kubernetes、Portainer 或用户自行修改过编排文件的实例第一阶段只提供版本检查和手工升级指引,不自动接管宿主机。 + +## 组件边界 + +```text +管理后台 + -> Backend 更新 API(鉴权、状态展示、审计) + -> Host Updater(宿主机最小权限守护进程) + -> GitHub Release / OCI Registry + -> Docker Compose + -> PostgreSQL / 数据卷备份 +``` + +- Backend 负责管理员权限、Release 信息缓存、更新任务展示和审计,不执行 Docker 命令。 +- Host Updater 负责下载、验签、备份、迁移、切换、健康验证和回退;只接受本机 Unix Socket 或随机高强度 Token 鉴权请求。 +- `migrate-schema` 是唯一结构迁移入口。生产 Backend 使用 `CANVAS_AUTO_MIGRATE=false`,只校验 Schema 与当前二进制兼容。 +- 官方 Release 发布不可变镜像摘要和更新清单;Updater 不执行 Release 文本中的任意命令或脚本。 + +## Release 清单 + +每个正式 Release 附带机器可读的 `update-manifest.json`,至少包含: + +| 字段 | 用途 | +| --- | --- | +| `version`、`channel`、`publishedAt` | 版本比较和稳定版/测试版通道 | +| `minimumUpdaterVersion` | 阻止旧 Updater 执行不理解的新协议 | +| `images` | Web、Backend 镜像及不可变 digest | +| `schemaVersion`、`minimumRollbackSchemaVersion` | 升级前兼容性和可回退性判断 | +| `composeRevision` | 确认当前部署模板是否受支持 | +| `releaseNotesUrl` | 后台展示更新日志 | +| `signature` | 对规范化清单做签名,防止供应链篡改 | + +版本比较使用语义化版本,正式通道默认忽略 prerelease。GitHub API 使用 ETag 和超时缓存,网络失败只显示“检查失败”,不会影响当前服务。 + +## 升级状态机 + +```text +idle -> checking -> preflight -> draining -> backup -> pulling + -> migrating -> switching -> verifying -> succeeded + | | | + +---------- failed --------+ + | + rolling_back + | + rolled_back / manual_intervention +``` + +更新任务和每一步结果持久化到宿主机,不只保存在正在被替换的 Backend 中。Updater 使用文件锁保证单实例执行,并为每次更新生成 operation ID,重复点击只返回同一任务。 + +### 预检 + +1. 校验管理员请求、更新通道、Release 签名、镜像 digest 和磁盘空间。 +2. 确认当前 Compose revision、数据目录、数据库连接和备份目录可访问。 +3. 检查当前 Schema、目标 Schema 与回退兼容窗口;不满足时在停机前拒绝更新。 +4. 拉取目标镜像但不切换,确认目标 Backend 内含期望版本的 `migrate-schema`。 + +### 排空与备份 + +Updater 先让 Backend 进入 drain。Readiness 立即变为 503,新生成任务返回可重试错误;周期 Worker 停止领取任务,已执行任务在 `CANVAS_SHUTDOWN_TIMEOUT` 内完成。随后执行 PostgreSQL 一致性备份、SQLite 文件快照和数据目录增量快照,并记录备份校验和。备份失败不得继续迁移。 + +### 迁移与切换 + +目标镜像以一次性容器执行 `migrate-schema up`。迁移整批运行在事务中,PostgreSQL 使用事务级 advisory lock;迁移实现与 `schema_migrations` 记录的名称或 checksum 不一致时立即失败。迁移成功后才切换 Web/Backend 镜像,并保持旧镜像 digest 与旧 Compose 配置可用。 + +### 健康验证 + +切换后依次验证: + +1. `/api/health/startup` 成功; +2. `/api/health/ready` 连续多次成功; +3. `/api/system/version` 与目标 Release、Schema 版本一致; +4. 可选的登录态只读冒烟检查成功。 + +单次 200 不视为升级成功。只有在稳定窗口内连续通过后才标记成功并清理临时资源。 + +## 回退策略 + +回退分为两类: + +- **镜像回退**:目标版本未修改 Schema,或新 Schema 仍兼容旧 Backend,直接恢复旧镜像和 Compose 配置并再次验证健康状态。 +- **数据回退**:迁移包含破坏性或不可向后兼容变更,停止所有业务容器后恢复数据库和数据目录快照,再启动旧镜像。该过程会丢弃升级开始后的新写入,因此升级期间必须保持写流量关闭。 + +迁移设计默认采用 expand/contract:先新增字段/表并保持旧版本可读,至少跨一个发布周期后再清理旧结构。无法做到向后兼容的 Release 必须在清单中标记维护窗口和数据回退要求,后台需要二次确认。自动回退失败时保持服务停止并输出明确的备份位置、失败步骤和人工恢复命令,不反复重试破坏现场。 + +## 后台能力 + +第一阶段后台提供:当前版本、目标版本、发布时间、更新日志、通道、检查时间、升级前检查结果、更新进度、失败原因、回退结果和审计记录。只有超级管理员可发起更新;普通管理员只读。危险状态不提供“强制跳过迁移/备份/验签”入口。 + +建议 API: + +- `GET /api/admin/system/update-status` +- `POST /api/admin/system/check-update` +- `POST /api/admin/system/update` +- `GET /api/admin/system/update-operations/:id` +- `POST /api/admin/system/update-operations/:id/rollback` + +## 分阶段实施 + +### P0:可升级运行基础 + +- 权威 BuildInfo 与 `/api/system/version`; +- 分层健康检查和 Worker drain; +- 版本化、事务化的 `schema_migrations` 与独立迁移命令; +- 生产 Compose 的 migrate job、停止宽限期和 Release 版本门禁。 + +### P1:只读检查更新 + +- Backend 缓存 GitHub Release/manifest; +- 管理后台展示版本、更新日志、部署类型和自动更新可用性; +- 无 Host Updater 的部署只展示手工更新指引。 + +### P2:Host Updater 与手动升级 + +- 独立最小权限进程、操作锁、持久化状态、备份、镜像切换和健康回退; +- 后台实时展示进度,先在 PostgreSQL 官方 Compose 上灰度; +- SQLite 和自定义数据目录在备份/恢复验证完成后开放。 + +### P3:自动调度 + +- 支持维护窗口、稳定版通道、失败通知和延迟发布; +- 默认关闭无人值守升级;涉及破坏性迁移的版本始终要求人工确认。 diff --git a/scripts/install-host-updater.sh b/scripts/install-host-updater.sh new file mode 100755 index 000000000..04dec91d0 --- /dev/null +++ b/scripts/install-host-updater.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/opt/open-ai-canvas}" +REPOSITORY="${REPOSITORY:-ddcat-ai/open-ai-canvas}" +SOCKET_DIR="${CANVAS_UPDATER_SOCKET_DIR:-/run/open-ai-canvas-updater}" +UPDATER_BIN="/usr/local/bin/open-ai-canvas-host-updater" +UPDATER_ENV="/etc/open-ai-canvas-updater.env" +UPDATER_SERVICE="/etc/systemd/system/open-ai-canvas-updater.service" + +fail() { + printf 'Host Updater 安装失败:%s\n' "$1" >&2 + exit 1 +} + +require_root() { + [[ "${EUID}" -eq 0 ]] || fail "请使用 sudo 运行" + [[ "$(uname -s)" == "Linux" ]] || fail "仅支持 Linux 服务器" + command -v systemctl >/dev/null 2>&1 || fail "服务器必须使用 systemd" + command -v curl >/dev/null 2>&1 || fail "缺少 curl" + command -v sha256sum >/dev/null 2>&1 || fail "缺少 sha256sum" + command -v openssl >/dev/null 2>&1 || fail "缺少 openssl" + [[ -f "${INSTALL_DIR}/.env" ]] || fail "未找到 ${INSTALL_DIR}/.env" + [[ -f "${INSTALL_DIR}/docker-compose.deploy.yml" ]] || fail "未找到部署 Compose" +} + +read_image_tag() { + local configured + configured="$(sed -n 's/^CANVAS_IMAGE_TAG=//p' "${INSTALL_DIR}/.env" | tail -n 1)" + [[ -n "$configured" && "$configured" != "latest" ]] || fail "请先把 CANVAS_IMAGE_TAG 固定为已发布版本" + if [[ "$configured" == v* ]]; then + RELEASE_TAG="$configured" + else + RELEASE_TAG="v${configured}" + fi +} + +install_binary() { + local arch asset temporary checksum_file expected + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) fail "不支持的 CPU 架构:$(uname -m)" ;; + esac + asset="open-ai-canvas-host-updater-linux-${arch}" + temporary="$(mktemp)" + checksum_file="$(mktemp)" + curl -fsSL "https://github.com/${REPOSITORY}/releases/download/${RELEASE_TAG}/${asset}" -o "$temporary" + curl -fsSL "https://github.com/${REPOSITORY}/releases/download/${RELEASE_TAG}/SHA256SUMS" -o "$checksum_file" + expected="$(awk -v asset="$asset" '$2 == asset { print $1 }' "$checksum_file")" + [[ "$expected" =~ ^[a-f0-9]{64}$ ]] || fail "Release 校验清单缺少 ${asset}" + printf '%s %s\n' "$expected" "$temporary" | sha256sum -c - >/dev/null || fail "Host Updater SHA-256 校验失败" + install -m 0755 "$temporary" "$UPDATER_BIN" + rm -f "$temporary" "$checksum_file" +} + +ensure_token() { + local token temporary + token="$(sed -n 's/^CANVAS_UPDATER_TOKEN=//p' "${INSTALL_DIR}/.env" | tail -n 1)" + if [[ -z "$token" ]]; then + token="$(openssl rand -hex 32)" + temporary="$(mktemp "${INSTALL_DIR}/.env.XXXXXX")" + awk -v token="$token" ' + BEGIN { updated=0 } + /^CANVAS_UPDATER_TOKEN=/ { print "CANVAS_UPDATER_TOKEN=" token; updated=1; next } + { print } + END { if (!updated) print "CANVAS_UPDATER_TOKEN=" token } + ' "${INSTALL_DIR}/.env" > "$temporary" + chmod --reference="${INSTALL_DIR}/.env" "$temporary" + mv "$temporary" "${INSTALL_DIR}/.env" + fi + [[ ${#token} -ge 32 ]] || fail "CANVAS_UPDATER_TOKEN 长度不足" + umask 077 + printf 'CANVAS_UPDATER_TOKEN=%s\nCANVAS_UPDATER_INSTALL_DIR=%s\nCANVAS_UPDATER_SOCKET=%s/updater.sock\n' "$token" "$INSTALL_DIR" "$SOCKET_DIR" > "$UPDATER_ENV" +} + +install_service() { + local temporary_service + install -d -m 0755 "$SOCKET_DIR" + install -d -m 0700 /var/lib/open-ai-canvas-updater "${INSTALL_DIR}/backups" + temporary_service="$(mktemp)" + printf '%s\n' \ + '[Unit]' \ + 'Description=Open AI Canvas Host Updater' \ + 'After=docker.service network-online.target' \ + 'Requires=docker.service' \ + 'Wants=network-online.target' \ + '' \ + '[Service]' \ + 'Type=simple' \ + "EnvironmentFile=${UPDATER_ENV}" \ + "ExecStart=${UPDATER_BIN}" \ + 'Restart=on-failure' \ + 'RestartSec=5s' \ + 'NoNewPrivileges=true' \ + 'PrivateTmp=true' \ + 'ProtectHome=true' \ + 'ProtectSystem=full' \ + "ReadWritePaths=${INSTALL_DIR} /var/lib/open-ai-canvas-updater ${SOCKET_DIR} ${UPDATER_BIN}" \ + '' \ + '[Install]' \ + 'WantedBy=multi-user.target' > "$temporary_service" + install -m 0644 "$temporary_service" "$UPDATER_SERVICE" + rm -f "$temporary_service" + systemctl daemon-reload + systemctl enable --now open-ai-canvas-updater.service + systemctl restart open-ai-canvas-updater.service +} + +main() { + require_root + read_image_tag + install_binary + ensure_token + install_service + printf 'Host Updater 已安装,Socket:%s/updater.sock\n' "$SOCKET_DIR" + printf '请重建 backend 容器,使 Token 与 Socket 挂载生效。\n' +} + +main "$@" diff --git a/scripts/install-server-image.sh b/scripts/install-server-image.sh index bc1736f5f..20d15eeec 100755 --- a/scripts/install-server-image.sh +++ b/scripts/install-server-image.sh @@ -5,9 +5,12 @@ set -Eeuo pipefail REPOSITORY_REF="${REPOSITORY_REF:-main}" INSTALL_DIR="${INSTALL_DIR:-/opt/open-ai-canvas}" CANVAS_HTTP_PORT="${CANVAS_HTTP_PORT:-3000}" -CANVAS_IMAGE_TAG="${CANVAS_IMAGE_TAG:-latest}" +REQUESTED_IMAGE_TAG="${CANVAS_IMAGE_TAG:-}" +CANVAS_IMAGE_TAG="${REQUESTED_IMAGE_TAG:-latest}" +CANVAS_IMAGE_TAG="${CANVAS_IMAGE_TAG#v}" COMPOSE_FILE="docker-compose.deploy.yml" COMPOSE_URL="${COMPOSE_URL:-https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/${REPOSITORY_REF}/${COMPOSE_FILE}}" +UPDATER_INSTALL_URL="${UPDATER_INSTALL_URL:-https://raw.githubusercontent.com/ddcat-ai/open-ai-canvas/${REPOSITORY_REF}/scripts/install-host-updater.sh}" step() { printf '\n==> %s\n' "$1" @@ -91,9 +94,20 @@ prepare_environment() { local configured_image_tag configured_image_tag="$(sed -n 's/^CANVAS_IMAGE_TAG=//p' .env | tail -n 1)" - if [[ -n "$configured_image_tag" ]]; then + if [[ -n "$REQUESTED_IMAGE_TAG" ]]; then + local temporary_env + temporary_env="$(mktemp "${INSTALL_DIR}/.env.XXXXXX")" + awk -v image_tag="$CANVAS_IMAGE_TAG" ' + BEGIN { updated=0 } + /^CANVAS_IMAGE_TAG=/ { print "CANVAS_IMAGE_TAG=" image_tag; updated=1; next } + { print } + END { if (!updated) print "CANVAS_IMAGE_TAG=" image_tag } + ' .env > "$temporary_env" + chmod --reference=.env "$temporary_env" + mv "$temporary_env" .env + elif [[ -n "$configured_image_tag" ]]; then [[ "$configured_image_tag" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail ".env 中的 CANVAS_IMAGE_TAG 无效" - CANVAS_IMAGE_TAG="$configured_image_tag" + CANVAS_IMAGE_TAG="${configured_image_tag#v}" fi return fi @@ -124,6 +138,19 @@ download_compose() { mv "$temporary_file" "$COMPOSE_FILE" } +install_host_updater() { + if [[ "$CANVAS_IMAGE_TAG" == "latest" ]]; then + printf '\n提示:CANVAS_IMAGE_TAG=latest,已跳过在线更新器安装。固定到具体发布版本后可再次运行本脚本。\n' + return + fi + step "安装宿主机在线更新服务" + local installer + installer="$(mktemp)" + curl -fsSL "$UPDATER_INSTALL_URL" -o "$installer" + INSTALL_DIR="$INSTALL_DIR" bash "$installer" + rm -f "$installer" +} + start_services() { step "拉取并启动 GHCR 网页与后端镜像" if ! docker compose --env-file .env -f "$COMPOSE_FILE" pull; then @@ -152,6 +179,7 @@ main() { login_ghcr prepare_environment download_compose + install_host_updater start_services print_result } diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 42427751f..be01e1f83 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -22,6 +22,7 @@ import { Paintbrush, PlugZap, RadioTower, + RefreshCw, Settings2, ShieldAlert, ShieldCheck, @@ -87,6 +88,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { path: "/admin/settings/ark-private-assets", label: "方舟素材库", description: "Seedance 可信参考素材", icon: }, { path: "/admin/settings/response-interception", label: "模型响应拦截", description: "先启用策略,再配置替换规则", icon: }, { path: "/admin/settings/third-party", label: "第三方参数配置", description: "先配置凭据,再开放用户入口", icon: }, + { path: "/admin/settings/system-update", label: "系统更新", description: "检查版本、备份与安全更新", icon: }, ], }, ]; diff --git a/web/src/pages/admin/settings/system-update-page.tsx b/web/src/pages/admin/settings/system-update-page.tsx new file mode 100644 index 000000000..e3d2e3e21 --- /dev/null +++ b/web/src/pages/admin/settings/system-update-page.tsx @@ -0,0 +1,280 @@ +import { App, Button, Input, Skeleton } from "antd"; +import { AlertTriangle, Archive, BadgeCheck, Circle, ExternalLink, History, RefreshCw, RotateCcw, ServerCog, ShieldCheck } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { checkSystemUpdate, getSystemUpdateStatus, rollbackSystemUpdate, startSystemUpdate, type SystemUpdateStatus, type UpdatePhase } from "@/services/api/system-update"; +import { AdminPageFrame } from "../components/admin-shell"; +import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui"; + +const activePhases = new Set(["checking", "preflight", "backing_up", "pulling", "draining", "migrating", "switching", "verifying", "rolling_back"]); + +const phaseLabels: Record = { + idle: "等待检查", + checking: "正在检查", + ready: "可更新", + no_update: "已是最新", + preflight: "前置检查", + backing_up: "正在备份", + pulling: "拉取镜像", + draining: "停止旧服务", + migrating: "迁移数据库", + switching: "切换版本", + verifying: "健康验证", + succeeded: "更新成功", + rolling_back: "正在回退", + rolled_back: "已回退", + failed: "更新失败", + manual_intervention: "需人工介入", +}; + +export default function SystemUpdatePage() { + const { message, modal } = App.useApp(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [checking, setChecking] = useState(false); + const [starting, setStarting] = useState(false); + const [reconnecting, setReconnecting] = useState(false); + const [loadError, setLoadError] = useState(""); + const mountedRef = useRef(true); + + const load = useCallback(async (initial = false) => { + if (initial) setLoading(true); + try { + const next = await getSystemUpdateStatus(); + if (!mountedRef.current) return; + setStatus(next); + setLoadError(""); + setReconnecting(false); + } catch (error) { + if (!mountedRef.current) return; + const errorMessage = error instanceof Error ? error.message : "读取系统更新状态失败"; + setLoadError(errorMessage); + if (status && activePhases.has(status.operation.phase)) setReconnecting(true); + } finally { + if (mountedRef.current && initial) setLoading(false); + } + }, [status]); + + useEffect(() => { + mountedRef.current = true; + void load(true); + return () => { + mountedRef.current = false; + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!status || !activePhases.has(status.operation.phase)) return; + const timer = window.setInterval(() => void load(false), 2500); + return () => window.clearInterval(timer); + }, [load, status]); + + const operationActive = Boolean(status && activePhases.has(status.operation.phase)); + const blockingCheckFailed = status?.checks.some((check) => check.blocking && check.status === "failed") ?? true; + + const requestCheck = async () => { + setChecking(true); + try { + const next = await checkSystemUpdate(); + setStatus(next); + message.success(next.updateAvailable ? `发现新版本 ${next.latestRelease?.version}` : "当前已是最新版本"); + } catch (error) { + message.error(error instanceof Error ? error.message : "检查更新失败"); + await load(false); + } finally { + setChecking(false); + } + }; + + const requestStart = () => { + if (!status?.latestRelease) return; + const target = status.latestRelease.version; + modal.confirm({ + title: `更新到 ${target}?`, + width: 560, + content: ( +
+

系统会先创建并校验数据库 ZIP 与数据目录备份,再停止旧服务、执行迁移并切换镜像。

+

更新期间站点会短暂不可用。任何前置检查失败都不会切换服务;切换后验证失败会自动恢复旧版本和数据库。

+
+ ), + okText: "确认并开始更新", + cancelText: "取消", + onOk: async () => { + setStarting(true); + try { + const next = await startSystemUpdate(target); + setStatus(next); + message.success("更新任务已启动,页面会自动重连并显示进度"); + } catch (error) { + message.error(error instanceof Error ? error.message : "无法开始更新"); + throw error; + } finally { + setStarting(false); + } + }, + }); + }; + + const requestRollback = () => { + if (!status?.rollbackVersion) return; + let reason = ""; + modal.confirm({ + title: `回退到 ${status.rollbackVersion}?`, + width: 520, + content: ( +
+

回退会停止当前服务、恢复最近一次已校验数据库备份并重新启动旧镜像。请先确认备份时间与业务影响。

+ (reason = event.target.value)} /> +
+ ), + okText: "开始回退", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: async () => { + if (!reason.trim()) { + message.warning("请填写回退原因"); + throw new Error("rollback reason required"); + } + const next = await rollbackSystemUpdate(reason.trim()); + setStatus(next); + message.success("回退任务已启动"); + }, + }); + }; + + if (loading) { + return ( + +
+
+ ); + } + + return ( + } loading={checking} disabled={operationActive || !status?.supported} onClick={() => void requestCheck()}> + 检查更新 + + } + > +
+ {loadError && !status ? : null} + {!status?.supported ? : null} + {reconnecting ? : null} + {status?.operation.phase === "manual_intervention" ? : null} + +
+ } + title="可用版本" + description={status?.updateAvailable ? "发现比当前运行版本更新的发布构建。" : "检查 GitHub Release 与当前运行版本。"} + status={} + footer={ + <> + 更新按钮不会跳过备份、镜像摘要、迁移或健康检查。 + + + } + > +
+
+ {status?.latestRelease?.version || "尚未检查"} + {status?.latestRelease?.prerelease ? : null} +
+
+
当前版本
{status?.currentVersion || "未识别"}
+
部署方式
{status?.deployment || "未知"}
+
发布时间
{formatDate(status?.latestRelease?.publishedAt)}
+
代码仓库
{status?.repository || "ddcat-ai/open-ai-canvas"}
+
+ {status?.latestRelease ? ( +
+

更新日志

查看 Release
+
{status.latestRelease.body || "本版本未填写更新日志。"}
+
+ ) : null} +
+
+ + } title="更新前检查" description="所有阻断项通过后才允许切换服务。" status={{ label: status?.connected ? "更新器已连接" : "更新器未连接", color: status?.connected ? "success" : "error" }}> +
+ {(status?.checks || []).map((check) => )} +
+
+
+ + } title="更新进度" description="操作状态和日志由宿主机持久化,服务重启后仍可读取。" status={}> +
+ {status?.operation.error ? : null} + {(status?.operation.logs || []).length ? ( +
    + {status?.operation.logs.map((entry, index) => ( +
  1. {phaseLabels[entry.phase]}

    {entry.message}

  2. + ))} +
+ ) :

检查更新后,这里会显示完整操作记录。

} +
+
+ + } + title="备份与回退" + description="只使用最近一次通过 ZIP 结构与 SHA-256 校验的备份。" + status={{ label: status?.lastBackup ? "备份已校验" : "暂无备份", color: status?.lastBackup ? "success" : "warning" }} + footer={ + <> + 人工回退同样会执行停服、数据库恢复、旧镜像启动和健康验证。 + + + } + > +
+ {status?.lastBackup ?
备份编号
{status.lastBackup.id}
来源版本
{status.lastBackup.version}
创建时间
{formatDate(status.lastBackup.createdAt)}
文件大小
{formatBytes(status.lastBackup.size)}
SHA-256
{status.lastBackup.checksum}
:

首次开始更新时会自动创建数据库与数据目录 ZIP;备份失败将直接中止更新。

} +
+
+
+
+ ); +} + +function PhaseBadge({ phase }: { phase: UpdatePhase }) { + const tone = phase === "succeeded" || phase === "ready" ? "success" : phase === "failed" || phase === "manual_intervention" ? "error" : phase === "rolling_back" || phase === "rolled_back" ? "warning" : activePhases.has(phase) ? "info" : "neutral"; + return ; +} + +function UpdateCheckRow({ check }: { check: SystemUpdateStatus["checks"][number] }) { + const Icon = check.status === "passed" ? BadgeCheck : check.status === "failed" ? AlertTriangle : Circle; + return
{check.label}

{check.detail || "等待执行"}

{check.status === "passed" ? "通过" : check.status === "failed" ? "失败" : "待执行"}
; +} + +function UpdateAlert({ tone, title, detail, compact = false }: { tone: "warning" | "error"; title: string; detail: string; compact?: boolean }) { + return
{title}

{detail}

; +} + +function formatDate(value?: string) { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("zh-CN", { hour12: false }); +} + +function formatTime(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleTimeString("zh-CN", { hour12: false }); +} + +function formatBytes(value: number) { + if (!Number.isFinite(value) || value <= 0) return "—"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1); + return `${(value / 1024 ** index).toFixed(index > 1 ? 2 : 0)} ${units[index]}`; +} diff --git a/web/src/router.tsx b/web/src/router.tsx index d9d7fa2e0..2e9f34de4 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -28,6 +28,7 @@ const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-se const ArkPrivateAssetsSettingsPage = lazy(() => import("@/pages/admin/settings/ark-private-assets-settings-page")); const ResponseInterceptionSettingsPage = lazy(() => import("@/pages/admin/settings/response-interception-settings-page")); const ThirdPartySettingsPage = lazy(() => import("@/pages/admin/settings/libtv-settings-page")); +const SystemUpdatePage = lazy(() => import("@/pages/admin/settings/system-update-page")); const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page")); const UsersPage = lazy(() => import("@/pages/admin/users/users-page")); const AssetsPage = lazy(loadAssetsPage); @@ -187,6 +188,7 @@ export const router = createBrowserRouter([ { path: "settings/ark-private-assets", element: deferred() }, { path: "settings/response-interception", element: deferred() }, { path: "settings/third-party", element: deferred() }, + { path: "settings/system-update", element: deferred() }, { path: "settings/libtv", element: }, ], }, diff --git a/web/src/services/api/system-update.ts b/web/src/services/api/system-update.ts new file mode 100644 index 000000000..7bb97053b --- /dev/null +++ b/web/src/services/api/system-update.ts @@ -0,0 +1,94 @@ +import { apiClient, request } from "./request"; + +export type UpdatePhase = + | "idle" + | "checking" + | "ready" + | "no_update" + | "preflight" + | "backing_up" + | "pulling" + | "draining" + | "migrating" + | "switching" + | "verifying" + | "succeeded" + | "rolling_back" + | "rolled_back" + | "failed" + | "manual_intervention"; + +export type SystemUpdateRelease = { + version: string; + name: string; + body: string; + url: string; + publishedAt: string; + prerelease: boolean; +}; + +export type SystemUpdateCheck = { + key: string; + label: string; + status: "passed" | "pending" | "failed" | string; + detail?: string; + blocking: boolean; +}; + +export type SystemUpdateBackup = { + id: string; + path: string; + checksum: string; + size: number; + createdAt: string; + version: string; +}; + +export type SystemUpdateLog = { + at: string; + phase: UpdatePhase; + message: string; +}; + +export type SystemUpdateOperation = { + id?: string; + phase: UpdatePhase; + fromVersion?: string; + targetVersion?: string; + startedAt?: string; + finishedAt?: string; + error?: string; + rollbackError?: string; + automaticRollback: boolean; + logs: SystemUpdateLog[]; +}; + +export type SystemUpdateStatus = { + supported: boolean; + connected: boolean; + repository: string; + deployment: string; + currentVersion: string; + latestRelease?: SystemUpdateRelease; + updateAvailable: boolean; + checks: SystemUpdateCheck[]; + lastBackup?: SystemUpdateBackup; + rollbackVersion?: string; + operation: SystemUpdateOperation; +}; + +export function getSystemUpdateStatus(signal?: AbortSignal) { + return request(apiClient.get("/admin/system-update", { signal })); +} + +export function checkSystemUpdate() { + return request(apiClient.post("/admin/system-update/check")); +} + +export function startSystemUpdate(targetVersion: string) { + return request(apiClient.post("/admin/system-update/start", { targetVersion })); +} + +export function rollbackSystemUpdate(reason: string) { + return request(apiClient.post("/admin/system-update/rollback", { reason })); +} diff --git a/web/src/styles/admin-ui.css b/web/src/styles/admin-ui.css index 0fc6bd9e2..de1aba507 100644 --- a/web/src/styles/admin-ui.css +++ b/web/src/styles/admin-ui.css @@ -8676,6 +8676,241 @@ html[data-magicui-theme-vt="active"] .admin-shell * { color: var(--foreground); } +.admin-system-update { + gap: 14px; + padding-bottom: 28px; +} + +.admin-system-update-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.75fr); + gap: 14px; +} + +.admin-system-update-release-body, +.admin-system-update-progress, +.admin-system-update-backup { + padding: 16px; +} + +.admin-system-update-version-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + padding-bottom: 15px; +} + +.admin-system-update-version-row > strong { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: clamp(22px, 3vw, 29px); + line-height: 1; + letter-spacing: -0.04em; +} + +.admin-system-update-facts, +.admin-system-update-backup dl { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + gap: 8px 14px; + margin: 0; + font-size: 12px; +} + +.admin-system-update-facts dt, +.admin-system-update-backup dt { + color: color-mix(in srgb, var(--foreground) 48%, transparent); +} + +.admin-system-update-facts dd, +.admin-system-update-backup dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.admin-system-update-notes { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid var(--admin-divider); +} + +.admin-system-update-notes h3 { + font-size: 12px; + font-weight: 650; +} + +.admin-system-update-notes a { + display: inline-flex; + align-items: center; + gap: 4px; + color: color-mix(in srgb, var(--foreground) 62%, transparent); + font-size: 11px; +} + +.admin-system-update-notes pre { + max-height: 260px; + margin: 10px 0 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: color-mix(in srgb, var(--foreground) 68%, transparent); + font: inherit; + font-size: 12px; + line-height: 1.7; +} + +.admin-system-update-checks { + padding: 4px 16px 8px; +} + +.admin-system-update-check-row { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: start; + gap: 9px; + padding: 11px 0; + border-bottom: 1px solid var(--admin-divider); +} + +.admin-system-update-check-row:last-child { + border-bottom: 0; +} + +.admin-system-update-check-row > svg { + margin-top: 1px; + color: color-mix(in srgb, var(--foreground) 38%, transparent); +} + +.admin-system-update-check-row[data-status="passed"] > svg, +.admin-system-update-check-row[data-status="passed"] > span { + color: var(--status-success); +} + +.admin-system-update-check-row[data-status="failed"] > svg, +.admin-system-update-check-row[data-status="failed"] > span { + color: var(--status-error); +} + +.admin-system-update-check-row strong { + display: block; + font-size: 12px; + font-weight: 600; +} + +.admin-system-update-check-row p { + margin: 2px 0 0; + color: color-mix(in srgb, var(--foreground) 48%, transparent); + font-size: 11px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.admin-system-update-check-row > span { + color: color-mix(in srgb, var(--foreground) 45%, transparent); + font-size: 11px; +} + +.admin-system-update-progress ol { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.admin-system-update-progress li { + display: grid; + grid-template-columns: 14px minmax(0, 1fr) auto; + gap: 9px; + padding: 9px 0; +} + +.admin-system-update-progress li strong { + font-size: 12px; + font-weight: 600; +} + +.admin-system-update-progress li p { + margin: 2px 0 0; + color: color-mix(in srgb, var(--foreground) 55%, transparent); + font-size: 12px; +} + +.admin-system-update-progress time { + color: color-mix(in srgb, var(--foreground) 40%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 10px; +} + +.admin-system-update-dot { + width: 8px; + height: 8px; + margin-top: 5px; + border-radius: 50%; + background: color-mix(in srgb, var(--foreground) 24%, transparent); +} + +.admin-system-update-dot[data-active="true"] { + background: var(--status-loading); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--status-loading) 12%, transparent); +} + +.admin-system-update-empty, +.admin-system-update-backup > p { + margin: 0; + color: color-mix(in srgb, var(--foreground) 52%, transparent); + font-size: 12px; + line-height: 1.7; +} + +.admin-system-update-alert { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 10px; + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--status-warning) 26%, var(--admin-divider)); + border-radius: 8px; + background: color-mix(in srgb, var(--status-warning) 7%, var(--background)); +} + +.admin-system-update-alert[data-tone="error"] { + border-color: color-mix(in srgb, var(--status-error) 28%, var(--admin-divider)); + background: color-mix(in srgb, var(--status-error) 7%, var(--background)); +} + +.admin-system-update-alert > svg { + margin-top: 1px; + color: var(--status-warning); +} + +.admin-system-update-alert[data-tone="error"] > svg { + color: var(--status-error); +} + +.admin-system-update-alert strong { + display: block; + font-size: 12px; +} + +.admin-system-update-alert p { + margin: 3px 0 0; + color: color-mix(in srgb, var(--foreground) 62%, transparent); + font-size: 12px; + line-height: 1.6; +} + +.admin-system-update-alert.is-compact { + margin-bottom: 10px; + padding: 10px 12px; +} + +@media (max-width: 980px) { + .admin-system-update-grid { + grid-template-columns: 1fr; + } +} + @media (max-width: 639px) { .admin-page-frame { padding: 10px 12px 0; diff --git a/web/vite.config.ts b/web/vite.config.ts index e913b3dff..410a56b05 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,7 +5,7 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; const webDir = dirname(fileURLToPath(import.meta.url)); -const appVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim(); +const appVersion = process.env.CANVAS_BUILD_VERSION?.trim() || readFileSync(resolve(webDir, "../VERSION"), "utf8").trim(); const appChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); const apiProxyTarget = process.env.VITE_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8080"; @@ -14,6 +14,7 @@ export default defineConfig({ define: { __APP_VERSION__: JSON.stringify(appVersion), __APP_CHANGELOG__: JSON.stringify(appChangelog), + "import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion), }, server: { proxy: {