feat: update this installation's own source in one action

An operator running a fork has no honest way to upgrade: upgrade.sh downloads the
Release tarball of one hard-coded third-party repository and rsyncs it over the
tree, so "upgrade" means "overwrite yourself with somebody else's code", and the
safe alternative is to pull by hand and remember the build command.

internal/update drives the remote the directory already tracks: fetch, fast-forward,
go build into .update-staging, atomic rename of the binary (the previous one kept as
cyberstrike-ai.prev), and a .update-state.json so a rollback has a target that is
not guessed at. Refusals are the point - local source edits and a diverged branch
stop with the file names and commit counts in the error rather than being reset to
match, and operator-owned content (roles/skills/tools/agents/data/config.yaml) is put
aside before the merge and restored over it, with what was kept listed in keptContent
instead of quietly dropped.

Consumers: GET/POST /api/system/update* behind update:read / update:apply (mutations
are process-global, so they need a global-scope session); a console page at
#system-update with an apply button that names why it is blocked, a job polled from
the server and a rollback control; the same three actions as -check-update / -update
/ -update-rollback on the binary; OpenAPI entries for all five endpoints. Permission
gating no longer re-enables a control a page disabled for a reason of its own
(data-state-disabled), which every state-disabled, permission-gated button in the
console was subject to. upgrade.sh becomes a thin shell over the same implementation
and only falls back to the tarball path for installs that are not git work trees.

Docs and the zh/EN/RU dictionaries are updated. The package runs 13 tests against
real temporary git repositories; the HTTP layer, RBAC and the console page each
carry their own contract tests.
This commit is contained in:
Sycun committed 2026-10-07 12:07:26 +08:00
1 parent d2084a9481
commit cb8bc6b824
34 files changed
+5225 -112

No files matched your search

