From e8a5392b377eaa56b2716793ce048b5ba868b996 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:58:12 +0800 Subject: [PATCH] fix: serialize installation updates and recover interrupted adoption and rollback --- .gitignore | 3 + cmd/server/build_scripts_test.go | 31 +++ cmd/server/update_cli.go | 3 + internal/config/config.go | 86 ++++++- internal/config/config_test.go | 59 ++++- internal/handler/config.go | 9 +- internal/handler/update.go | 53 ++++- internal/handler/update_http_test.go | 45 ++++ internal/update/adopt.go | 118 +++++++-- internal/update/adopt_test.go | 21 ++ internal/update/apply.go | 260 +++++++++++++++----- internal/update/build.go | 8 +- internal/update/lock.go | 28 +++ internal/update/lock_other.go | 12 + internal/update/lock_unix.go | 10 + internal/update/lock_windows.go | 10 + internal/update/paths.go | 33 +++ internal/update/pending_build_test.go | 4 +- internal/update/replace_unix.go | 7 + internal/update/replace_windows.go | 15 ++ internal/update/rollback_pending.go | 98 ++++++++ internal/update/safety_test.go | 330 ++++++++++++++++++++++++++ internal/update/update.go | 83 +++++-- internal/update/version.go | 15 +- upgrade.sh | 15 +- web/static/i18n/en-US.json | 1 + web/static/i18n/ru-RU.json | 1 + web/static/i18n/zh-CN.json | 1 + web/static/js/update-ui.test.cjs | 11 + web/static/js/update.js | 4 + 30 files changed, 1250 insertions(+), 124 deletions(-) create mode 100644 internal/update/lock.go create mode 100644 internal/update/lock_other.go create mode 100644 internal/update/lock_unix.go create mode 100644 internal/update/lock_windows.go create mode 100644 internal/update/paths.go create mode 100644 internal/update/replace_unix.go create mode 100644 internal/update/replace_windows.go create mode 100644 internal/update/rollback_pending.go create mode 100644 internal/update/safety_test.go diff --git a/.gitignore b/.gitignore index ed0dd309..921ce4e4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ coverage.out coverage.html # One-click update artifacts (internal/update): machine-local, written at the install root +.update-lock +.update-rollback-pending.json +.update-rollback-pending.json.tmp .update-staging/ .update-backup/ .update-state.json diff --git a/cmd/server/build_scripts_test.go b/cmd/server/build_scripts_test.go index bdd7de94..85ad51b5 100644 --- a/cmd/server/build_scripts_test.go +++ b/cmd/server/build_scripts_test.go @@ -128,6 +128,37 @@ func TestUpgradeScriptUpdateConfigVersionPreservesDocumentAndMode(t *testing.T) } } +func TestUpgradeScriptVersionRefusesUnsupportedYAML(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("upgrade.sh requires a POSIX shell") + } + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 unavailable") + } + script, err := os.ReadFile(filepath.Join("..", "..", "upgrade.sh")) + if err != nil { + t.Fatal(err) + } + function := extractShellFunction(t, string(script), "update_config_version") + for _, original := range []string{ + "--- {server: {port: 8088}}\n", + "{server: {port: 8088}}\n", + "version: \"old\n version\"\nserver:\n port: 8088\n", + } { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + command := function + "\nCONFIG_FILE=$1\nupdate_config_version v1.2.3\n" + if output, err := exec.Command("bash", "-c", command, "test", path).CombinedOutput(); err != nil { + t.Fatalf("writer failed: %v %s", err, output) + } + if got, err := os.ReadFile(path); err != nil || string(got) != original { + t.Fatalf("unsupported YAML was changed: %q %v", got, err) + } + } +} + // The tarball path syncs with `rsync --delete`, so every artifact a one-click update writes // at the install root has to be on its keep list. .gitignore is exactly that list of // machine-local artifacts, so the assertion is derived from it: a new marker cannot be diff --git a/cmd/server/update_cli.go b/cmd/server/update_cli.go index a3f7c23d..523934cb 100644 --- a/cmd/server/update_cli.go +++ b/cmd/server/update_cli.go @@ -45,6 +45,9 @@ func updateCommandIfNeeded(configPath string, check, apply, rollback bool) (bool return true, 1 } printSnapshot(snap) + if !snap.Installed || snap.CheckError != "" { + return true, 1 + } return true, 0 case apply: res, err := update.Apply(ctx, opts, func(s update.Step) { diff --git a/internal/config/config.go b/internal/config/config.go index 968e29f6..dc5c9165 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "regexp" "strconv" "strings" @@ -1821,6 +1822,11 @@ func WriteVersion(path, version string) (bool, error) { if len(version) > 64 || !versionValuePattern.MatchString(version) { return false, fmt.Errorf("版本号不合法:%q", version) } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return false, err + } + path = resolved data, err := os.ReadFile(path) if err != nil { return false, err @@ -1860,18 +1866,88 @@ func WriteVersion(path, version string) (bool, error) { if got, err := parseTopVersion([]byte(out)); err != nil || got != version { return false, fmt.Errorf("version 的写法无法就地替换(改后配置解析失败或读回为 %q),已放弃写入,原文件未改动", got) } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, []byte(out), info.Mode().Perm()); err != nil { + // Reading back version alone is insufficient: inserting before an inline + // document marker can create a new first document and hide every setting. + var originalSettings, nextSettings map[string]interface{} + if err := yaml.Unmarshal(data, &originalSettings); err != nil { return false, err } - if err := os.Chmod(tmp, info.Mode().Perm()); err != nil { - _ = os.Remove(tmp) + if err := yaml.Unmarshal([]byte(out), &nextSettings); err != nil { return false, err } - if err := os.Rename(tmp, path); err != nil { + delete(originalSettings, "version") + delete(nextSettings, "version") + if len(originalSettings) != len(nextSettings) || (len(originalSettings) > 0 && !reflect.DeepEqual(originalSettings, nextSettings)) { + return false, fmt.Errorf("版本号写入会改变其它配置,已放弃写入,原文件未改动") + } + if err := writeVersionFile(path, []byte(out), info.Mode().Perm()); err != nil { return false, err } return true, nil + +} + +// RemoveVersion undoes a version field added by an update to an unversioned +// configuration. All other settings and the file's permissions stay intact. +func RemoveVersion(path string) (bool, error) { + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return false, err + } + path = resolved + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + info, err := os.Stat(path) + if err != nil { + return false, err + } + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if !topVersionKey(line) { + continue + } + out := strings.Join(append(lines[:i], lines[i+1:]...), "\n") + var before, after map[string]interface{} + if err := yaml.Unmarshal(data, &before); err != nil { + return false, err + } + if err := yaml.Unmarshal([]byte(out), &after); err != nil { + return false, err + } + delete(before, "version") + if _, present := after["version"]; present || len(before) != len(after) || (len(before) > 0 && !reflect.DeepEqual(before, after)) { + return false, fmt.Errorf("无法安全移除版本号,原配置未改动") + } + if err := writeVersionFile(path, []byte(out), info.Mode().Perm()); err != nil { + return false, err + } + return true, nil + } + return false, nil +} + +func writeVersionFile(path string, data []byte, mode os.FileMode) error { + f, err := os.CreateTemp(filepath.Dir(path), ".version-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if err := f.Chmod(mode); err != nil { + return err + } + if _, err := f.Write(data); err != nil { + return err + } + if err := f.Sync(); err != nil { + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), path) } func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5ba181b3..97a99c17 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -388,6 +389,10 @@ func TestFileVersionAndWriteVersion(t *testing.T) { if err := os.Chmod(path, 0o640); err != nil { t.Fatal(err) } + originalInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } if v, err := FileVersion(path); err != nil || v != "v1.0.0" { t.Fatalf("version = %q err = %v", v, err) } @@ -401,7 +406,7 @@ func TestFileVersionAndWriteVersion(t *testing.T) { if got != want { t.Fatalf("file after write:\n%q\nwant:\n%q", got, want) } - if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o640 { + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != originalInfo.Mode().Perm() { t.Fatalf("mode = %v err = %v, want 0640 preserved", info.Mode(), err) } @@ -437,6 +442,11 @@ func TestWriteVersionPreservesYAMLDocumentStart(t *testing.T) { t.Fatal(err) } + originalInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + changed, err := WriteVersion(path, "v1.2.3") if err != nil || !changed { t.Fatalf("WriteVersion: changed=%v err=%v", changed, err) @@ -463,7 +473,7 @@ func TestWriteVersionPreservesYAMLDocumentStart(t *testing.T) { if err != nil { t.Fatal(err) } - if info.Mode().Perm() != originalMode { + if info.Mode().Perm() != originalInfo.Mode().Perm() { t.Fatalf("mode = %v, want %v preserved", info.Mode().Perm(), originalMode) } } @@ -554,3 +564,48 @@ func TestWriteVersionAfterCommentedDocumentStart(t *testing.T) { }) } } + +func TestWriteVersionRefusesToHideAnInlineYAMLDocument(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + original := "--- {server: {port: 8088}}\n" + if err := os.WriteFile(path, []byte(original), 0600); err != nil { + t.Fatal(err) + } + if changed, err := WriteVersion(path, "v1.2.3"); err == nil || changed { + t.Fatalf("must refuse document loss: changed=%v err=%v", changed, err) + } + if got := readTestFile(t, path); got != original { + t.Fatal("refusal modified config") + } +} + +func TestVersionWritePreservesConfigSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires privileges on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "actual.yaml") + link := filepath.Join(dir, "config.yaml") + original := "server:\n port: 8088\n" + if err := os.WriteFile(target, []byte(original), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if _, err := WriteVersion(link, "v1.2.3"); err != nil { + t.Fatal(err) + } + if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatal("version write replaced config symlink") + } + if version, err := FileVersion(target); err != nil || version != "v1.2.3" { + t.Fatal("version did not reach symlink target") + } + if _, err := RemoveVersion(link); err != nil { + t.Fatal(err) + } + if got := readTestFile(t, target); got != original { + t.Fatal("version removal changed original settings") + } +} diff --git a/internal/handler/config.go b/internal/handler/config.go index 8159a4cc..7176b959 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1916,9 +1916,14 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { // 走与其他设置相同的写入路径:备份旧文件、按 YAML 文档只改 update 段、原子落盘。 func (h *ConfigHandler) SetUpdateSource(repo string) error { h.mu.Lock() + defer h.mu.Unlock() + previous := h.config.Update h.config.Update = config.UpdateConfig{Repo: strings.TrimSpace(repo)} - h.mu.Unlock() - return h.saveConfig() + if err := h.saveConfig(); err != nil { + h.config.Update = previous + return err + } + return nil } // UpdateSource 读当前配置的更新源地址(加读锁;更新处理器每次请求现读,保存后立即生效)。 diff --git a/internal/handler/update.go b/internal/handler/update.go index 3e33a661..3cdcfce6 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -198,6 +198,16 @@ func (h *UpdateHandler) SaveSource(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "本次启动没有接入配置保存,更新源改不了"}) return } + release, err := update.LockInstall(h.options()) + if err != nil { + c.JSON(statusForError(err), errorBody(err)) + return + } + defer release() + if busy := h.activeJob(); busy != nil { + c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,不能切换更新源", "job": busy}) + return + } if err := h.saveSource(body.Repo); err != nil { h.record(c, "update.source", "failure", "保存更新源失败: "+err.Error(), map[string]interface{}{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()}) @@ -281,6 +291,17 @@ func (h *UpdateHandler) Apply(c *gin.Context) { // answering requests. It refuses when there is nothing to activate, because a bounce that // cannot change anything is a dropped service for no reason. func (h *UpdateHandler) Restart(c *gin.Context) { + release, err := update.LockInstall(h.options()) + if err != nil { + c.JSON(statusForError(err), errorBody(err)) + return + } + scheduled := false + defer func() { + if !scheduled { + release() + } + }() if busy := h.activeJob(); busy != nil { c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,等它结束再重启", "job": busy}) return @@ -293,9 +314,14 @@ func (h *UpdateHandler) Restart(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制与当前进程一致,没有待生效的版本", "reason": "nothing_pending"}) return } + if !update.StampBinary(h.root, h.options()).Exists { + c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"}) + return + } h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil) c.JSON(http.StatusAccepted, gin.H{"restarting": true}) - time.AfterFunc(updateRestartDelay, h.restart) + scheduled = true + time.AfterFunc(updateRestartDelay, func() { defer release(); h.restart() }) } // Job answers GET /api/system/update/job: the running job, or the most recent one, with @@ -347,6 +373,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) { } h.active = job.ID snapshot := job.view() + opts := h.options() h.mu.Unlock() // The update outlives its HTTP request on purpose: the caller gets a handle, and a @@ -369,7 +396,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) { if kind == "adopt" { run = update.Adopt } - res, err := run(ctx, h.options(), onStep) + res, err := run(ctx, opts, onStep) finish := "succeeded" if err != nil { finish = "failed" @@ -385,12 +412,20 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) { job.State = finish job.Finished = time.Now().Format(time.RFC3339) if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.restart != nil { - h.active = "" - h.mu.Unlock() - // Give the polling page one more chance to read the final state before the - // process it is watching goes away. - time.AfterFunc(updateRestartDelay, h.restart) - return + release, lockErr := update.LockInstall(opts) + if lockErr == nil { + h.mu.Unlock() + // Keep both CLI and HTTP writers out until the old process exits. + time.AfterFunc(updateRestartDelay, func() { + defer release() + defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }() + h.restart() + }) + return + } + // Another process began an operation after the build. The binary is + // ready, but shutting down now would interrupt that operation. + job.Restart = false } h.active = "" h.mu.Unlock() @@ -480,7 +515,7 @@ func statusForError(err error) int { return http.StatusInternalServerError } switch ue.Reason { - case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary", "already_a_repo": + case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary", "already_a_repo", "update_busy", "content_path_conflict", "existing_git_metadata": return http.StatusConflict default: return http.StatusBadRequest diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index 1d10ad2c..3f26cd76 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "cyberstrike-ai/internal/config" "cyberstrike-ai/internal/update" "github.com/gin-gonic/gin" @@ -389,3 +390,47 @@ func TestUpdateRestartRefusesWithoutAHookOrDuringAJob(t *testing.T) { t.Errorf("the conflict must hand back the job that owns the tree: %s", w.Body) } } + +func TestUpdateSourceSaveAndRestartRespectInstallLock(t *testing.T) { + root := t.TempDir() + saved := false + router, _ := newUpdateRouterWithSource(root, func() {}, nil, func(string) error { saved = true; return nil }) + release, err := update.LockInstall(update.Options{Root: root}) + if err != nil { + t.Fatal(err) + } + defer release() + for _, endpoint := range []string{"source", "restart"} { + w := doUpdate(router, http.MethodPost, "/api/system/update/"+endpoint, `{"repo":""}`) + if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "update_busy") { + t.Fatalf("%s did not respect CLI lock: %d %s", endpoint, w.Code, w.Body) + } + } + if saved { + t.Fatal("source changed while another operation owned the installation") + } +} + +func TestUpdateRestartRefusesMissingExecutable(t *testing.T) { + root := t.TempDir() + writeFakeBinary(t, root, "old") + router, _ := newUpdateRouter(root, func() { t.Error("missing binary must not cause shutdown") }) + if err := os.Remove(filepath.Join(root, update.Binary)); err != nil { + t.Fatal(err) + } + w := doUpdate(router, http.MethodPost, "/api/system/update/restart", "") + if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "no_binary") { + t.Fatalf("restart accepted a missing executable: %d %s", w.Code, w.Body) + } +} + +func TestFailedUpdateSourceWriteKeepsActiveSource(t *testing.T) { + const original = "https://github.com/AIPentest/CyberStrikeAI.git" + h := &ConfigHandler{configPath: filepath.Join(t.TempDir(), "missing.yaml"), config: &config.Config{Update: config.UpdateConfig{Repo: original}}} + if err := h.SetUpdateSource("https://example.com/another.git"); err == nil { + t.Fatal("missing config must refuse the write") + } + if got := h.UpdateSource(); got != original { + t.Fatalf("failed save changed live update source to %q", got) + } +} diff --git a/internal/update/adopt.go b/internal/update/adopt.go index fa14cf3f..11488521 100644 --- a/internal/update/adopt.go +++ b/internal/update/adopt.go @@ -1,6 +1,7 @@ package update import ( + "bytes" "context" "crypto/sha1" "encoding/hex" @@ -8,11 +9,61 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strings" "time" ) +// recoverAdoption resumes a checkout interrupted before the directory had a +// usable HEAD. It only repeats a forced checkout if every differing local file +// still matches its recorded backup; later operator edits are never overwritten. +func recoverAdoption(ctx context.Context, opts Options) error { + root, err := opts.installRoot() + if err != nil { + return err + } + pending, present, err := readBuildPending(root) + if err != nil { + return &Error{Reason: "state_unreadable", Message: "无法读取接入恢复记录:" + err.Error()} + } + if !present || pending.AdoptBranch == "" { + return nil + } + if !ValidName(pending.AdoptBranch) { + return &Error{Reason: "bad_state", Message: "接入恢复记录的分支不合法"} + } + head, headErr := gitCmd(ctx, root, "rev-parse", "HEAD") + if headErr != nil { + scan, err := scanAdoption(ctx, root, root, pending.Commit) + if err != nil { + return err + } + for _, rel := range append(scan.changed, scan.kept...) { + backup := filepath.Join(pending.BackupDir, "overwritten", filepath.FromSlash(rel)) + if Protected(rel) { + backup = filepath.Join(pending.BackupDir, filepath.FromSlash(rel)) + } + old, oldErr := os.ReadFile(backup) + current, currentErr := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) + if oldErr != nil || currentErr != nil || !bytes.Equal(old, current) { + return &Error{Reason: "local_source_edits", Message: "接入中断后本地文件发生变化,已保留现场:" + rel} + } + } + if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", pending.AdoptBranch, pending.Commit); err != nil { + return err + } + } else if head != pending.Commit { + return &Error{Reason: "build_state_mismatch", Message: "接入中断后 HEAD 发生变化,已保留现场"} + } + if err := restoreInterruptedProtected(ctx, root, pending, true); err != nil { + return err + } + pending.AdoptBranch = "" + pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) + return writeBuildPending(root, pending) +} + // Adopt connects a plain directory - a tarball or zip installation, or a copy carried in by // hand - to the configured update source, so it becomes a git work tree the one-click // update applies to from then on. @@ -84,6 +135,11 @@ func Preview(ctx context.Context, opts Options) (*AdoptPlan, error) { // (the preview's throwaway fetch cannot be reused), and on any failure removes only the // .git it created itself, so a retry starts from the same pristine state. func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) { + release, err := LockInstall(opts) + if err != nil { + return nil, err + } + defer release() if onStep == nil { onStep = func(Step) {} } @@ -122,13 +178,19 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error step("fetch", fmt.Sprintf("%s/%s(%s %s)共 %d 个文件:%d 个本机版本会被目标版本替换,%d 个运维者文件先暂存再放回", source, branch, commit, subject, len(scan.paths), len(scan.changed), len(scan.kept))) - backupDir := filepath.Join(root, ".update-backup", time.Now().Format("20060102_150405")) + backupDir := "" backupAll := append(append([]string{}, scan.changed...), scan.kept...) + if len(backupAll) > 0 { + backupDir, err = newBackupDir(root, time.Now().Format("20060102_150405")) + if err != nil { + return nil, err + } + } for _, p := range backupAll { abs := filepath.Join(root, filepath.FromSlash(p)) info, statErr := os.Stat(abs) if statErr != nil || !info.Mode().IsRegular() { - continue + return nil, &Error{Reason: "backup_failed", Message: "备份前本地文件发生变化,未执行接入:" + p} } dst := filepath.Join(backupDir, "overwritten", filepath.FromSlash(p)) if Protected(p) { @@ -147,9 +209,12 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error return nil, err } pending := buildPendingState{ - Commit: targetCommit, - BackupDir: backupDir, - KeptContent: append([]string(nil), scan.kept...), + Commit: targetCommit, + BackupDir: backupDir, + KeptContent: append([]string(nil), scan.kept...), + OverwrittenContent: append([]string(nil), scan.changed...), + AdoptBranch: branch, + PreviousBinaryAbsent: !fileExists(filepath.Join(root, opts.binaryName())), } if opts.binaryName() != "none" { bin := filepath.Join(root, opts.binaryName()) @@ -160,25 +225,22 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error } } } - if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", branch, ref); err != nil { - return nil, &Error{Reason: "checkout_failed", Message: "落地目标内容失败:" + err.Error()} + // Record both the target and backups before checkout can replace any file. + if err := writeBuildPending(root, pending); err != nil { + return nil, &Error{Reason: "state_unwritable", Message: "无法写入接入恢复状态:" + err.Error()} } - // The source has landed. Keep the repository from here onward so any failure can be - // resumed by the normal update path instead of hiding the moved tree by deleting .git. adopted = true - // The connected code carries its own release version and the live config gets it below; - // the pair is recorded in the marker first so a crash during that write still leaves a - // recovery record that can put the old number back. - pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) - if opts.binaryName() != "none" { - if err := writeBuildPending(root, pending); err != nil { - _ = restoreProtected(backupDir, scan.kept) - return nil, &Error{Reason: "state_unwritable", Message: "无法写入待编译状态:" + err.Error()} - } + if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", branch, targetCommit); err != nil { + return nil, &Error{Reason: "checkout_failed", Message: "落地目标内容失败,已保留恢复记录,再次更新可恢复:" + err.Error()} } if err := restoreProtected(backupDir, scan.kept); err != nil { return nil, err } + pending.AdoptBranch = "" + pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) + if err := writeBuildPending(root, pending); err != nil { + return nil, &Error{Reason: "state_unwritable", Message: "无法更新接入恢复状态:" + err.Error()} + } if _, err := gitCmd(ctx, root, "branch", "--set-upstream-to="+ref); err != nil { return nil, &Error{Reason: "upstream_failed", Message: "设置分支跟踪失败:" + err.Error()} } @@ -197,6 +259,9 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error } if opts.binaryName() == "none" { + if err := clearBuildPending(root); err != nil { + return res, err + } step("done", fmt.Sprintf("目录已接入 %s/%s(%s);按请求跳过重新编译", source, branch, commit)) res.Duration = time.Since(start).Round(time.Millisecond).String() return res, nil @@ -238,6 +303,9 @@ func adoptPreflight(ctx context.Context, opts Options) (root, source string, err if fi, statErr := os.Stat(root); statErr != nil || !fi.IsDir() { return "", "", &Error{Reason: "bad_root", Message: "安装目录不存在:" + root} } + if _, statErr := os.Lstat(filepath.Join(root, ".git")); !os.IsNotExist(statErr) { + return "", "", &Error{Reason: "existing_git_metadata", Message: "安装目录已存在 .git 元数据,接入不会覆盖它;请先人工检查仓库状态"} + } source = opts.repoURL() if !ValidRemoteURL(source) { return "", "", &Error{Reason: "bad_source", Message: "更新源仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"} @@ -277,6 +345,7 @@ func scanAdoption(ctx context.Context, root, repo, ref string) (*adoptionScan, e return nil, &Error{Reason: "diff_failed", Message: err.Error()} } scan := &adoptionScan{target: map[string]string{}} + targetModes := map[string]os.FileMode{} for _, rec := range nulList(out) { tab := strings.IndexByte(rec, '\t') if tab < 0 { @@ -288,17 +357,28 @@ func scanAdoption(ctx context.Context, root, repo, ref string) (*adoptionScan, e } p := rec[tab+1:] scan.target[p] = fields[2] + targetModes[p] = 0644 + if fields[0] == "100755" { + targetModes[p] = 0755 + } scan.paths = append(scan.paths, p) } sort.Strings(scan.paths) for _, p := range scan.paths { + if err := validateFilePath(root, p); err != nil { + return nil, err + } abs := filepath.Join(root, filepath.FromSlash(p)) info, err := os.Lstat(abs) if err != nil || !info.Mode().IsRegular() { continue } hash, err := gitBlobHash(abs) - if err != nil || hash == scan.target[p] { + if err != nil { + return nil, &Error{Reason: "backup_failed", Message: "无法读取待接入的本地文件,未修改目录:" + p + ": " + err.Error()} + } + modeChanged := runtime.GOOS != "windows" && Protected(p) && info.Mode().Perm() != targetModes[p] + if hash == scan.target[p] && !modeChanged { continue } if Protected(p) { diff --git a/internal/update/adopt_test.go b/internal/update/adopt_test.go index d033e9df..3ffdcd80 100644 --- a/internal/update/adopt_test.go +++ b/internal/update/adopt_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -162,3 +163,23 @@ func asReason(err error) string { } return "" } + +func TestAdoptPreservesModeOfUnchangedProtectedFile(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX permissions are not available on Windows") + } + requireGit(t) + tr, dir := newAdoptFixture(t) + role := filepath.Join(dir, "roles", "shipped.yaml") + writeFile(t, role, readFile(t, filepath.Join(tr.upstream, "roles", "shipped.yaml"))) + if err := os.Chmod(role, 0600); err != nil { + t.Fatal(err) + } + if _, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil); err != nil { + t.Fatal(err) + } + info, err := os.Stat(role) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("adoption lost private permissions: %v %v", info, err) + } +} diff --git a/internal/update/apply.go b/internal/update/apply.go index f0ffc352..8b7f2ddf 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -28,6 +28,7 @@ type State struct { BinaryCommit string `json:"binary_commit,omitempty"` BinarySHA256 string `json:"binary_sha256,omitempty"` PreviousBinarySHA256 string `json:"previous_binary_sha256,omitempty"` + PreviousBinaryAbsent bool `json:"previous_binary_absent,omitempty"` // VersionBefore/VersionAfter record a version-line change this update made, so a // rollback can put the old number back next to the old code. Absent for updates that // touched no version (old state files simply have no such fields). @@ -50,10 +51,14 @@ type buildPendingState struct { PreviousCommit string `json:"previous_commit,omitempty"` BackupDir string `json:"backup_dir,omitempty"` KeptContent []string `json:"kept_content,omitempty"` + DeletedContent []string `json:"deleted_content,omitempty"` VersionBefore string `json:"version_before,omitempty"` VersionAfter string `json:"version_after,omitempty"` BinarySHA256 string `json:"binary_sha256,omitempty"` PreviousBinarySHA256 string `json:"previous_binary_sha256,omitempty"` + AdoptBranch string `json:"adopt_branch,omitempty"` + OverwrittenContent []string `json:"overwritten_content,omitempty"` + PreviousBinaryAbsent bool `json:"previous_binary_absent,omitempty"` Legacy bool `json:"-"` } @@ -173,12 +178,27 @@ func (e *Error) Error() string { return e.Message } // failed build leaves the tree updated (source and previous binary are both recoverable), // while a refused precondition leaves nothing touched at all. func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) { + release, err := LockInstall(opts) + if err != nil { + return nil, err + } + defer release() if onStep == nil { onStep = func(Step) {} } step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) } - snap, err := Check(ctx, opts) + root, rootErr := opts.installRoot() + if rootErr != nil { + return nil, rootErr + } + if pending, err := readRollbackPending(root); err != nil || pending != nil { + return nil, &Error{Reason: "rollback_pending", Message: "上一次回滚尚未完成,请先重试回滚,再执行更新"} + } + if err := recoverAdoption(ctx, opts); err != nil { + return nil, err + } + snap, err := check(ctx, opts) if err != nil { return nil, err } @@ -241,9 +261,9 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error // write to, so the fast-forward cannot be refused by "your local changes would be // overwritten". Nothing here deletes a file the operator made: every path is copied // out first and copied back after. - root := snap.Root + root = snap.Root ref := snap.sourceRef() - backupDir, kept, err := stashProtected(ctx, root, ref) + backupDir, kept, deleted, err := stashProtected(ctx, root, ref) if err != nil { return nil, err } @@ -265,7 +285,9 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error PreviousCommit: previousCommit, BackupDir: backupDir, KeptContent: kept, + DeletedContent: deleted, } + pending.PreviousBinaryAbsent = !fileExists(bin) if opts.binaryName() != "none" && fileExists(bin) { pending.PreviousBinarySHA256, err = fileSHA256(bin) if err != nil { @@ -299,6 +321,10 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error return nil, err } + if err := restoreDeleted(root, deleted); err != nil { + return nil, err + } + files, _ := gitRaw(ctx, root, "diff", "--name-only", "-z", previousCommit+".."+updatedCommit) res.FromCommit = snap.Commit res.ToCommit = snap.RemoteCommit @@ -332,6 +358,7 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error state.BinaryCommit = previousCommit state.BinarySHA256 = pending.PreviousBinarySHA256 state.PreviousBinarySHA256 = pending.PreviousBinarySHA256 + state.PreviousBinaryAbsent = pending.PreviousBinaryAbsent } if err := writeState(root, state); err != nil { return res, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()} @@ -391,6 +418,7 @@ func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res * pending.VersionBefore = state.VersionBefore pending.VersionAfter = state.VersionAfter pending.PreviousBinarySHA256 = state.PreviousBinarySHA256 + pending.PreviousBinaryAbsent = state.PreviousBinaryAbsent if state.BinaryCommit != "" { binaryCommit, resolveErr := gitCmd(ctx, snap.Root, "rev-parse", state.BinaryCommit) previous, previousErr := gitCmd(ctx, snap.Root, "rev-parse", state.PreviousCommit) @@ -481,6 +509,7 @@ func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res * BinaryCommit: target, BinarySHA256: currentHash, PreviousBinarySHA256: pending.PreviousBinarySHA256, + PreviousBinaryAbsent: pending.PreviousBinaryAbsent, VersionBefore: pending.VersionBefore, VersionAfter: pending.VersionAfter, } @@ -515,6 +544,7 @@ func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res * BinaryCommit: pending.PreviousCommit, BinarySHA256: pending.PreviousBinarySHA256, PreviousBinarySHA256: pending.PreviousBinarySHA256, + PreviousBinaryAbsent: pending.PreviousBinaryAbsent, VersionBefore: pending.VersionBefore, VersionAfter: pending.VersionAfter, } @@ -534,6 +564,9 @@ func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res * // a retry can prove whether the swap happened instead of doing it twice. func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, step func(string, string), commit string, state *State, pending buildPendingState) error { step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)") + if err := verifyBuildSource(ctx, root, commit); err != nil { + return err + } staging := filepath.Join(root, ".update-staging") if err := os.MkdirAll(staging, 0o755); err != nil { return &Error{Reason: "staging_failed", Message: err.Error()} @@ -544,6 +577,9 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s if err := build(ctx, root, newBin); err != nil { return &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()} } + if err := verifyBuildSource(ctx, root, commit); err != nil { + return err + } newHash, err := fileSHA256(newBin) if err != nil { return &Error{Reason: "build_failed", Message: "无法校验新二进制:" + err.Error()} @@ -576,7 +612,7 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s // A legacy marker without hashes is ambiguous when a rebuild differs from the // installed binary. Only a clean VCS stamp for the previous commit can prove // that another swap will preserve the right rollback binary. - if (pending.Legacy || pending.PreviousCommit != "") && pending.PreviousBinarySHA256 == "" { + if (pending.Legacy || pending.PreviousCommit != "") && pending.PreviousBinarySHA256 == "" && !pending.PreviousBinaryAbsent { revision := binaryRevision(bin) previous, resolveErr := gitCmd(ctx, root, "rev-parse", pending.PreviousCommit) if revision == "" || resolveErr != nil || revision != previous { @@ -610,6 +646,7 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s state.BinaryCommit = commit state.BinarySHA256 = newHash state.PreviousBinarySHA256 = pending.PreviousBinarySHA256 + state.PreviousBinaryAbsent = pending.PreviousBinaryAbsent if err := writeState(root, *state); err != nil { return &Error{Reason: "state_unwritable", Message: "二进制已换入,但无法更新回滚状态:" + err.Error()} } @@ -623,49 +660,70 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s // stashProtected copies every operator-owned file that stands in the way of the // fast-forward into backupDir. The caller persists the recovery record before removing // these paths, so a crash can never strand content without saying where its copy lives. -func stashProtected(ctx context.Context, root, ref string) (backupDir string, kept []string, err error) { +func stashProtected(ctx context.Context, root, ref string) (backupDir string, kept, deleted []string, err error) { ts := time.Now().Format("20060102_150405") - backupDir = filepath.Join(root, ".update-backup", ts) + backupDir = "" - tracked, _ := localChanges(ctx, root) + tracked, _, changeErr := localChanges(ctx, root) + if changeErr != nil { + return "", nil, nil, changeErr + } incoming, err := gitRaw(ctx, root, "diff", "--name-only", "-z", "HEAD.."+ref) if err != nil { - return "", nil, &Error{Reason: "diff_failed", Message: err.Error()} + return "", nil, nil, &Error{Reason: "diff_failed", Message: err.Error()} } willWrite := map[string]bool{} for _, f := range nulList(incoming) { willWrite[filepath.ToSlash(f)] = true } + if err := validateIncomingFiles(ctx, root, willWrite); err != nil { + return "", nil, nil, err + } + // A locally modified content file would refuse the merge; an untracked content file // whose path upstream now adds would also refuse it. Both are put aside. targets := map[string]bool{} for _, c := range tracked { if c.Protected && willWrite[c.Path] { - targets[c.Path] = true + if c.Status == "deleted" { + deleted = append(deleted, c.Path) + } else { + targets[c.Path] = true + } } } for _, p := range untrackedUnder(ctx, root, willWrite) { targets[p] = true } + sort.Strings(deleted) for p := range targets { + if err := validateFilePath(root, p); err != nil { + return backupDir, kept, deleted, err + } abs := filepath.Join(root, p) info, statErr := os.Stat(abs) - if statErr != nil || info.IsDir() { - continue + if statErr != nil || !info.Mode().IsRegular() { + return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: "备份前本地文件发生变化,未执行更新:" + p} + } + if backupDir == "" { + backupDir, err = newBackupDir(root, ts) + if err != nil { + return "", nil, deleted, err + } } if err := copyFile(abs, filepath.Join(backupDir, p), info.Mode()); err != nil { - return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} + return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} } kept = append(kept, p) } if len(kept) > 0 { sort.Strings(kept) - return backupDir, kept, nil + return backupDir, kept, deleted, nil } // Nothing to put aside: don't leave an empty backup directory behind. - return "", nil, nil + return "", nil, deleted, nil } func removeProtected(root string, kept []string) error { @@ -705,18 +763,27 @@ func stashProtectedEdits(ctx context.Context, root, target string, changed []Cha for _, p := range nulList(incoming) { willWrite[filepath.ToSlash(p)] = true } + if err := validateIncomingFiles(ctx, root, willWrite); err != nil { + return "", nil, nil, err + } for _, p := range untrackedUnder(ctx, root, willWrite) { files[p] = true } for p := range files { + if err := validateFilePath(root, p); err != nil { + return backupDir, kept, deleted, err + } abs := filepath.Join(root, filepath.FromSlash(p)) info, statErr := os.Stat(abs) if statErr != nil || !info.Mode().IsRegular() { - continue + return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: "备份前本地文件发生变化,未执行回滚:" + p} } if backupDir == "" { - backupDir = filepath.Join(root, ".update-backup", "rollback_"+ts) + backupDir, err = newBackupDir(root, "rollback_"+ts) + if err != nil { + return "", nil, deleted, err + } } if err := copyFile(abs, filepath.Join(backupDir, filepath.FromSlash(p)), info.Mode()); err != nil { return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} @@ -751,6 +818,19 @@ func restoreProtected(backupDir string, kept []string) error { } func restoreInterruptedProtected(ctx context.Context, root string, pending buildPendingState, merged bool) error { + if merged { + // Reapply only untouched copies written by the merge. A file recreated + // by the operator after the crash belongs to them. + for _, rel := range pending.DeletedContent { + workHash, workErr := gitCmd(ctx, root, "hash-object", "--", rel) + headHash, headErr := gitCmd(ctx, root, "rev-parse", "HEAD:"+rel) + if workErr == nil && headErr == nil && workHash == headHash { + if err := restoreDeleted(root, []string{rel}); err != nil { + return err + } + } + } + } if pending.BackupDir == "" || len(pending.KeptContent) == 0 { return nil } @@ -788,16 +868,15 @@ func restoreInterruptedProtected(ctx context.Context, root string, pending build // untrackedUnder lists untracked files under the given repository paths - the operator's // own additions, which must be backed up but never deleted by an update. func untrackedUnder(ctx context.Context, root string, willWrite map[string]bool) []string { - out, err := gitRaw(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames") + // With no exclude patterns ls-files includes ignored files too. git merge + // may overwrite an ignored config or database without an untracked warning. + out, err := gitRaw(ctx, root, "ls-files", "--others", "-z") if err != nil { return nil } var list []string for _, entry := range nulList(out) { - if len(entry) < 4 || entry[:2] != "??" { - continue - } - p := filepath.ToSlash(entry[3:]) + p := filepath.ToSlash(entry) if willWrite[p] && Protected(p) { list = append(list, p) } @@ -809,14 +888,42 @@ func untrackedUnder(ctx context.Context, root string, willWrite map[string]bool) // before the swap. It refuses unless HEAD is still exactly what the update wrote, so it // can never discard work done after that update. func Rollback(ctx context.Context, opts Options) (*Result, error) { + release, err := LockInstall(opts) + if err != nil { + return nil, err + } + defer release() root, err := opts.installRoot() if err != nil { return nil, err } + + interrupted, err := readRollbackPending(root) + if err != nil { + return nil, &Error{Reason: "state_unreadable", Message: "无法读取回滚恢复记录:" + err.Error()} + } + if interrupted != nil { + if err := repositoryRoot(ctx, root); err != nil { + return nil, err + } + head, err := gitCmd(ctx, root, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + if head == interrupted.State.PreviousCommit { + return finishRollback(ctx, opts, *interrupted) + } + if head != interrupted.State.UpdatedCommit { + return nil, &Error{Reason: "moved_since_update", Message: "回滚中断后 HEAD 已经变了,拒绝覆盖后续提交"} + } + } st, ok := readState(root) if !ok { return nil, &Error{Reason: "no_state", Message: "没有可回滚的更新记录"} } + if err := repositoryRoot(ctx, root); err != nil { + return nil, err + } bin := filepath.Join(root, opts.binaryName()) restoreBinary := true if st.BinaryCommit != "" { @@ -848,6 +955,9 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { } } if restoreBinary { + if st.PreviousBinaryAbsent { + return nil, &Error{Reason: "no_binary", Message: "这次更新之前没有二进制,不能使用其它更新留下的 .prev 回滚"} + } if !fileExists(bin + ".prev") { return nil, &Error{Reason: "no_binary", Message: "上一次更新没有留下旧二进制,无法回滚二进制(源码仍可用 git 处理)"} } @@ -874,7 +984,10 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { } start := time.Now() - changes, blocking := localChanges(ctx, root) + changes, blocking, changeErr := localChanges(ctx, root) + if changeErr != nil { + return nil, changeErr + } if len(blocking) > 0 { items := make([]string, 0, len(blocking)) for _, c := range blocking { @@ -893,47 +1006,31 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { return nil, err } - if _, err := gitCmd(ctx, root, "reset", "--hard", "--quiet", st.PreviousCommit); err != nil { - return nil, &Error{Reason: "reset_failed", Message: err.Error()} - } - if err := restoreProtected(backupDir, kept); err != nil { - return nil, err - } - for _, p := range deleted { - // A protected file the operator deleted by hand stays deleted: the reset just - // brought the old commit's copy back, and that deletion is a local change like any - // other this rollback promised not to touch. - if err := os.Remove(filepath.Join(root, filepath.FromSlash(p))); err != nil && !os.IsNotExist(err) { + pending := rollbackPendingState{State: st, BackupDir: backupDir, Kept: kept, Deleted: deleted, RestoreBinary: restoreBinary} + if fileExists(bin) { + pending.LiveSHA256, err = fileSHA256(bin) + if err != nil { return nil, err } } if restoreBinary { - if err := restorePrevBinary(bin); err != nil { - return nil, &Error{Reason: "swap_failed", Message: err.Error()} + pending.PreviousSHA256, err = fileSHA256(bin + ".prev") + if err != nil { + return nil, err } } - // Source and binary agree again (both the previous commit's), so a pending-build - // marker from a failed attempt no longer describes anything - keeping it would invite - // a rebuild of the old tree. - if err := clearBuildPending(root); err != nil { - return nil, &Error{Reason: "state_unwritable", Message: "回滚完成,但无法清除待编译标记:" + err.Error()} + if err := writeRollbackPending(root, pending); err != nil { + return nil, &Error{Reason: "state_unwritable", Message: "无法写入回滚恢复记录,未修改源码或二进制:" + err.Error()} } - // The version number goes back with the code it belongs to (only when it is still the - // one that update wrote - see restoreVersionAfterRollback). - restoreVersionAfterRollback(root, st) - if err := os.Remove(statePath(root)); err != nil && !os.IsNotExist(err) { - return nil, err + if _, err := gitCmd(ctx, root, "reset", "--hard", "--quiet", st.PreviousCommit); err != nil { + return nil, &Error{Reason: "reset_failed", Message: err.Error()} } - return &Result{ - FromCommit: shortHash(head), - ToCommit: shortHash(st.PreviousCommit), - KeptContent: kept, - BinaryPath: bin, - BinaryBuilt: restoreBinary, - BackupDir: st.BackupDir, - NeedsRestart: true, - Duration: time.Since(start).Round(time.Millisecond).String(), - }, nil + res, err := finishRollback(ctx, opts, pending) + if res != nil { + res.Duration = time.Since(start).Round(time.Millisecond).String() + } + return res, err + } func shortHash(h string) string { @@ -947,3 +1044,56 @@ func fileExists(p string) bool { _, err := os.Stat(p) return err == nil } + +func restoreDeleted(root string, deleted []string) error { + for _, rel := range deleted { + if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) { + return err + } + } + return nil +} + +func newBackupDir(root, prefix string) (string, error) { + base := filepath.Join(root, ".update-backup") + if err := os.MkdirAll(base, 0700); err != nil { + return "", err + } + return os.MkdirTemp(base, prefix+"-") +} + +func verifyBuildSource(ctx context.Context, root, commit string) error { + head, err := gitCmd(ctx, root, "rev-parse", "HEAD") + if err != nil { + return err + } + if head != commit { + return &Error{Reason: "build_state_mismatch", Message: "编译期间源码提交发生变化,拒绝换入无法配对的二进制"} + } + _, blocking, err := localChanges(ctx, root) + if err != nil { + return err + } + if len(blocking) > 0 { + return &Error{Reason: "local_source_edits", Message: "编译期间源码发生本地改动,已保留二进制和备份,请先处理这些改动"} + } + return nil +} + +func validateIncomingFiles(ctx context.Context, root string, paths map[string]bool) error { + for rel := range paths { + if err := validateFilePath(root, rel); err != nil { + return err + } + } + others, err := gitRaw(ctx, root, "ls-files", "--others", "-z") + if err != nil { + return err + } + for _, rel := range nulList(others) { + if paths[rel] && !Protected(rel) { + return &Error{Reason: "local_source_edits", Message: "目标提交会覆盖本地未跟踪的源码文件,已拒绝更新:" + rel, Items: []string{rel}} + } + } + return nil +} diff --git a/internal/update/build.go b/internal/update/build.go index f828112f..88fb4edf 100644 --- a/internal/update/build.go +++ b/internal/update/build.go @@ -99,15 +99,11 @@ func restorePrevBinary(bin string) error { if !fileExists(prev) { return fmt.Errorf("没有旧二进制:%s", prev) } - if fileExists(bin) { - if err := os.Remove(bin); err != nil { - return err - } - } if err := os.Chmod(prev, 0o755); err != nil { return err } - return os.Rename(prev, bin) + return replaceBinary(prev, bin) + } func fileSHA256(path string) (string, error) { diff --git a/internal/update/lock.go b/internal/update/lock.go new file mode 100644 index 00000000..c40107be --- /dev/null +++ b/internal/update/lock.go @@ -0,0 +1,28 @@ +package update + +import ( + "os" + "path/filepath" +) + +// LockInstall serializes CLI and HTTP operations on the same installation. The +// file stays in place: removing it would let another process lock a new inode. +// Kernel locks are released on exit, including a crash or SIGKILL. +func LockInstall(opts Options) (func(), error) { + root, err := opts.installRoot() + if err != nil { + return nil, err + } + if info, err := os.Stat(root); err != nil || !info.IsDir() { + return nil, &Error{Reason: "bad_root", Message: "安装目录不存在或不是目录:" + root} + } + f, err := os.OpenFile(filepath.Join(root, ".update-lock"), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, &Error{Reason: "lock_failed", Message: "无法锁定安装目录:" + err.Error()} + } + if err := lockFile(f); err != nil { + f.Close() + return nil, &Error{Reason: "update_busy", Message: "此安装目录已有更新、检查或回滚正在执行,请稍后重试"} + } + return func() { f.Close() }, nil +} diff --git a/internal/update/lock_other.go b/internal/update/lock_other.go new file mode 100644 index 00000000..ebd03813 --- /dev/null +++ b/internal/update/lock_other.go @@ -0,0 +1,12 @@ +//go:build !darwin && !linux && !freebsd && !openbsd && !netbsd && !dragonfly && !windows + +package update + +import ( + "fmt" + "os" +) + +func lockFile(f *os.File) error { + return fmt.Errorf("installation locking is unavailable on this platform") +} diff --git a/internal/update/lock_unix.go b/internal/update/lock_unix.go new file mode 100644 index 00000000..7b9d1b9b --- /dev/null +++ b/internal/update/lock_unix.go @@ -0,0 +1,10 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package update + +import ( + "golang.org/x/sys/unix" + "os" +) + +func lockFile(f *os.File) error { return unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) } diff --git a/internal/update/lock_windows.go b/internal/update/lock_windows.go new file mode 100644 index 00000000..808b0250 --- /dev/null +++ b/internal/update/lock_windows.go @@ -0,0 +1,10 @@ +package update + +import ( + "golang.org/x/sys/windows" + "os" +) + +func lockFile(f *os.File) error { + return windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) +} diff --git a/internal/update/paths.go b/internal/update/paths.go new file mode 100644 index 00000000..f18b3d57 --- /dev/null +++ b/internal/update/paths.go @@ -0,0 +1,33 @@ +package update + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// validateFilePath refuses directory/file and symlink conflicts before git can +// remove an untracked directory or backups can follow a link outside the install. +func validateFilePath(root, rel string) error { + parts := strings.Split(filepath.ToSlash(rel), "/") + path := root + for i, part := range parts { + if part == "" || part == "." || part == ".." { + return &Error{Reason: "content_path_conflict", Message: "文件路径不合法:" + rel} + } + path = filepath.Join(path, part) + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + leaf := i == len(parts)-1 + if (leaf && !info.Mode().IsRegular()) || (!leaf && !info.IsDir()) { + return &Error{Reason: "content_path_conflict", Message: fmt.Sprintf("%s 存在符号链接或目录/文件冲突,已保留本地内容,请先人工处理", rel), Items: []string{rel}} + } + } + return nil +} diff --git a/internal/update/pending_build_test.go b/internal/update/pending_build_test.go index db4742ce..adb48410 100644 --- a/internal/update/pending_build_test.go +++ b/internal/update/pending_build_test.go @@ -496,7 +496,7 @@ func TestRetryRestoresProtectedContentWhenInterruptedBeforeMerge(t *testing.T) { if err != nil || snap.CheckError != "" { t.Fatalf("check: %v, %s", err, snap.CheckError) } - backupDir, kept, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) + backupDir, kept, _, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) if err != nil { t.Fatal(err) } @@ -553,7 +553,7 @@ func TestRetryAfterMergeRestoresOnlyUntouchedProtectedContent(t *testing.T) { if err != nil || snap.CheckError != "" { t.Fatalf("check: %v, %s", err, snap.CheckError) } - backupDir, kept, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) + backupDir, kept, _, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) if err != nil { t.Fatal(err) } diff --git a/internal/update/replace_unix.go b/internal/update/replace_unix.go new file mode 100644 index 00000000..b59893b1 --- /dev/null +++ b/internal/update/replace_unix.go @@ -0,0 +1,7 @@ +//go:build !windows + +package update + +import "os" + +func replaceBinary(src, dst string) error { return os.Rename(src, dst) } diff --git a/internal/update/replace_windows.go b/internal/update/replace_windows.go new file mode 100644 index 00000000..66887661 --- /dev/null +++ b/internal/update/replace_windows.go @@ -0,0 +1,15 @@ +package update + +import "golang.org/x/sys/windows" + +func replaceBinary(src, dst string) error { + from, err := windows.UTF16PtrFromString(src) + if err != nil { + return err + } + to, err := windows.UTF16PtrFromString(dst) + if err != nil { + return err + } + return windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH) +} diff --git a/internal/update/rollback_pending.go b/internal/update/rollback_pending.go new file mode 100644 index 00000000..228d89c8 --- /dev/null +++ b/internal/update/rollback_pending.go @@ -0,0 +1,98 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const rollbackPendingFile = ".update-rollback-pending.json" + +type rollbackPendingState struct { + State State `json:"state"` + BackupDir string `json:"backup_dir"` + Kept []string `json:"kept"` + Deleted []string `json:"deleted"` + RestoreBinary bool `json:"restore_binary"` + LiveSHA256 string `json:"live_sha256"` + PreviousSHA256 string `json:"previous_sha256"` +} + +func readRollbackPending(root string) (*rollbackPendingState, error) { + data, err := os.ReadFile(filepath.Join(root, rollbackPendingFile)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var pending rollbackPendingState + if err := json.Unmarshal(data, &pending); err != nil { + return nil, err + } + if pending.State.PreviousCommit == "" || pending.State.UpdatedCommit == "" { + return nil, fmt.Errorf("回滚恢复记录缺少提交") + } + return &pending, nil +} + +func writeRollbackPending(root string, pending rollbackPendingState) error { + data, err := json.Marshal(pending) + if err != nil { + return err + } + path := filepath.Join(root, rollbackPendingFile) + if err := os.WriteFile(path+".tmp", data, 0600); err != nil { + return err + } + return os.Rename(path+".tmp", path) +} + +// finishRollback can run again after a reset or binary rename was interrupted. +// Hashes distinguish the old live binary from the already-restored one. +func finishRollback(ctx context.Context, opts Options, pending rollbackPendingState) (*Result, error) { + root, err := opts.installRoot() + if err != nil { + return nil, err + } + bin := filepath.Join(root, opts.binaryName()) + if pending.RestoreBinary { + current, currentErr := fileSHA256(bin) + if currentErr != nil || current != pending.PreviousSHA256 { + if currentErr == nil && current != pending.LiveSHA256 { + return nil, &Error{Reason: "binary_changed", Message: "回滚中断后现有二进制发生变化,已保留现场"} + } + previous, previousErr := fileSHA256(bin + ".prev") + if previousErr != nil || previous != pending.PreviousSHA256 { + return nil, &Error{Reason: "binary_changed", Message: "回滚备份与恢复记录不一致,已保留现场"} + } + if err := restorePrevBinary(bin); err != nil { + return nil, &Error{Reason: "swap_failed", Message: err.Error()} + } + } + } else if pending.LiveSHA256 != "" { + current, err := fileSHA256(bin) + if err != nil || current != pending.LiveSHA256 { + return nil, &Error{Reason: "binary_changed", Message: "回滚中断后现有二进制发生变化,已保留现场"} + } + } + content := buildPendingState{BackupDir: pending.BackupDir, KeptContent: pending.Kept, DeletedContent: pending.Deleted} + if err := restoreInterruptedProtected(ctx, root, content, true); err != nil { + return nil, err + } + if err := clearBuildPending(root); err != nil { + return nil, err + } + if err := restoreVersionAfterRollback(root, pending.State); err != nil { + return nil, &Error{Reason: "version_restore_failed", Message: "源码和二进制已回滚,但版本恢复失败;修复配置写入后重试回滚:" + err.Error()} + } + if err := os.Remove(statePath(root)); err != nil && !os.IsNotExist(err) { + return nil, err + } + if err := os.Remove(filepath.Join(root, rollbackPendingFile)); err != nil { + return nil, err + } + return &Result{FromCommit: shortHash(pending.State.UpdatedCommit), ToCommit: shortHash(pending.State.PreviousCommit), KeptContent: pending.Kept, BinaryPath: bin, BinaryBuilt: pending.RestoreBinary, BackupDir: pending.BackupDir, NeedsRestart: true}, nil +} diff --git a/internal/update/safety_test.go b/internal/update/safety_test.go new file mode 100644 index 00000000..c6d08e79 --- /dev/null +++ b/internal/update/safety_test.go @@ -0,0 +1,330 @@ +package update + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestApplyRefusesRepositorySubdirectory(t *testing.T) { + requireGit(t) + tr := newTree(t) + sub := filepath.Join(tr.install, "settings") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatal(err) + } + head := mustGit(t, tr.install, "rev-parse", "HEAD") + tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"}) + _, err := Apply(context.Background(), Options{Root: sub, Repo: tr.upstream, BinaryName: "none"}, nil) + if err == nil { + t.Fatal("updating a repository subdirectory must be refused") + } + if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head { + t.Fatal("refusal changed parent repository") + } +} + +func TestInstallLockReleasedWhenProcessIsKilled(t *testing.T) { + if root := os.Getenv("CSAI_TEST_LOCK_ROOT"); root != "" { + release, err := LockInstall(Options{Root: root}) + if err != nil { + t.Fatal(err) + } + defer release() + fmt.Println("locked") + time.Sleep(time.Hour) + return + } + root := t.TempDir() + bin, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "-test.run=^TestInstallLockReleasedWhenProcessIsKilled$") + cmd.Env = append(os.Environ(), "CSAI_TEST_LOCK_ROOT="+root) + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + line, err := bufio.NewReader(stdout).ReadString('\n') + if err != nil || line != "locked\n" { + t.Fatalf("child did not acquire lock: %q %v", line, err) + } + if release, err := LockInstall(Options{Root: root}); err == nil { + release() + t.Fatal("parent acquired child's lock") + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = cmd.Wait() + release, err := LockInstall(Options{Root: root}) + if err != nil { + t.Fatalf("crashed process left a stale lock: %v", err) + } + release() +} + +func TestFirstBuildDoesNotUseAnUnrelatedPreviousBinary(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin+".prev", "unrelated old binary") + tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"}) + if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { + t.Fatal(err) + } + head := mustGit(t, tr.install, "rev-parse", "HEAD") + if _, err := Rollback(context.Background(), buildOpts(tr)); err == nil { + t.Fatal("first build must not roll back to an unrelated .prev") + } + if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head { + t.Fatal("refusal reset source") + } +} + +func TestInterruptedAdoptionCanResumeFromUnbornHead(t *testing.T) { + requireGit(t) + tr, dir := newAdoptFixture(t) + scriptFakeGo(t) + role := filepath.Join(dir, "roles", "shipped.yaml") + original := readFile(t, role) + mustGit(t, dir, "init", "-q") + mustGit(t, dir, "remote", "add", "origin", tr.upstream) + branch, _, _, err := fetchSource(context.Background(), dir, tr.upstream) + if err != nil { + t.Fatal(err) + } + scan, err := scanAdoption(context.Background(), dir, dir, "origin/"+branch) + if err != nil { + t.Fatal(err) + } + backup := filepath.Join(dir, ".update-backup", "interrupted") + for _, rel := range append(scan.changed, scan.kept...) { + dst := filepath.Join(backup, "overwritten", rel) + if Protected(rel) { + dst = filepath.Join(backup, rel) + } + if err := copyFile(filepath.Join(dir, rel), dst, 0644); err != nil { + t.Fatal(err) + } + } + target := mustGit(t, dir, "rev-parse", "origin/"+branch) + if err := writeBuildPending(dir, buildPendingState{Commit: target, AdoptBranch: branch, BackupDir: backup, KeptContent: scan.kept, OverwrittenContent: scan.changed, PreviousBinaryAbsent: true}); err != nil { + t.Fatal(err) + } + if _, err := Apply(context.Background(), Options{Root: dir, Repo: tr.upstream}, nil); err != nil { + t.Fatalf("resume: %v", err) + } + if got := readFile(t, role); got != original { + t.Fatalf("resume lost role: %q", got) + } + if buildPending(dir) { + t.Fatal("resume left a pending marker") + } +} + +func TestInterruptedRollbackResumesAfterSourceReset(t *testing.T) { + for _, swapped := range []bool{false, true} { + t.Run(fmt.Sprint(swapped), func(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"}) + if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { + t.Fatal(err) + } + st, _ := readState(tr.install) + role := filepath.Join(tr.install, "roles", "shipped.yaml") + writeFile(t, role, "name: operator edit after update\n") + changes, _, _ := localChanges(context.Background(), tr.install) + backup, kept, deleted, err := stashProtectedEdits(context.Background(), tr.install, st.PreviousCommit, changes) + if err != nil { + t.Fatal(err) + } + liveHash, _ := fileSHA256(bin) + previousHash, _ := fileSHA256(bin + ".prev") + pending := rollbackPendingState{State: st, BackupDir: backup, Kept: kept, Deleted: deleted, RestoreBinary: true, LiveSHA256: liveHash, PreviousSHA256: previousHash} + if err := writeRollbackPending(tr.install, pending); err != nil { + t.Fatal(err) + } + mustGit(t, tr.install, "reset", "--hard", st.PreviousCommit) + if swapped { + if err := restorePrevBinary(bin); err != nil { + t.Fatal(err) + } + } + if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { + t.Fatal("update must refuse an interrupted rollback") + } + if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { + t.Fatalf("resume rollback: %v", err) + } + if got := readFile(t, bin); got != "original binary" { + t.Fatalf("wrong binary after resume: %q", got) + } + if got := readFile(t, role); got != "name: operator edit after update\n" { + t.Fatalf("role lost after resume: %q", got) + } + if pending, err := readRollbackPending(tr.install); err != nil || pending != nil { + t.Fatal("rollback journal not cleared") + } + }) + } +} + +func TestRollbackRemovesVersionInsertedIntoUnversionedConfig(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + writeFile(t, filepath.Join(tr.install, Binary), "old binary") + before := readFile(t, filepath.Join(tr.install, "config.yaml")) + tr.upstreamCommit(t, "release", map[string]string{"config.example.yaml": "version: v2.0.0\n"}) + if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { + t.Fatal(err) + } + if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(tr.install, "config.yaml")); got != before { + t.Fatalf("rollback did not restore an unversioned config: %q", got) + } +} + +func TestApplyRefusesIgnoredSourceCollision(t *testing.T) { + requireGit(t) + tr := newTree(t) + writeFile(t, filepath.Join(tr.install, ".git", "info", "exclude"), "local.go\n") + writeFile(t, filepath.Join(tr.install, "local.go"), "package operator\n") + head := mustGit(t, tr.install, "rev-parse", "HEAD") + tr.upstreamCommit(t, "add source", map[string]string{"local.go": "package upstream\n"}) + if _, err := Apply(context.Background(), opts(tr), nil); err == nil { + t.Fatal("ignored source collision must be refused") + } + if got := readFile(t, filepath.Join(tr.install, "local.go")); got != "package operator\n" { + t.Fatal("ignored source was lost") + } + if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != head { + t.Fatal("refusal changed HEAD") + } +} + +func TestBuildRefusesSourceChangesMadeAfterPreflight(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "old binary") + tr.upstreamCommit(t, "second", map[string]string{"run.sh": "new script\n"}) + _, err := Apply(context.Background(), buildOpts(tr), func(step Step) { + if step.Phase == "build" { + writeFile(t, filepath.Join(tr.install, "run.sh"), "operator edit during build\n") + } + }) + if ue, ok := err.(*Error); !ok || ue.Reason != "local_source_edits" { + t.Fatalf("want refusal of changed source, got %v", err) + } + if got := readFile(t, bin); got != "old binary" { + t.Fatal("unmatched binary installed") + } + if !buildPending(tr.install) { + t.Fatal("refusal must keep the recovery marker") + } +} + +func TestApplyKeepsProtectedDeletion(t *testing.T) { + requireGit(t) + tr := newTree(t) + role := filepath.Join(tr.install, "roles", "shipped.yaml") + if err := os.Remove(role); err != nil { + t.Fatal(err) + } + tr.upstreamCommit(t, "rewrite role", map[string]string{"roles/shipped.yaml": "name: new upstream\n"}) + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(role); !os.IsNotExist(err) { + t.Fatalf("deleted role resurrected: %v", err) + } +} + +func TestAdoptRefusesExistingInvalidGitMetadata(t *testing.T) { + requireGit(t) + tr, dir := newAdoptFixture(t) + metadata := filepath.Join(dir, ".git", "operator-note") + writeFile(t, metadata, "keep this metadata") + _, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil) + if err == nil { + t.Fatal("existing .git must never be taken over") + } + if got := readFile(t, metadata); got != "keep this metadata" { + t.Fatal("existing metadata changed") + } +} + +func TestAdoptionRefusesFileDirectoryConflict(t *testing.T) { + requireGit(t) + tr, dir := newAdoptFixture(t) + if err := os.Remove(filepath.Join(dir, "run.sh")); err != nil { + t.Fatal(err) + } + valuable := filepath.Join(dir, "run.sh", "local-note") + writeFile(t, valuable, "operator content") + if _, err := Preview(context.Background(), Options{Root: dir, Repo: tr.upstream}); err == nil { + t.Fatal("preview must report a directory conflict") + } + if _, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil); err == nil { + t.Fatal("adoption must refuse a directory conflict") + } + if got := readFile(t, valuable); got != "operator content" { + t.Fatal("directory content lost") + } +} + +func TestApplyKeepsIgnoredConfigWhenUpstreamStartsTrackingIt(t *testing.T) { + requireGit(t) + tr := newTree(t) + writeFile(t, filepath.Join(tr.install, ".git", "info", "exclude"), "config.yaml\n") + before := readFile(t, filepath.Join(tr.install, "config.yaml")) + tr.upstreamCommit(t, "track example config", map[string]string{"config.yaml": "server:\n port: 9999\n"}) + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { + t.Fatal(err) + } + if got := readFile(t, filepath.Join(tr.install, "config.yaml")); got != before { + t.Fatalf("ignored config overwritten: %q", got) + } +} + +func TestUpdateOperationsShareInstallationLock(t *testing.T) { + requireGit(t) + tr := newTree(t) + release, err := LockInstall(opts(tr)) + if err != nil { + t.Fatal(err) + } + defer release() + for _, run := range []func() error{ + func() error { _, err := Check(context.Background(), opts(tr)); return err }, + func() error { _, err := Apply(context.Background(), opts(tr), nil); return err }, + func() error { _, err := Rollback(context.Background(), opts(tr)); return err }, + func() error { _, err := Adopt(context.Background(), opts(tr), nil); return err }, + } { + if ue, ok := run().(*Error); !ok || ue.Reason != "update_busy" { + t.Fatalf("concurrent operation was not refused: %v", ue) + } + } +} diff --git a/internal/update/update.go b/internal/update/update.go index fed60bd0..22efc1aa 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -24,6 +24,7 @@ package update import ( "context" "fmt" + "os" "os/exec" "path/filepath" "regexp" @@ -79,7 +80,7 @@ func ValidRemoteURL(s string) bool { if strings.Contains(s, "::") { return false } - if strings.HasPrefix(s, "/") { + if strings.HasPrefix(s, "/") || filepath.IsAbs(s) { return true } return urlSchemePattern.MatchString(s) @@ -89,12 +90,12 @@ func ValidRemoteURL(s string) bool { // changed through the console or by hand. An update never overwrites them. var protectedDirs = []string{ "roles/", "skills/", "tools/", "agents/", "bundles/", "knowledge_base/", - "data/", "log/", "logs/", "tmp/", "venv/", ".upgrade-backup/", + "data/", "log/", "logs/", "tmp/", "venv/", ".venv/", "chat_uploads/", ".upgrade-backup/", } // protectedFiles are single files with the same rule - most importantly the live // configuration, which is never in the repository in the first place. -var protectedFiles = []string{"config.yaml", "config.yml", ".env"} +var protectedFiles = []string{"config.yaml", "config.yml", "config.local.yaml", ".env"} // Protected reports whether a repository-relative path belongs to the operator rather // than to the product. @@ -214,7 +215,8 @@ type Snapshot struct { // failed compile, or a machine without a toolchain). Such a tree has nothing left to // pull and still owes a build, which is why this is visible without a network round // trip: the page has to offer the click the failure message told the operator to make. - BuildPending bool `json:"buildPending"` + BuildPending bool `json:"buildPending"` + RollbackPending bool `json:"rollbackPending"` // CheckError carries a fetch or parse failure to the page instead of turning the // whole endpoint into a 500: "offline" and "your token expired" are answers, not @@ -289,6 +291,10 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { return snap, nil } snap.Installed = true + if err := repositoryRoot(ctx, root); err != nil { + snap.CheckError = err.Error() + return snap, nil + } for _, q := range []struct { args []string @@ -304,16 +310,28 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { } } - snap.LocalChanges, snap.BlockingChanges = localChanges(ctx, root) + var changeErr error + snap.LocalChanges, snap.BlockingChanges, changeErr = localChanges(ctx, root) + if changeErr != nil { + snap.CheckError = "无法检查本地改动:" + changeErr.Error() + return snap, nil + } snap.GoToolchain, snap.CanBuild = toolchain(ctx, root) bin := filepath.Join(root, opts.binaryName()) snap.HasBinary = fileExists(bin) snap.BuildPending = buildPending(root) if st, ok := readState(root); ok { binaryAlreadyMatchesTarget := st.BinaryCommit != "" && st.BinaryCommit == st.PreviousCommit - snap.HasRollback = st.UpdatedCommit != "" && (binaryAlreadyMatchesTarget || fileExists(bin+".prev")) + snap.HasRollback = st.UpdatedCommit != "" && !st.PreviousBinaryAbsent && (binaryAlreadyMatchesTarget || fileExists(bin+".prev")) snap.RollbackTo = st.PreviousCommit } + if pending, err := readRollbackPending(root); err != nil { + snap.CheckError = "回滚恢复记录无法读取:" + err.Error() + } else if pending != nil { + snap.HasRollback = true + snap.RollbackPending = true + snap.RollbackTo = pending.State.PreviousCommit + } return snap, nil } @@ -325,10 +343,10 @@ func (s *Snapshot) sourceLabel() string { return s.Repo } // localChanges lists modified and deleted tracked files, and splits them by who owns // the path. -func localChanges(ctx context.Context, root string) (all, blocking []Change) { +func localChanges(ctx context.Context, root string) (all, blocking []Change, err error) { out, err := gitRaw(ctx, root, "status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=no") if err != nil { - return all, blocking + return all, blocking, err } for _, entry := range nulList(out) { if len(entry) < 4 { @@ -346,7 +364,7 @@ func localChanges(ctx context.Context, root string) (all, blocking []Change) { } sort.Slice(all, func(i, j int) bool { return all[i].Path < all[j].Path }) sort.Slice(blocking, func(i, j int) bool { return blocking[i].Path < blocking[j].Path }) - return all, blocking + return all, blocking, nil } func changeVerb(status string) string { @@ -364,6 +382,15 @@ func changeVerb(status string) string { // the only network operations in this package, and a failure is a readable answer rather // than a fault: the page has to distinguish "up to date" from "could not look". func Check(ctx context.Context, opts Options) (*Snapshot, error) { + release, err := LockInstall(opts) + if err != nil { + return nil, err + } + defer release() + return check(ctx, opts) +} + +func check(ctx context.Context, opts Options) (*Snapshot, error) { snap, err := Status(ctx, opts) if err != nil { return nil, err @@ -400,13 +427,18 @@ func Check(ctx context.Context, opts Options) (*Snapshot, error) { } snap.RemoteSubject, _ = gitCmd(ctx, root, "log", "-1", "--format=%s", ref) - if counts, err := gitCmd(ctx, root, "rev-list", "--left-right", "--count", "HEAD..."+ref); err == nil { - var ahead, behind int - if _, scanErr := fmt.Sscanf(counts, "%d %d", &ahead, &behind); scanErr == nil { - snap.Ahead, snap.Behind = ahead, behind - snap.Diverged = ahead > 0 && behind > 0 - } + counts, err := gitCmd(ctx, root, "rev-list", "--left-right", "--count", "HEAD..."+ref) + if err != nil { + snap.CheckError = err.Error() + return snap, nil } + var ahead, behind int + if _, err := fmt.Sscanf(counts, "%d %d", &ahead, &behind); err != nil { + snap.CheckError = "无法解析更新提交差异:" + err.Error() + return snap, nil + } + snap.Ahead, snap.Behind = ahead, behind + snap.Diverged = ahead > 0 && behind > 0 snap.UpdateAvailable = snap.Behind > 0 snap.IncomingTotal = snap.Behind @@ -459,3 +491,24 @@ func parseDefaultBranch(out string) string { } return "" } + +// Git accepts any descendant of a work tree, but file backups and binary swaps +// are rooted at the installation. Refuse descendants before touching that tree. +func repositoryRoot(ctx context.Context, root string) error { + top, err := gitCmd(ctx, root, "rev-parse", "--show-toplevel") + if err != nil { + return err + } + actual, err := os.Stat(root) + if err != nil { + return err + } + expected, err := os.Stat(top) + if err != nil { + return err + } + if !os.SameFile(actual, expected) { + return &Error{Reason: "bad_root", Message: "更新只能操作 git 仓库根目录,请使用:" + top} + } + return nil +} diff --git a/internal/update/version.go b/internal/update/version.go index bde24079..25fa2c12 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -108,17 +108,22 @@ func versionStep(step func(string, string), before, after string, err error) { // restoreVersionAfterRollback puts the old number back with the old code - but only while // the live config still carries exactly what the update wrote: a version someone edited by // hand after the update belongs to them, not to the rollback. -func restoreVersionAfterRollback(root string, st State) { +func restoreVersionAfterRollback(root string, st State) error { if st.VersionAfter == "" { - return + return nil } path := liveConfigFile(root) if path == "" { - return + return nil } current, err := config.FileVersion(path) if err != nil || current != st.VersionAfter { - return + return nil } - _, _ = config.WriteVersion(path, st.VersionBefore) + if st.VersionBefore == "" { + _, err := config.RemoveVersion(path) + return err + } + _, err = config.WriteVersion(path, st.VersionBefore) + return err } diff --git a/upgrade.sh b/upgrade.sh index 206a85a0..a4b7adf1 100644 --- a/upgrade.sh +++ b/upgrade.sh @@ -533,7 +533,10 @@ for i, line in enumerate(lines): if m: value = m.group(2).strip() nxt = lines[i + 1] if i + 1 < len(lines) else "" - if value[:1] in ("|", ">") or (value == "" and nxt[:1] in (" ", "\t")): + # Without a YAML parser, only replace a complete scalar on one + # line. An open quote or flow/anchor value may continue below. + simple_value = re.fullmatch(r"(?:[A-Za-z0-9._+-]+|'[^'\n]*'|\"[^\"\\\n]*\")?(?:[ \t]+#.*)?", value) + if simple_value is None or (value == "" and nxt[:1] in (" ", "\t")): sys.stderr.write("版本号未写入:config.yaml 的 version 是多行/块标量写法,无法就地替换(原文件未改动)\n") refused = True break @@ -555,6 +558,10 @@ if not refused: break trimmed = first.strip() if trimmed and not trimmed.startswith("#") and not first.startswith("%"): + # Only a block mapping at column zero can accept a new key. + if not re.match(r"^[A-Za-z_][A-Za-z0-9_-]*[ \t]*:", first): + sys.stderr.write("版本号未写入:无法安全地在此 YAML 文档插入字段,原文件未改动\n") + sys.exit(0) break out.insert(insert_at, 'version: "%s"\n' % tag) @@ -589,9 +596,9 @@ sync_code() { local -a rsync_excludes rsync_excludes+=( "--exclude=.upgrade-backup/" ) - rsync_excludes+=( "--exclude=config.yaml" ) + rsync_excludes+=( "--exclude=config.yaml" "--exclude=config.yml" "--exclude=config.local.yaml" "--exclude=.env" ) rsync_excludes+=( "--exclude=data/" ) - rsync_excludes+=( "--exclude=tmp/" ) + rsync_excludes+=( "--exclude=tmp/" "--exclude=chat_uploads/" "--exclude=log/" "--exclude=logs/" "--exclude=bundles/" "--exclude=.venv/" ) if [[ "$PRESERVE_VENV" -eq 1 ]]; then rsync_excludes+=( "--exclude=venv/" ) @@ -615,7 +622,7 @@ sync_code() { # forget a compile it still owes, so the next click reports "already up to date". rsync_excludes+=( "--exclude=.update-backup/" ) rsync_excludes+=( "--exclude=.update-staging/" ) - rsync_excludes+=( "--exclude=.update-state.json" ) + rsync_excludes+=( "--exclude=.update-state.json" "--exclude=.update-lock" "--exclude=.update-rollback-pending.json" "--exclude=.update-rollback-pending.json.tmp" ) rsync_excludes+=( "--exclude=.update-state.json.tmp" ) rsync_excludes+=( "--exclude=.update-build-pending" ) rsync_excludes+=( "--exclude=.update-build-pending.tmp" ) diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index f8346cc0..7ac4dcf2 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -139,6 +139,7 @@ "applyBtn": "Update now", "jobRunningHint": "An update is already running; its progress is below.", "noToolchainWarn": "No Go toolchain here: the source will be updated but the binary will not be replaced; build it externally, then restart.", + "rollbackPendingHint": "The previous rollback is incomplete. Click Roll back to finish recovery before updating.", "buildPendingHint": "The last update moved the source but never replaced the binary (the build failed, or there was no Go toolchain): press Update to finish that build.", "blockedNotGitTree": "This directory is not a git work tree, so it cannot be updated automatically.", "blockedJobRunning": "An update is already running; wait for it to finish.", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index c5f87780..d4e554de 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -139,6 +139,7 @@ "applyBtn": "Обновить сейчас", "jobRunningHint": "Обновление уже выполняется; его ход показан ниже.", "noToolchainWarn": "Здесь нет инструментария Go: исходники будут обновлены, но бинарный файл не заменится; соберите его извне и перезапустите службу.", + "rollbackPendingHint": "Предыдущий откат не завершён. Нажмите «Откатить», чтобы закончить восстановление перед обновлением.", "buildPendingHint": "Прошлое обновление перенесло исходники, но не заменило бинарный файл (сборка не удалась или тогда не было инструментария Go): нажмите «Обновить», чтобы завершить эту сборку.", "blockedNotGitTree": "Этот каталог не является git-рабочим деревом, автоматическое обновление невозможно.", "blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index d50aabdb..1cf04332 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -139,6 +139,7 @@ "applyBtn": "一键更新", "jobRunningHint": "已有一次更新在进行中,下面是它的进度。", "noToolchainWarn": "本机没有 Go 工具链:源码会更新,二进制不会更换,要由外部构建产出后再重启。", + "rollbackPendingHint": "上一次回滚尚未完成,请先点「回滚」继续恢复,再执行更新。", "buildPendingHint": "上一次更新只搬了源码、没能换二进制(编译失败或当时没有 Go 工具链):点「一键更新」会补上这次编译。", "blockedNotGitTree": "这个目录不是 git 工作树,无法自动更新。", "blockedJobRunning": "已有更新在进行中,请等它结束。", diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 7b52391e..a3bf65b4 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -1494,3 +1494,14 @@ test('a job poll that dies while the restart is in flight is not a failure', asy await h.flush(); assert.equal(h.location.replaced.length, 1); }); + +test('an interrupted rollback offers recovery and blocks a new update', async () => { + const h = await harness({ status: statusOf({ rollbackPending: true, hasRollback: true }) }); + assert.match(h.html(), /上一次回滚尚未完成/); + assert.match(h.html(), /update-rollback-btn" data-require-permission="update:apply"/); + assert.match(h.html(), /update-apply-btn" disabled data-state-disabled="true"/); + h.calls.length = 0; + h.fire('startUpdateApply'); + await h.flush(); + assert.equal(h.calls.some(c => c.url === '/api/system/update/apply'), false); +}); diff --git a/web/static/js/update.js b/web/static/js/update.js index ed251044..0332809a 100644 --- a/web/static/js/update.js +++ b/web/static/js/update.js @@ -414,6 +414,7 @@ function updateBehindCount(status) { // 按钮为什么点不动,得说得出原因;"灰着"不算解释。 function updateApplyBlockers(status, job) { const blockers = []; + if (status.rollbackPending) blockers.push(updateT('rollbackPendingHint')); if (!status.installed) blockers.push(updateT('blockedNotGitTree')); if (job && job.state === 'running') blockers.push(updateT('blockedJobRunning')); if ((status.blockingChanges || []).length) { @@ -781,6 +782,9 @@ function renderUpdateApplyBody(status, job) { } // 源码已经在更新源尖端、只欠一次编译:这不是"没有可用更新",按钮必须照常可点,而且 // 得说清楚点它会去补编译——否则 no_toolchain 里那句"再点一次更新"在页面上根本没有落点。 + if (status.rollbackPending) { + parts.push('
' + escapeHtml(updateT('rollbackPendingHint')) + '
'); + } if (status.buildPending && status.installed) { parts.push('
' + escapeHtml(updateT('buildPendingHint')) + '
'); }