diff --git a/CHANGELOG.md b/CHANGELOG.md index adb9687..ebead92 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.2] - 2026-09-27 + +### 修复 + +- **一键升级无法读取远端版本**:`fonu-updater` 检查更新时原来会 `git clone --depth 1` 整个仓库,而状态接口的父级超时只有 20 秒,克隆未完成即被 `signal: killed`,表现为 `fatal: early EOF`,前端因此一直不显示「一键升级」。现改为优先通过 Gitea API / raw 地址或 GitHub raw 读取 `VERSION`,显著降低检查耗时,不再依赖完整克隆 + +### 变更 + +- 版本号提升到 1.3.2;`web/package.json` 与 `web/package-lock.json` 同步对齐 +- `AgentVersion` 保持 0.12.8,本次无 Agent 行为变化 +- `fonu-updater` 位于 sidecar,不能通过自身升级自身;已有部署需要先手动重建一次 `updater` 服务,之后该修复才生效 + +### 测试 + +- `internal/updater`:新增 Gitea API / raw 地址解析、文本与 base64 JSON 版本内容解析测试 + ## [v1.3.1] - 2026-09-27 ### 新增 diff --git a/VERSION b/VERSION index 3a3cd8c..1892b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.1 +1.3.2 diff --git a/docs/README.md b/docs/README.md index 7342027..e119379 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Fonu 文档索引 > 维护约定:新增方案 / 审查文档时在下方登记;方案落地后在「状态」列标注实施结果,并给已被推翻的描述加时效说明。 -> 最近更新:2026-09-27(v1.3.1) +> 最近更新:2026-09-27(v1.3.2) ## 方案与设计(本目录) @@ -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.1 验证页 | 已实施(v1.3.1) | +| `system-upgrade.md` | Docker Compose 一键升级:sidecar 架构、首次部署、安全边界、回滚、故障排查与 v1.3.1 验证页 | 已实施(v1.3.2) | | `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 c37856d..75e56b4 100644 --- a/docs/system-upgrade.md +++ b/docs/system-upgrade.md @@ -134,6 +134,21 @@ FONU_VERSION=<上一个版本> docker compose up -d --no-deps --force-recreate f 3. `FONU_UPDATER_SOCKET` 是否在两侧指向同一路径; 4. 共享卷 `fonu-updater-sock` 是否存在。 +如果状态接口返回: + +```text +读取远端版本失败:git clone ... signal: killed ... fatal: early EOF +``` + +说明运行中的 updater 仍是旧版本,仍在用完整克隆读取 `VERSION`。升级到 v1.3.2 后,updater 会改用 Gitea API / raw 地址读取版本文件。由于 sidecar 不能升级自身,需要先手动重建一次: + +```bash +docker compose -f docker-compose.hub.yml up -d --build updater + +# 本地源码部署 +docker compose up -d --build updater +``` + ### `docker-compose.yml` 模式提示存在未提交改动 升级前的 Git 工作区检查要求干净,避免覆盖用户修改。先处理工作区,或手动执行: diff --git a/internal/updater/remote_version_test.go b/internal/updater/remote_version_test.go new file mode 100644 index 0000000..3431916 --- /dev/null +++ b/internal/updater/remote_version_test.go @@ -0,0 +1,59 @@ +package updater + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRemoteVersionURLs(t *testing.T) { + urls, err := remoteVersionURLs("http://example.com/mrlee/-Fonu.git", "main") + if err != nil { + t.Fatal(err) + } + if len(urls) == 0 || !strings.Contains(urls[0], "/api/v1/repos/mrlee/-Fonu/contents/VERSION") { + t.Fatalf("缺少 Gitea API 候选地址: %#v", urls) + } + if !strings.Contains(urls[1], "/mrlee/-Fonu/raw/branch/main/VERSION") { + t.Fatalf("缺少 Gitea raw 候选地址: %#v", urls) + } +} + +func TestParseRemoteVersion(t *testing.T) { + if got, err := parseRemoteVersion([]byte("1.3.2\n")); err != nil || got != "1.3.2" { + t.Fatalf("文本版本解析失败: got=%q err=%v", got, err) + } + payload, _ := json.Marshal(map[string]string{ + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("1.3.2\n")), + }) + if got, err := parseRemoteVersion(payload); err != nil || got != "1.3.2" { + t.Fatalf("JSON 版本解析失败: got=%q err=%v", got, err) + } +} + +func TestFetchRemoteVersionFromGiteaAPI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/repos/mrlee/-Fonu/contents/VERSION" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{ + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("1.3.2\n")), + }) + })) + defer server.Close() + + got, err := fetchRemoteVersion(context.Background(), server.URL+"/mrlee/-Fonu.git", "main") + if err != nil { + t.Fatal(err) + } + if got != "1.3.2" { + t.Fatalf("got %q, want 1.3.2", got) + } +} diff --git a/internal/updater/runner.go b/internal/updater/runner.go index 49460c7..d2da593 100644 --- a/internal/updater/runner.go +++ b/internal/updater/runner.go @@ -2,10 +2,12 @@ package updater import ( "context" + "encoding/base64" "encoding/json" "fmt" "io" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -123,48 +125,136 @@ func (s *Server) prepareLocalSource(ctx context.Context) error { return fmt.Errorf("本地源码存在未提交改动,已拒绝自动拉取") } s.logf("拉取远端源码") - if _, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "pull", "--ff-only"); err != nil { + if _, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "pull", "--ff-only", "origin", s.cfg.Branch); err != nil { return fmt.Errorf("git pull 失败:%w", err) } return nil } func (s *Server) latestVersion(ctx context.Context) (string, error) { - if s.cfg.Mode == "git" { - return s.latestVersionFromGitClone(ctx) + repoURL := strings.TrimSpace(s.cfg.RepoURL) + if s.cfg.Mode == "local" { + if remote, err := s.localRemoteURL(ctx); err == nil && strings.TrimSpace(remote) != "" { + repoURL = remote + } } - return s.latestVersionFromLocal(ctx) -} - -func (s *Server) latestVersionFromLocal(ctx context.Context) (string, error) { localPath := filepath.Join(s.cfg.ProjectDir, "VERSION") - if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil { - return readVersionFile(localPath) + if repoURL != "" { + version, err := fetchRemoteVersion(ctx, repoURL, s.cfg.Branch) + if err == nil { + return version, nil + } + if s.cfg.Mode == "git" { + return "", err + } + s.logf("读取远端版本失败,改用本地 VERSION:%v", err) } - fetchCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) - defer cancel() - if _, err := runCommand(fetchCtx, s.cfg.ProjectDir, nil, "git", "fetch", "origin", s.cfg.Branch, "--prune"); err != nil { - return readVersionFile(localPath) - } - value, err := runCommand(ctx, s.cfg.ProjectDir, nil, "git", "show", "origin/"+s.cfg.Branch+":VERSION") - if err != nil { - return readVersionFile(localPath) - } - return strings.TrimSpace(value), nil + return readVersionFile(localPath) } -func (s *Server) latestVersionFromGitClone(ctx context.Context) (string, error) { - dir, err := os.MkdirTemp("", "fonu-update-*") +func (s *Server) localRemoteURL(ctx context.Context) (string, error) { + if _, err := os.Stat(filepath.Join(s.cfg.ProjectDir, ".git")); err != nil { + return "", err + } + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + output, err := runCommand(checkCtx, s.cfg.ProjectDir, nil, "git", "config", "--get", "remote.origin.url") if err != nil { return "", err } - defer os.RemoveAll(dir) - cloneCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) - defer cancel() - if _, err := runCommand(cloneCtx, "", nil, "git", "clone", "--depth", "1", "--branch", s.cfg.Branch, s.cfg.RepoURL, dir); err != nil { - return "", fmt.Errorf("读取远端版本失败:%w", err) + return strings.TrimSpace(output), nil +} + +// fetchRemoteVersion 通过仓库 raw / API 地址读取 VERSION,避免为了检查版本克隆整个仓库。 +func fetchRemoteVersion(ctx context.Context, repoURL, branch string) (string, error) { + urls, err := remoteVersionURLs(repoURL, branch) + if err != nil { + return "", err } - return readVersionFile(filepath.Join(dir, "VERSION")) + var lastErr error + for _, candidate := range urls { + version, err := fetchVersionURL(ctx, candidate) + if err == nil { + return version, nil + } + lastErr = err + } + return "", fmt.Errorf("读取远端版本失败:%w", lastErr) +} + +func remoteVersionURLs(repoURL, branch string) ([]string, error) { + parsed, err := url.Parse(strings.TrimSpace(repoURL)) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("仓库地址无效:%s", repoURL) + } + branch = strings.TrimSpace(branch) + if branch == "" { + branch = "main" + } + repoPath := strings.Trim(strings.TrimSuffix(parsed.Path, ".git"), "/") + if repoPath == "" { + return nil, fmt.Errorf("仓库地址缺少路径:%s", repoURL) + } + base := parsed.Scheme + "://" + parsed.Host + urls := []string{ + fmt.Sprintf("%s/api/v1/repos/%s/contents/VERSION?ref=%s", base, repoPath, url.QueryEscape(branch)), + fmt.Sprintf("%s/%s/raw/branch/%s/VERSION", base, repoPath, url.PathEscape(branch)), + fmt.Sprintf("%s/%s/raw/%s/VERSION", base, repoPath, url.PathEscape(branch)), + } + if strings.EqualFold(parsed.Hostname(), "github.com") { + urls = append([]string{fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/VERSION", repoPath, url.PathEscape(branch))}, urls...) + } + return urls, nil +} + +func fetchVersionURL(ctx context.Context, rawURL string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("%s: HTTP %d", rawURL, resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", err + } + version, err := parseRemoteVersion(data) + if err != nil { + return "", fmt.Errorf("%s: %w", rawURL, err) + } + return version, nil +} + +func parseRemoteVersion(data []byte) (string, error) { + text := strings.TrimSpace(string(data)) + if strings.HasPrefix(text, "{") { + var payload struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + DownloadURL string `json:"download_url"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return "", err + } + if payload.Encoding == "base64" && payload.Content != "" { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(payload.Content)) + if err != nil { + return "", err + } + text = strings.TrimSpace(string(decoded)) + } + } + if err := validateSemver(text); err != nil { + return "", err + } + return text, nil } func readVersionFile(path string) (string, error) { diff --git a/web/package-lock.json b/web/package-lock.json index 6629ad7..838389b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "fonu-web", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fonu-web", - "version": "1.3.1", + "version": "1.3.2", "dependencies": { "@codemirror/commands": "^6.11.1", "@codemirror/lang-javascript": "^6.2.5", diff --git a/web/package.json b/web/package.json index faf6ff8..6ef0bce 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "fonu-web", "private": true, - "version": "1.3.1", + "version": "1.3.2", "packageManager": "npm@10.9.4", "type": "module", "scripts": {