fix: serialize installation updates and recover interrupted adoption and rollback

This commit is contained in:
Sycun committed 2026-10-09 10:58:12 +08:00
1 parent 54d8f1187d
commit e8a5392b37
30 files changed
+1250 -124

No files matched your search

+3
View File
@@ -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
+31
View File
@@ -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
+3
View File
@@ -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) {
+81 -5
View File
@@ -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) {
+57 -2
View File
@@ -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")
}
}
+7 -2
View File
@@ -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 读当前配置的更新源地址(加读锁;更新处理器每次请求现读,保存后立即生效)。
+44 -9
View File
@@ -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
+45
View File
@@ -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)
}
}
+99 -19
View File
@@ -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) {
+21
View File
@@ -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)
}
}
+205 -55
View File
@@ -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
}
+2 -6
View File
@@ -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) {
+28
View File
@@ -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
}
+12
View File
@@ -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")
}
+10
View File
@@ -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) }
+10
View File
@@ -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{})
}
+33
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package update
import "os"
func replaceBinary(src, dst string) error { return os.Rename(src, dst) }
+15
View File
@@ -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)
}
+98
View File
@@ -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
}
+330
View File
@@ -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)
}
}
}
+68 -15
View File
@@ -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
}
+10 -5
View File
@@ -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
}
+11 -4
View File
@@ -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" )
+1
View File
@@ -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.",
+1
View File
@@ -139,6 +139,7 @@
"applyBtn": "Обновить сейчас",
"jobRunningHint": "Обновление уже выполняется; его ход показан ниже.",
"noToolchainWarn": "Здесь нет инструментария Go: исходники будут обновлены, но бинарный файл не заменится; соберите его извне и перезапустите службу.",
"rollbackPendingHint": "Предыдущий откат не завершён. Нажмите «Откатить», чтобы закончить восстановление перед обновлением.",
"buildPendingHint": "Прошлое обновление перенесло исходники, но не заменило бинарный файл (сборка не удалась или тогда не было инструментария Go): нажмите «Обновить», чтобы завершить эту сборку.",
"blockedNotGitTree": "Этот каталог не является git-рабочим деревом, автоматическое обновление невозможно.",
"blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.",
+1
View File
@@ -139,6 +139,7 @@
"applyBtn": "一键更新",
"jobRunningHint": "已有一次更新在进行中,下面是它的进度。",
"noToolchainWarn": "本机没有 Go 工具链:源码会更新,二进制不会更换,要由外部构建产出后再重启。",
"rollbackPendingHint": "上一次回滚尚未完成,请先点「回滚」继续恢复,再执行更新。",
"buildPendingHint": "上一次更新只搬了源码、没能换二进制(编译失败或当时没有 Go 工具链):点「一键更新」会补上这次编译。",
"blockedNotGitTree": "这个目录不是 git 工作树,无法自动更新。",
"blockedJobRunning": "已有更新在进行中,请等它结束。",
+11
View File
@@ -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);
});
+4
View File
@@ -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('<div class="update-warn">' + escapeHtml(updateT('rollbackPendingHint')) + '</div>');
}
if (status.buildPending && status.installed) {
parts.push('<div class="update-warn">' + escapeHtml(updateT('buildPendingHint')) + '</div>');
}