From d154aef807a09361f64e9da3866c33812eb3fd90 Mon Sep 17 00:00:00 2001 From: mrlee <470349233@qq.com> Date: Mon, 28 Sep 2026 11:10:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(updater):=20=E8=87=AA=E5=8A=A8=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E5=AE=BF=E4=B8=BB=E6=9C=BA=20Compose=20=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=8F=91=E5=B8=83=20v1.3.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 ++++ VERSION | 2 +- cmd/fonu-updater/main.go | 19 +++-- docker-compose.hub.yml | 3 + docker-compose.yml | 3 + docs/README.md | 4 +- docs/system-upgrade.md | 18 +++-- internal/updater/runner.go | 48 +++++++++++- internal/updater/server.go | 128 ++++++++++++++++++++++++++++---- internal/updater/server_test.go | 28 ++++++- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/views/ExampleView.vue | 4 +- 13 files changed, 235 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f438aa..f045327 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## [v1.3.6] - 2026-09-28 + +### 修复 + +- **自动定位宿主机 Compose 目录**:当 updater 容器内的 `/workspace` 没有挂载到真实 Compose 目录时,不再直接失败。updater 会从 `fonu` 容器的 `com.docker.compose.project.working_dir` / `project.config_files` 标签读取宿主机路径,并通过临时 helper 容器挂载真实目录执行 `docker compose`。`FONU_UPDATE_HOST_DIR` 仍可作为显式覆盖 +- 示例页验证标识更新为 `v1.3.6-example-page` + +### 变更 + +- 版本号提升到 1.3.6;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8 + +### 测试 + +- `internal/updater`:覆盖 Compose 文件直挂载与宿主机目录 helper 模式 + ## [v1.3.5] - 2026-09-28 ### 新增 diff --git a/VERSION b/VERSION index 80e78df..95b25ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.5 +1.3.6 diff --git a/cmd/fonu-updater/main.go b/cmd/fonu-updater/main.go index e2067a0..907e08d 100644 --- a/cmd/fonu-updater/main.go +++ b/cmd/fonu-updater/main.go @@ -12,14 +12,17 @@ import ( func main() { server, err := updater.NewServer(updater.Config{ - SocketPath: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"), - ProjectDir: envOr("FONU_UPDATE_PROJECT_DIR", "/workspace"), - ComposeFile: envOr("FONU_UPDATE_COMPOSE_FILE", "docker-compose.yml"), - Service: envOr("FONU_UPDATE_SERVICE", "fonu"), - Mode: envOr("FONU_UPDATE_MODE", "local"), - RepoURL: envOr("FONU_UPDATE_REPO", ""), - Branch: envOr("FONU_UPDATE_BRANCH", "main"), - AppURL: envOr("FONU_UPDATE_APP_URL", "http://fonu:6893"), + SocketPath: envOr("FONU_UPDATER_SOCKET", "/run/fonu-updater/updater.sock"), + ProjectDir: envOr("FONU_UPDATE_PROJECT_DIR", "/workspace"), + ComposeFile: envOr("FONU_UPDATE_COMPOSE_FILE", "docker-compose.yml"), + Service: envOr("FONU_UPDATE_SERVICE", "fonu"), + Mode: envOr("FONU_UPDATE_MODE", "local"), + RepoURL: envOr("FONU_UPDATE_REPO", ""), + Branch: envOr("FONU_UPDATE_BRANCH", "main"), + AppURL: envOr("FONU_UPDATE_APP_URL", "http://fonu:6893"), + HostProjectDir: envOr("FONU_UPDATE_HOST_DIR", ""), + SelfImage: envOr("FONU_UPDATE_SELF_IMAGE", ""), + DockerSocket: envOr("FONU_UPDATE_DOCKER_SOCK", "/var/run/docker.sock"), }, nil) if err != nil { log.Fatal(err) diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml index de8ae41..36bea38 100644 --- a/docker-compose.hub.yml +++ b/docker-compose.hub.yml @@ -81,6 +81,9 @@ services: FONU_UPDATE_REPO: http://45.207.223.190:10082/mrlee/-Fonu.git FONU_UPDATE_BRANCH: main FONU_UPDATE_APP_URL: http://fonu:6893 + FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-} + FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local} + FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock} TZ: ${TZ:-Asia/Shanghai} volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 4d496b3..9b241de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,9 @@ services: FONU_UPDATE_SERVICE: fonu FONU_UPDATE_MODE: local FONU_UPDATE_APP_URL: http://fonu:6893 + FONU_UPDATE_HOST_DIR: ${FONU_UPDATE_HOST_DIR:-} + FONU_UPDATE_SELF_IMAGE: fonu-updater:${FONU_VERSION:-local} + FONU_UPDATE_DOCKER_SOCK: ${DOCKER_SOCK:-/var/run/docker.sock} TZ: ${TZ:-Asia/Shanghai} volumes: diff --git a/docs/README.md b/docs/README.md index 6d644ac..1aa200a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-28(v1.3.5) +> 最近更新:2026-09-28(v1.3.6) ## 方案与设计(本目录) @@ -9,7 +9,7 @@ | --- | --- | --- | | `project-analysis-2026-09-27.md` | 全项目详细分析(v1.2.19):架构、模块、数据、API、前端、链路、风险与改进路线 | 当前版本快照 | | `improvement-roadmap-2026-09-27.md` | P0-P3 改进路线实施方案:逐项改法、测试、验收、依赖、发布与回滚 | P0-1、P0-3 已完成,其余待实施 | -| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.5) | +| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与验证页 | 已实施(v1.3.6) | | `project-audit-2026-09-24.md` | 全项目审查:P0/P1/P2 分级,含精确行号与逐条修法 | **进行中**——P0 已随 v1.2.7 完成,P1 实施中 | | `frp-server-list-merge-plan.md` | FRP 服务端列表并入「公网服务端」页(方案 A,四步) | 步 1/2 已完成(v1.2.6);步 3 文案收尾、步 4 死样式清理未做 | | `agent-capability-plan.md` | fonu-agent 能力扩展(P0/P1/P2/P3) | 已实施(agent 0.11.0);P3 三项不做 | diff --git a/docs/system-upgrade.md b/docs/system-upgrade.md index d5592ba..cee1c97 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -94,7 +94,7 @@ docker compose -f docker-compose.hub.yml up -d --build | `FONU_UPDATE_BRANCH` | `git` 模式下的分支,默认 `main` | | `FONU_UPDATE_APP_URL` | 健康检查地址,默认 `http://fonu:6893` | | `DOCKER_SOCK` | 宿主机 Docker Socket,默认 `/var/run/docker.sock` | -| `FONU_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;Docker UI / fnOS 场景下建议显式设置 | +| `FONU_UPDATE_HOST_DIR` | 宿主机上 Compose 文件所在目录;留空时自动从 Compose 容器标签发现,设置后作为显式覆盖 | ## 6. 前端行为 @@ -160,13 +160,15 @@ docker compose up -d --build ### 升级时报 `/workspace/docker-compose.hub.yml: no such file or directory` -说明 updater 容器没有挂载到真实的 Compose 项目目录。先查询运行中 `fonu` 容器的 Compose 工作目录: +v1.3.6 起 updater 会自动从 `fonu` 容器的 Compose 标签读取宿主机目录,并通过 helper 容器执行 Compose。如果自动发现仍失败,可以显式设置目录: + +先查询运行中 `fonu` 容器的 Compose 工作目录: ```bash docker inspect fonu --format '{{ index .Config.Labels "com.docker.compose.project.working_dir" }}' ``` -把输出目录写入 Compose 同层 `.env`: +把输出目录写入 Compose 同层 `.env` 作为覆盖: ```dotenv FONU_UPDATE_HOST_DIR=/实际/的/Compose/目录 @@ -178,7 +180,7 @@ FONU_UPDATE_HOST_DIR=/实际/的/Compose/目录 docker compose -f /实际/的/Compose/目录/docker-compose.hub.yml up -d --build updater ``` -状态接口此时会明确提示 Compose 文件缺失,修复挂载后按钮才会重新显示。 +状态接口会明确提示 Compose 文件缺失,修复挂载或设置覆盖后按钮才会重新显示。 ### `docker-compose.hub.yml` 模式无法读取远端版本 @@ -226,16 +228,16 @@ FONU_VERSION=<旧版本> docker compose up -d --no-deps --force-recreate fonu ## 10. 在线升级验证 -示例页面用于验证升级结果。v1.3.5 的验证流程: +示例页面用于验证升级结果。v1.3.6 的验证流程: 1. 确认当前线上实例为 v1.3.0,且 `fonu-updater` sidecar 已运行。 -2. 确认远端 `main` 分支的 `VERSION` 为 `1.3.5`。 +2. 确认远端 `main` 分支的 `VERSION` 为 `1.3.6`。 3. 在侧栏看到「一键升级」后点击并确认。 4. 等待升级弹窗显示成功,页面自动刷新。 5. 打开侧栏「示例页面」,确认: - 页面路由为 `/example`; - - 页面显示的版本为 `1.3.5`; - - 页面显示验证标识 `v1.3.5-example-page`; + - 页面显示的版本为 `1.3.6`; + - 页面显示验证标识 `v1.3.6-example-page`; - 「升级验证」显示为「通过」。 如果示例页面可见且版本正确,说明镜像构建、容器重建、后端版本注入和前端静态资源更新均已生效。 diff --git a/internal/updater/runner.go b/internal/updater/runner.go index fd5619c..c9fa87e 100644 --- a/internal/updater/runner.go +++ b/internal/updater/runner.go @@ -22,7 +22,7 @@ func (s *Server) runUpdate(ctx context.Context) error { s.state.Phase = PhaseRunning s.state.Message = "正在准备升级" s.mu.Unlock() - if err := s.ensureComposeFile(); err != nil { + if err := s.ensureComposeFile(ctx); err != nil { return err } @@ -316,11 +316,55 @@ func imageTag(image string) string { } func (s *Server) compose(ctx context.Context, version string, args ...string) (string, error) { - fullArgs := append([]string{"compose", "-f", s.composePath}, args...) + plan, err := s.resolveComposePlan(ctx) + if err != nil { + return "", err + } + if plan.helper { + return s.composeViaHelper(ctx, plan, version, args...) + } + fullArgs := append([]string{"compose", "-f", plan.containerCompose}, args...) env := []string{"FONU_VERSION=" + version} return runCommand(ctx, s.cfg.ProjectDir, env, "docker", fullArgs...) } +func (s *Server) composeViaHelper(ctx context.Context, plan composePlan, version string, args ...string) (string, error) { + image, err := s.selfImage(ctx) + if err != nil { + return "", err + } + helperArgs := []string{ + "run", "--rm", "--entrypoint", "docker", + "-v", s.cfg.DockerSocket + ":/var/run/docker.sock", + "-v", plan.hostProjectDir + ":/workspace", + "-w", "/workspace", + "-e", "FONU_VERSION=" + version, + image, + "compose", "-f", plan.helperComposePath, + } + helperArgs = append(helperArgs, args...) + return runCommand(ctx, "", nil, "docker", helperArgs...) +} + +func (s *Server) selfImage(ctx context.Context) (string, error) { + if s.cfg.SelfImage != "" { + return s.cfg.SelfImage, nil + } + hostname, err := os.Hostname() + if err != nil { + return "", err + } + output, err := runCommand(ctx, "", nil, "docker", "inspect", "--format", "{{.Config.Image}}", hostname) + if err != nil { + return "", fmt.Errorf("无法识别 updater 镜像:%w", err) + } + image := strings.TrimSpace(output) + if image == "" { + return "", fmt.Errorf("无法识别 updater 镜像") + } + return image, nil +} + func runCommand(ctx context.Context, dir string, extraEnv []string, name string, args ...string) (string, error) { command := exec.CommandContext(ctx, name, args...) command.Dir = dir diff --git a/internal/updater/server.go b/internal/updater/server.go index ddf7193..24ee02d 100644 --- a/internal/updater/server.go +++ b/internal/updater/server.go @@ -22,16 +22,19 @@ var serviceNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) // Config 是 fonu-updater sidecar 的运行配置。 type Config struct { - SocketPath string - ProjectDir string - ComposeFile string - Service string - Mode string - RepoURL string - Branch string - AppURL string - BuildTimeout time.Duration - HealthTimeout time.Duration + SocketPath string + ProjectDir string + ComposeFile string + Service string + Mode string + RepoURL string + Branch string + AppURL string + HostProjectDir string + SelfImage string + DockerSocket string + BuildTimeout time.Duration + HealthTimeout time.Duration } type Server struct { @@ -44,6 +47,14 @@ type Server struct { lastChecked time.Time } +type composePlan struct { + helper bool + containerCompose string + hostProjectDir string + hostComposePath string + helperComposePath string +} + // NewServer 校验 sidecar 配置;参数不正确时拒绝启动。 func NewServer(cfg Config, logger *slog.Logger) (*Server, error) { cfg.SocketPath = strings.TrimSpace(cfg.SocketPath) @@ -68,6 +79,11 @@ func NewServer(cfg Config, logger *slog.Logger) (*Server, error) { if strings.TrimSpace(cfg.AppURL) == "" { cfg.AppURL = "http://fonu:6893" } + if strings.TrimSpace(cfg.DockerSocket) == "" { + cfg.DockerSocket = "/var/run/docker.sock" + } + cfg.HostProjectDir = strings.TrimSpace(cfg.HostProjectDir) + cfg.SelfImage = strings.TrimSpace(cfg.SelfImage) if cfg.BuildTimeout <= 0 { cfg.BuildTimeout = 30 * time.Minute } @@ -162,15 +178,95 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusAccepted, status) } -func (s *Server) ensureComposeFile() error { +func (s *Server) ensureComposeFile(ctx context.Context) error { + _, err := s.resolveComposePlan(ctx) + return err +} + +func (s *Server) resolveComposePlan(ctx context.Context) (composePlan, error) { info, err := os.Stat(s.composePath) + if err == nil && !info.IsDir() { + return composePlan{containerCompose: s.composePath}, nil + } + if err == nil && info.IsDir() { + return composePlan{}, fmt.Errorf("Compose 路径是目录而不是文件:%s", s.composePath) + } + hostDir, hostComposePath, discoverErr := s.discoverHostCompose(ctx) + if discoverErr == nil { + helperPath, pathErr := helperComposePath(hostDir, hostComposePath) + if pathErr == nil { + return composePlan{ + helper: true, + hostProjectDir: hostDir, + hostComposePath: hostComposePath, + helperComposePath: helperPath, + }, nil + } + } + return composePlan{}, fmt.Errorf("找不到 Compose 文件 %s,且无法自动定位宿主机目录;请设置 FONU_UPDATE_HOST_DIR", s.composePath) +} + +func (s *Server) discoverHostCompose(ctx context.Context) (string, string, error) { + if s.cfg.HostProjectDir != "" { + if !filepath.IsAbs(s.cfg.HostProjectDir) { + return "", "", fmt.Errorf("FONU_UPDATE_HOST_DIR 必须是绝对路径") + } + return s.cfg.HostProjectDir, filepath.Join(s.cfg.HostProjectDir, filepath.Base(s.cfg.ComposeFile)), nil + } + workingDir, _ := s.dockerLabel(ctx, "com.docker.compose.project.working_dir") + configFiles, _ := s.dockerLabel(ctx, "com.docker.compose.project.config_files") + hostDir := strings.TrimSpace(workingDir) + hostCompose := chooseComposeFile(configFiles, filepath.Base(s.cfg.ComposeFile)) + if hostCompose == "" { + return "", "", fmt.Errorf("无法从容器标签读取 Compose 文件") + } + if hostDir == "" { + hostDir = filepath.Dir(hostCompose) + } + if !filepath.IsAbs(hostDir) || !filepath.IsAbs(hostCompose) { + return "", "", fmt.Errorf("容器标签中的 Compose 路径不是绝对路径") + } + return filepath.Clean(hostDir), filepath.Clean(hostCompose), nil +} + +func (s *Server) dockerLabel(ctx context.Context, label string) (string, error) { + format := fmt.Sprintf(`{{ index .Config.Labels %q }}`, label) + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + output, err := runCommand(checkCtx, "", nil, "docker", "inspect", "--format", format, s.cfg.Service) if err != nil { - return fmt.Errorf("找不到 Compose 文件 %s;请确认 updater 的 /workspace 挂载,或在 .env 设置 FONU_UPDATE_HOST_DIR 指向 Compose 所在目录", s.composePath) + return "", err } - if info.IsDir() { - return fmt.Errorf("Compose 路径是目录而不是文件:%s", s.composePath) + return strings.TrimSpace(output), nil +} + +func chooseComposeFile(configFiles, expectedBase string) string { + for _, item := range strings.Split(configFiles, ",") { + path := strings.TrimSpace(item) + if path == "" { + continue + } + if filepath.Base(path) == expectedBase { + return path + } } - return nil + for _, item := range strings.Split(configFiles, ",") { + if path := strings.TrimSpace(item); path != "" { + return path + } + } + return "" +} + +func helperComposePath(hostDir, hostCompose string) (string, error) { + rel, err := filepath.Rel(hostDir, hostCompose) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("Compose 文件不在宿主机项目目录内") + } + return filepath.Join("/workspace", rel), nil } // Status 返回当前版本、远端版本和任务状态。 @@ -182,7 +278,7 @@ func (s *Server) Status(ctx context.Context) Status { return status } s.mu.Unlock() - if err := s.ensureComposeFile(); err != nil { + if err := s.ensureComposeFile(ctx); err != nil { s.mu.Lock() defer s.mu.Unlock() s.state.UpdateAvailable = false diff --git a/internal/updater/server_test.go b/internal/updater/server_test.go index 15bc0e7..eb83486 100644 --- a/internal/updater/server_test.go +++ b/internal/updater/server_test.go @@ -1,6 +1,7 @@ package updater import ( + "context" "os" "path/filepath" "testing" @@ -49,14 +50,37 @@ func TestEnsureComposeFile(t *testing.T) { if err != nil { t.Fatal(err) } - if err := server.ensureComposeFile(); err == nil { + if err := server.ensureComposeFile(context.Background()); err == nil { t.Fatal("缺少 compose 文件时应返回错误") } path := filepath.Join(dir, "docker-compose.yml") if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil { t.Fatal(err) } - if err := server.ensureComposeFile(); err != nil { + if err := server.ensureComposeFile(context.Background()); err != nil { t.Fatalf("存在 compose 文件时不应报错: %v", err) } } + +func TestResolveComposePlanUsesHostProjectDir(t *testing.T) { + containerDir := t.TempDir() + hostDir := t.TempDir() + server, err := NewServer(Config{ + ProjectDir: containerDir, + ComposeFile: "docker-compose.hub.yml", + HostProjectDir: hostDir, + }, nil) + if err != nil { + t.Fatal(err) + } + plan, err := server.resolveComposePlan(context.Background()) + if err != nil { + t.Fatal(err) + } + if !plan.helper { + t.Fatal("容器内看不到 Compose 文件时应使用 helper 模式") + } + if plan.helperComposePath != filepath.Join("/workspace", "docker-compose.hub.yml") { + t.Fatalf("helper compose 路径不正确: %s", plan.helperComposePath) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 93c9942..5a57bb5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.3.5", + "version": "1.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index 67f717d..2371e7e 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.3.5", + "version": "1.3.6", "packageManager": "npm@10.9.4", "type": "module", "scripts": { diff --git a/web/src/views/ExampleView.vue b/web/src/views/ExampleView.vue index 8869bb1..6a60dd0 100644 --- a/web/src/views/ExampleView.vue +++ b/web/src/views/ExampleView.vue @@ -8,7 +8,7 @@ - + 当前页面由 v{{ version }} 提供,验证标识为 {{ verificationMarker }}。 如果你是在升级后首次看到这行内容,说明前端构建、镜像重建和服务重启均已成功。 @@ -77,7 +77,7 @@ const message = useMessage() const version = ref(__APP_VERSION__) const checkedAt = ref('') const refreshing = ref(false) -const verificationMarker = 'v1.3.5-example-page' +const verificationMarker = 'v1.3.6-example-page' async function refreshVersion() { refreshing.value = true