mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
fix: serialize installation updates and recover interrupted adoption and rollback
This commit is contained in:
1 parent
54d8f1187d
commit
e8a5392b37
30 files changed
+1250
-124
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 读当前配置的更新源地址(加读锁;更新处理器每次请求现读,保存后立即生效)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package update
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceBinary(src, dst string) error { return os.Rename(src, dst) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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" )
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"applyBtn": "Обновить сейчас",
|
||||
"jobRunningHint": "Обновление уже выполняется; его ход показан ниже.",
|
||||
"noToolchainWarn": "Здесь нет инструментария Go: исходники будут обновлены, но бинарный файл не заменится; соберите его извне и перезапустите службу.",
|
||||
"rollbackPendingHint": "Предыдущий откат не завершён. Нажмите «Откатить», чтобы закончить восстановление перед обновлением.",
|
||||
"buildPendingHint": "Прошлое обновление перенесло исходники, но не заменило бинарный файл (сборка не удалась или тогда не было инструментария Go): нажмите «Обновить», чтобы завершить эту сборку.",
|
||||
"blockedNotGitTree": "Этот каталог не является git-рабочим деревом, автоматическое обновление невозможно.",
|
||||
"blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"applyBtn": "一键更新",
|
||||
"jobRunningHint": "已有一次更新在进行中,下面是它的进度。",
|
||||
"noToolchainWarn": "本机没有 Go 工具链:源码会更新,二进制不会更换,要由外部构建产出后再重启。",
|
||||
"rollbackPendingHint": "上一次回滚尚未完成,请先点「回滚」继续恢复,再执行更新。",
|
||||
"buildPendingHint": "上一次更新只搬了源码、没能换二进制(编译失败或当时没有 Go 工具链):点「一键更新」会补上这次编译。",
|
||||
"blockedNotGitTree": "这个目录不是 git 工作树,无法自动更新。",
|
||||
"blockedJobRunning": "已有更新在进行中,请等它结束。",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user