+104
View File
@@ -6449,6 +6449,110 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/system/update": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "查看本安装的源码版本",
"description": "读取安装目录自己的 git 工作树(分支、远端、当前提交、本地改动、是否有 Go 工具链、是否有可回滚点),不发起任何网络请求",
"operationId": "getUpdateStatus",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "安装状态与最近一次更新任务",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/system/update/check": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "检查本安装自己的远端有无新提交",
"description": "对安装目录已经跟踪的远端分支执行 fetch,报告落后/领先提交数与新提交列表;取代码的来源永远是本目录自己的远端,不是任何写死的第三方仓库。检查失败返回 200 且带 checkError,不把「查不了」说成「已是最新」",
"operationId": "checkUpdate",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "检查结果(含 checkError 时为检查未成功)",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/system/update/apply": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "一键更新源码并重编译",
"description": "快进到远端分支、重新编译二进制并原子换入(旧二进制留作 .prev 以便回滚)。本机改过源码文件或分支已分叉时拒绝执行并点名;roles/skills/tools/agents/bundles/knowledge_base/data/config.yaml 等运维者内容在合并前暂存、合并后放回,被保留的文件列在 keptContent。请求立即返回 job_id,进度用 GET /api/system/update/job 轮询;已有任务在跑时返回 409",
"operationId": "applyUpdate",
"requestBody": map[string]interface{}{
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"restart": map[string]interface{}{
"type": "boolean",
"description": "更新成功后优雅关闭并退出进程;只在有外部守护时才会被拉起,默认 false",
},
},
},
},
},
},
"responses": map[string]interface{}{
"202": map[string]interface{}{
"description": "任务已受理,返回 job_id",
},
"400": map[string]interface{}{
"description": "请求体不合法,或要求重启但本次启动没有装配重启钩子",
},
"409": map[string]interface{}{
"description": "已有一次更新在进行中",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/system/update/job": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "查看更新任务进度",
"description": "返回正在运行或最近一次更新任务的状态、逐行进度 steps、结果 result 与失败 failure;从未更新过时 job 为 null",
"operationId": "getUpdateJob",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "任务状态",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
"/api/system/update/rollback": map[string]interface{}{
"post": map[string]interface{}{
"tags": []string{"系统更新"},
"summary": "回滚到本次更新之前",
"description": "撤到上一次更新记录里的旧提交,并换回换入前保留的旧二进制。自那次更新之后 HEAD 已经变化、或本机有源码改动时返回 409 拒绝,而不是丢弃更新之后的工作",
"operationId": "rollbackUpdate",
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "已回滚",
},
"409": map[string]interface{}{
"description": "没有可回滚的记录、更新进行中、或更新之后 HEAD 已被移动",
},
"401": map[string]interface{}{
"description": "未授权",
},
},
},
},
},
}
+327
View File
@@ -0,0 +1,327 @@
package handler
import (
"context"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/update"
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// UpdateHandler is the "update my own source" surface: what this installation is, what
// its remote has that it does not, and one action that moves the tree and rebuilds the
// binary.
//
// It exists because the alternative was a shell script pointed at one fixed upstream
// repository, which for anyone running a fork meant "downgrade yourself to somebody
// else's code". Here the remote is whatever this directory already tracks, and the person
// at the keyboard never types git or go build.
type UpdateHandler struct {
root string // the installation to update: the directory the config file lives in
logger *zap.Logger
audit *audit.Service
// restart is the operator's own shutdown hook. It is only ever called when the
// request asked for a restart, because an unsupervised process that exits stays
// exited - "I restarted you" would be a lie on most deployments.
restart func()
mu sync.Mutex
jobs map[string]*updateJob
order []string
active string
}
// updateJob is one running or finished apply. A build takes minutes, so the request
// returns a handle and the page polls it rather than sitting in one HTTP call until the
// compiler finishes.
type updateJob struct {
ID string `json:"id"`
State string `json:"state"` // running | succeeded | failed
Started string `json:"started"`
Finished string `json:"finished,omitempty"`
Steps []update.Step `json:"steps"`
Result *update.Result `json:"result,omitempty"`
Failure *update.Error `json:"failure,omitempty"`
Restart bool `json:"restartRequested"`
}
// view copies a job for transport, because its fields are written by the job's own
// goroutine: a response must never serialize the live struct. Callers hold h.mu.
func (j *updateJob) view() *updateJob {
out := *j
out.Steps = append([]update.Step{}, j.Steps...)
return &out
}
// NewUpdateHandler takes the audit service as a constructor argument rather than through
// a SetAudit the assembly has to remember: forgetting here is a compile error instead of
// a privileged endpoint that writes no audit records at all.
func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func()) *UpdateHandler {
return &UpdateHandler{
root: root,
logger: logger,
audit: auditSvc,
restart: restart,
jobs: map[string]*updateJob{},
}
}
func (h *UpdateHandler) options() update.Options { return update.Options{Root: h.root} }
// GetStatus answers GET /api/system/update: the install tree as it is on disk, without a
// network round trip, so opening the page is never gated on GitHub being reachable.
func (h *UpdateHandler) GetStatus(c *gin.Context) {
snap, err := update.Status(c.Request.Context(), h.options())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": snap,
"job": h.latest(),
"canRestart": h.restart != nil,
})
}
// Check answers POST /api/system/update/check: fetch the tracked branch and report the
// gap. A failed fetch is a 200 with checkError, not a 500 - "offline" and "bad token"
// are answers the operator needs to read, not server faults to debug.
func (h *UpdateHandler) Check(c *gin.Context) {
snap, err := update.Check(c.Request.Context(), h.options())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.record(c, "update.check", resultOf(snap.CheckError), fmt.Sprintf("检查更新:%s/%s", snap.Remote, snap.Branch), map[string]interface{}{
"commit": snap.Commit, "remote_commit": snap.RemoteCommit, "behind": snap.Behind, "error": snap.CheckError,
})
c.JSON(http.StatusOK, gin.H{"status": snap})
}
// Apply answers POST /api/system/update/apply. It starts one job and returns immediately;
// a second concurrent apply is refused, because two processes moving the same working
// tree and swapping the same binary is how an installation becomes unrecoverable.
func (h *UpdateHandler) Apply(c *gin.Context) {
var body struct {
Restart bool `json:"restart"`
}
if c.Request.ContentLength > 0 {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"})
return
}
}
if body.Restart && h.restart == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,更新完成后请由外部进程管理器重启服务"})
return
}
job, started := h.startJob(body.Restart)
if !started {
c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job})
return
}
h.record(c, "update.apply", "started", "开始一键更新源码并重编译", map[string]interface{}{"job": job.ID, "restart": body.Restart})
c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State})
}
// Job answers GET /api/system/update/job: the running job, or the most recent one, with
// every progress line so far.
func (h *UpdateHandler) Job(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"job": h.latest()})
}
// Rollback answers POST /api/system/update/rollback: back to the commit the last update
// came from, with the binary kept before the swap. It refuses when HEAD has moved since
// that update, rather than resetting away work nobody asked it to touch.
func (h *UpdateHandler) Rollback(c *gin.Context) {
if busy := h.activeJob(); busy != nil {
c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,不能同时回滚", "job": busy})
return
}
res, err := update.Rollback(c.Request.Context(), h.options())
if err != nil {
h.record(c, "update.rollback", "failure", "回滚失败: "+err.Error(), map[string]interface{}{"error": errMessage(err)})
c.JSON(statusForError(err), errorBody(err))
return
}
h.record(c, "update.rollback", "success", fmt.Sprintf("已回滚到 %s", res.ToCommit), map[string]interface{}{
"from": res.FromCommit, "to": res.ToCommit,
})
c.JSON(http.StatusOK, gin.H{"result": res})
}
func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) {
h.mu.Lock()
if h.active != "" {
return h.jobs[h.active].view(), false
}
job := &updateJob{
ID: fmt.Sprintf("upd-%d", time.Now().UnixNano()),
State: "running",
Started: time.Now().Format(time.RFC3339),
Restart: restart,
}
h.jobs[job.ID] = job
h.order = append(h.order, job.ID)
for len(h.order) > 20 {
oldest := h.order[0]
h.order = h.order[1:]
if oldest != h.active {
delete(h.jobs, oldest)
}
}
h.active = job.ID
snapshot := job.view()
h.mu.Unlock()
// The update outlives its HTTP request on purpose: the caller gets a handle, and a
// browser that closes the tab must not cancel a merge halfway through. Once the
// working tree has moved there is no going back, so the context is generous with
// time instead of inheriting the request's deadline.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute)
defer cancel()
onStep := func(s update.Step) {
h.mu.Lock()
job.Steps = append(job.Steps, s)
h.mu.Unlock()
if h.logger != nil {
h.logger.Info("更新进度", zap.String("phase", s.Phase), zap.String("message", s.Message))
}
}
res, err := update.Apply(ctx, h.options(), onStep)
finish := "succeeded"
if err != nil {
finish = "failed"
}
h.mu.Lock()
job.Result = res
if ue, ok := asUpdateError(err); ok {
job.Failure = ue
} else if err != nil {
job.Failure = &update.Error{Reason: "error", Message: err.Error()}
}
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(2*time.Second, h.restart)
return
}
h.active = ""
h.mu.Unlock()
}()
return snapshot, true
}
func (h *UpdateHandler) activeJob() *updateJob {
h.mu.Lock()
defer h.mu.Unlock()
if h.active == "" {
return nil
}
if job := h.jobs[h.active]; job != nil {
return job.view()
}
return nil
}
func (h *UpdateHandler) latest() *updateJob {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.order) == 0 {
return nil
}
if job := h.jobs[h.order[len(h.order)-1]]; job != nil {
return job.view()
}
return nil
}
func (h *UpdateHandler) record(c *gin.Context, action, result, message string, detail map[string]interface{}) {
if h.audit == nil {
return
}
h.audit.Record(c, audit.Entry{
Level: auditLevelFor(result),
Category: "update",
Action: action,
Result: result,
Message: message,
ResourceType: "system",
ResourceID: h.root,
Detail: detail,
})
}
func auditLevelFor(result string) string {
if result == "failure" {
return "error"
}
return "info"
}
func resultOf(checkError string) string {
if checkError != "" {
return "failure"
}
return "success"
}
func asUpdateError(err error) (*update.Error, bool) {
if err == nil {
return nil, false
}
if ue, ok := err.(*update.Error); ok {
return ue, true
}
return nil, false
}
func errMessage(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// statusForError keeps a refusal distinguishable from an accident: the precondition
// failures are the ones the page can act on (409), while anything else is a bad request it
// can show. No case maps a refusal onto a 2xx - a status that reads as accepted would
// contradict the failure the same response carries, and an asynchronous update never asks
// this function (the job's own state says whether it worked).
func statusForError(err error) int {
ue, ok := asUpdateError(err)
if !ok {
return http.StatusInternalServerError
}
switch ue.Reason {
case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary":
return http.StatusConflict
default:
return http.StatusBadRequest
}
}
func errorBody(err error) gin.H {
body := gin.H{"error": errMessage(err)}
if ue, ok := asUpdateError(err); ok {
body["reason"] = ue.Reason
if len(ue.Items) > 0 {
body["items"] = ue.Items
}
}
return body
}
+168
View File
@@ -0,0 +1,168 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// The HTTP contract of "update my own source in one click": status without a network
// round trip, an apply that returns a job handle instead of blocking a request open for
// the length of a compile, and refusals that stay distinguishable from accidents.
func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) {
gin.SetMode(gin.TestMode)
h := NewUpdateHandler(root, nil, nil, restart)
router := gin.New()
router.GET("/api/system/update", h.GetStatus)
router.GET("/api/system/update/job", h.Job)
router.POST("/api/system/update/check", h.Check)
router.POST("/api/system/update/apply", h.Apply)
router.POST("/api/system/update/rollback", h.Rollback)
return router, h
}
func doUpdate(router *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
var reader *strings.Reader
if body == "" {
reader = strings.NewReader("")
} else {
reader = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestUpdateStatusIsServedWithoutTouchingTheNetwork(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodGet, "/api/system/update", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body)
}
var body struct {
Status struct {
Root string `json:"root"`
Installed bool `json:"installed"`
CheckError string `json:"checkError"`
CanBuild bool `json:"canBuild"`
} `json:"status"`
Job *struct{ ID string } `json:"job"`
CanRestart bool `json:"canRestart"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status.Root == "" || body.Status.Installed {
t.Errorf("a plain directory must report its root and that it is not a git tree: %+v", body.Status)
}
if body.Status.CheckError == "" {
t.Error("the reason an update cannot run must be readable on the page, not just a status code")
}
if body.Job != nil {
t.Error("job must be null before anything has been applied")
}
if body.CanRestart {
t.Error("canRestart must be false when no restart hook was wired")
}
}
func TestUpdateApplyReturnsAJobAndReportsAnHonestFailure(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart":false}`)
if w.Code != http.StatusAccepted {
t.Fatalf("apply should be accepted and return a handle, got %d %s", w.Code, w.Body)
}
var started struct {
JobID string `json:"job_id"`
State string `json:"state"`
}
if err := json.Unmarshal(w.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.JobID == "" {
t.Fatal("apply must return the job id the page polls")
}
// The job runs against a non-git directory, so it finishes with a refusal rather
// than a merge. What matters is that the reported failure is the real reason.
deadline := time.Now().Add(10 * time.Second)
var finished struct {
Job struct {
State string `json:"state"`
Failure struct {
Reason string `json:"reason"`
} `json:"failure"`
} `json:"job"`
}
for time.Now().Before(deadline) {
w = doUpdate(router, http.MethodGet, "/api/system/update/job", "")
if err := json.Unmarshal(w.Body.Bytes(), &finished); err != nil {
t.Fatal(err)
}
if finished.Job.State != "running" {
break
}
time.Sleep(20 * time.Millisecond)
}
if finished.Job.State != "failed" {
t.Fatalf("job state = %q, want failed for a non-git install", finished.Job.State)
}
if finished.Job.Failure.Reason != "not_a_repo" {
t.Errorf("failure reason = %q, want not_a_repo", finished.Job.Failure.Reason)
}
}
func TestUpdateApplyRefusesRestartWhenNothingCanStartTheProcessAgain(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart":true}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: restarting without a supervisor would just stop the platform", w.Code)
}
if !strings.Contains(w.Body.String(), "重启") {
t.Errorf("the refusal must say why: %s", w.Body)
}
}
func TestUpdateApplyRejectsMalformedBody(t *testing.T) {
router, _ := newUpdateRouter(t.TempDir(), nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/apply", `{"restart": "yes please"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d body = %s, want 400 for a body that is not valid JSON", w.Code, w.Body)
}
// A rejected request must not have left a job behind: the page would then poll a
// phantom update.
if job := doUpdate(router, http.MethodGet, "/api/system/update/job", ""); !strings.Contains(job.Body.String(), `"job":null`) {
t.Errorf("a 400 apply must not create a job, got %s", job.Body)
}
}
func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) {
root := t.TempDir()
router, _ := newUpdateRouter(root, nil)
w := doUpdate(router, http.MethodPost, "/api/system/update/rollback", "")
if w.Code != http.StatusConflict {
t.Fatalf("status = %d body = %s, want 409", w.Code, w.Body)
}
var body map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["reason"] != "no_state" {
t.Errorf("reason = %v, want no_state so the page can say there is nothing to undo", body["reason"])
}
}