mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-21 14:52:05 +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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user