From 7d2465ec898c3096012f2c10739a836ea08d09aa Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Fri, 13 Nov 2026 10:05:42 +0800 Subject: [PATCH] fix(update): reload after confirming the accepted update is running (cherry picked from commit aa7a533497a6b524fbd4d7d330ca06b04414e2fb) --- cmd/server/main.go | 3 +- internal/app/app.go | 4 +- internal/app/update_restart_other.go | 4 +- internal/app/update_restart_unix.go | 19 ++- internal/app/update_restart_unix_test.go | 7 +- internal/handler/update.go | 61 +++++--- internal/handler/update_http_test.go | 6 +- web/static/i18n/ar-SA.json | 2 + web/static/i18n/en-US.json | 2 + web/static/i18n/ru-RU.json | 2 + web/static/i18n/zh-CN.json | 2 + web/static/js/update-ui.test.cjs | 72 +++++++-- web/static/js/update.js | 177 +++++++++++++++-------- web/templates/index.html | 2 +- 14 files changed, 257 insertions(+), 106 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1c4d2e4a..3c78bd75 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -22,6 +22,7 @@ import ( ) func main() { + updateActivation := app.TakeUpdateActivation() updateSessions := app.TakeUpdateSessionHandoff() var configPath = flag.String("config", "config.yaml", "Path to the configuration file") var httpsBootstrap = flag.Bool("https", false, "Enable HTTPS for the main site; uses an in-memory self-signed certificate when no cert/key is configured") @@ -157,7 +158,7 @@ func main() { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) // 创建应用 - application, err := app.New(cfg, log, cp) + application, err := app.New(cfg, log, cp, updateActivation) if err != nil { log.Fatal("应用初始化失败", "error", err) } diff --git a/internal/app/app.go b/internal/app/app.go index 36de93a6..83aeae35 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -79,7 +79,7 @@ type App struct { } // New 创建新应用 -func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) { +func New(cfg *config.Config, log *logger.Logger, configPath, updateActivation string) (*App, error) { toolGuard, err := toolguard.NewManager(cfg.EffectiveToolGuard()) if err != nil { return nil, fmt.Errorf("初始化调用拦截规则: %w", err) @@ -613,7 +613,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error }) updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, - app.updateRestart(configDir), configHandler.UpdateSource, configHandler.SetUpdateSource) + app.updateRestart(configDir), updateActivation, configHandler.UpdateSource, configHandler.SetUpdateSource) // 设置路由(使用 App 实例以便动态获取 handler) setupRoutes( diff --git a/internal/app/update_restart_other.go b/internal/app/update_restart_other.go index dc621f5a..600285ed 100644 --- a/internal/app/update_restart_other.go +++ b/internal/app/update_restart_other.go @@ -6,4 +6,6 @@ import "os" func TakeUpdateSessionHandoff() *os.File { return nil } -func (a *App) updateRestart(string) func() (func(), error) { return nil } +func TakeUpdateActivation() string { return "" } + +func (a *App) updateRestart(string) func(string) (func(), error) { return nil } diff --git a/internal/app/update_restart_unix.go b/internal/app/update_restart_unix.go index 94b31add..21a39f03 100644 --- a/internal/app/update_restart_unix.go +++ b/internal/app/update_restart_unix.go @@ -18,6 +18,19 @@ import ( ) const updateSessionsEnv = "CYBERSTRIKE_UPDATE_SESSIONS_FD" +const updateActivationEnv = "CYBERSTRIKE_UPDATE_ACTIVATION" + +// TakeUpdateActivation identifies the update that launched this process. +// A fresh start by a supervisor has no activation ID. +func TakeUpdateActivation() string { + value := os.Getenv(updateActivationEnv) + _ = os.Unsetenv(updateActivationEnv) + pid, id, ok := strings.Cut(value, ":") + if !ok || pid != strconv.Itoa(os.Getpid()) { + return "" + } + return id +} // TakeUpdateSessionHandoff claims the descriptor left by an in-place exec. // Do this before startup runs any subprocesses. @@ -61,7 +74,7 @@ func writeUpdateSessionHandoff(payload []byte) (*os.File, error) { // updateRestart only replaces the executable this instance was started from. // For example, go run and installs with a separate config directory must not // start a different executable just because the updater wrote one there. -func (a *App) updateRestart(root string) func() (func(), error) { +func (a *App) updateRestart(root string) func(string) (func(), error) { dir, err := filepath.Abs(root) if err != nil { return nil @@ -79,7 +92,7 @@ func (a *App) updateRestart(root string) func() (func(), error) { if err != nil || self != path { return nil } - return func() (func(), error) { + return func(activationID string) (func(), error) { info, err := os.Lstat(path) if err != nil { return nil, err @@ -95,7 +108,7 @@ func (a *App) updateRestart(root string) func() (func(), error) { // Keep the same PID, arguments, environment and working directory. // This works both in a terminal and under an existing supervisor. exec := func(payload []byte) error { - env := os.Environ() + env := append(os.Environ(), fmt.Sprintf("%s=%d:%s", updateActivationEnv, os.Getpid(), activationID)) if len(payload) != 0 { f, err := writeUpdateSessionHandoff(payload) if err == nil { diff --git a/internal/app/update_restart_unix_test.go b/internal/app/update_restart_unix_test.go index 1dbcdf55..62a307d7 100644 --- a/internal/app/update_restart_unix_test.go +++ b/internal/app/update_restart_unix_test.go @@ -105,6 +105,9 @@ func TestUpdateSessionHandoffChild(t *testing.T) { t.Fatal(err) } if token := os.Getenv("CSAI_RESTART_TEST_TOKEN"); token != "" { + if got := TakeUpdateActivation(); got != "upd-session-test" || os.Getenv(updateActivationEnv) != "" { + t.Fatalf("replacement did not consume the update activation: %q", got) + } if handoff == nil || os.Getenv(updateSessionsEnv) != "" { t.Fatal("replacement did not claim the private handoff") } @@ -142,7 +145,7 @@ func TestUpdateSessionHandoffChild(t *testing.T) { if err := os.Rename("next", update.Binary); err != nil { t.Fatal(err) } - restart, err := prepare() + restart, err := prepare("upd-session-test") if err != nil { t.Fatal(err) } @@ -162,7 +165,7 @@ func TestUpdateRestartChild(t *testing.T) { if err := os.Rename("next", update.Binary); err != nil { t.Fatal(err) } - restart, err := prepare() + restart, err := prepare("upd-standalone-test") if err != nil { t.Fatal(err) } diff --git a/internal/handler/update.go b/internal/handler/update.go index 657be816..4e15f8e5 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "runtime/debug" "strings" "sync" "time" @@ -34,8 +35,10 @@ type UpdateHandler struct { logger *zap.Logger audit *audit.Service // prepareRestart validates the replacement before the running service shuts down. - prepareRestart func() (func(), error) + prepareRestart func(activationID string) (func(), error) bootID string + activationID string + buildRevision string // source reports the configured update source (config.yaml update.repo) as the // operator last saved it; an empty string means "not configured" and the official // repository is used. A function rather than a value because the config can change @@ -82,14 +85,25 @@ func (j *updateJob) view() *updateJob { // 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, prepareRestart func() (func(), error), +func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func(string) (func(), error), activationID string, source func() string, saveSource func(repo string) error) *UpdateHandler { + var revision string + if info, ok := debug.ReadBuildInfo(); ok { + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" { + revision = setting.Value + break + } + } + } return &UpdateHandler{ root: root, logger: logger, audit: auditSvc, prepareRestart: prepareRestart, bootID: uuid.NewString(), + activationID: activationID, + buildRevision: revision, source: source, saveSource: saveSource, baseline: update.StampBinary(root, update.Options{Root: root}), @@ -145,6 +159,7 @@ func (h *UpdateHandler) sourceView() gin.H { // 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) { + c.Header("Cache-Control", "no-store") snap, err := update.Status(c.Request.Context(), h.options()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -152,15 +167,17 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { } needsRestart, binaryBuiltAt := h.restartPending() c.JSON(http.StatusOK, gin.H{ - "status": snap, - "job": h.latest(), - "canRestart": h.prepareRestart != nil, - "autoRestart": h.prepareRestart != nil, - "bootID": h.bootID, - "supervised": restartSupervised(), - "needsRestart": needsRestart, - "binaryBuiltAt": binaryBuiltAt, - "source": h.sourceView(), + "status": snap, + "job": h.latest(), + "canRestart": h.prepareRestart != nil, + "autoRestart": h.prepareRestart != nil, + "bootID": h.bootID, + "buildRevision": h.buildRevision, + "activationTracking": true, + "supervised": restartSupervised(), + "needsRestart": needsRestart, + "binaryBuiltAt": binaryBuiltAt, + "source": h.sourceView(), }) } @@ -168,6 +185,12 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { // a new boot from a revoked or expired session on the current boot. func (h *UpdateHandler) SetBootHeader(c *gin.Context) { c.Header("X-CyberStrike-Boot-ID", h.bootID) + c.Header("X-CyberStrike-Build-Revision", h.buildRevision) + activationID := h.activationID + if activationID == "" { + activationID = "-" + } + c.Header("X-CyberStrike-Activation-ID", activationID) c.Header("Cache-Control", "no-store") } @@ -260,11 +283,11 @@ func (h *UpdateHandler) Adopt(c *gin.Context) { } job, started := h.startJob("adopt", body.Restart) if !started { - c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) + c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID}) return } h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart}) - c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State}) + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID}) } // Apply answers POST /api/system/update/apply. It starts one job and returns immediately; @@ -287,12 +310,12 @@ func (h *UpdateHandler) Apply(c *gin.Context) { job, started := h.startJob("apply", body.Restart) if !started { - c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) + c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job, "bootID": h.bootID}) 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}) + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State, "bootID": h.bootID}) } // Restart answers POST /api/system/update/restart: replace this process so the binary @@ -327,14 +350,15 @@ func (h *UpdateHandler) Restart(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"}) return } - restart, err := h.prepareRestart() + activationID := uuid.NewString() + restart, err := h.prepareRestart(activationID) if err != nil { failure := &update.Error{Reason: "restart_unavailable", Message: "无法启动磁盘上的二进制:" + err.Error()} c.JSON(statusForError(failure), errorBody(failure)) return } h.record(c, "update.restart", "started", "重启安装以运行磁盘上的新二进制", nil) - c.JSON(http.StatusAccepted, gin.H{"restarting": true}) + c.JSON(http.StatusAccepted, gin.H{"restarting": true, "activation_id": activationID, "bootID": h.bootID}) scheduled = true time.AfterFunc(updateRestartDelay, func() { defer release(); restart() }) } @@ -342,6 +366,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) { // 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.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{"job": h.latest()}) } @@ -429,7 +454,7 @@ func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) { if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil { release, lockErr := update.LockInstall(opts) if lockErr == nil { - restart, prepareErr := h.prepareRestart() + restart, prepareErr := h.prepareRestart(job.ID) if prepareErr == nil { h.mu.Unlock() // Keep both CLI and HTTP writers out until the old executable is replaced. diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index 4a164be6..478ff765 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -36,11 +36,11 @@ func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) func newUpdateRouterWithSource(root string, restart func(), source func() string, save func(string) error) (*gin.Engine, *UpdateHandler) { gin.SetMode(gin.TestMode) - var prepare func() (func(), error) + var prepare func(string) (func(), error) if restart != nil { - prepare = func() (func(), error) { return restart, nil } + prepare = func(string) (func(), error) { return restart, nil } } - h := NewUpdateHandler(root, nil, nil, prepare, source, save) + h := NewUpdateHandler(root, nil, nil, prepare, "", source, save) router := gin.New() router.GET("/api/system/update", h.GetStatus) router.GET("/api/system/update/job", h.Job) diff --git a/web/static/i18n/ar-SA.json b/web/static/i18n/ar-SA.json index 6f87e584..53d2ee59 100644 --- a/web/static/i18n/ar-SA.json +++ b/web/static/i18n/ar-SA.json @@ -112,6 +112,7 @@ "rollbackTitle": "التراجع", "root": "دليل التثبيت", "branch": "الفرع المحلّي", + "runningBuild": "البنية قيد التشغيل", "commit": "الإيداع الحالي", "committedAt": "أُودِع في", "subject": "موضوع الإيداع", @@ -180,6 +181,7 @@ "restartOverlayBody": "ستُعاد الصفحة تلقائيًا عندما يصبح الإصدار الجديد جاهزًا.", "restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.", "restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.", + "restartOverlayUnexpected": "عادت الخدمة، لكن لم يُؤكّد تثبيت الإصدار المستهدف. ستستمر الصفحة في التحقق، ويمكنك تحديثها لمعرفة الإصدار الحالي.", "restartOverlayRefresh": "تحديث هذه الصفحة", "failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل", "resultTitle": "نتيجة التحديث", diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index d225707f..651c1ad4 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -112,6 +112,7 @@ "rollbackTitle": "Rollback", "root": "Install directory", "branch": "Local branch", + "runningBuild": "Running build", "commit": "Current commit", "committedAt": "Committed at", "subject": "Commit subject", @@ -180,6 +181,7 @@ "restartOverlayBody": "This page will reload automatically when the new version is ready.", "restartOverlaySlowRunning": "The service is still answering as the old process - the restart does not seem to have happened. Use the button below to look at the current state.", "restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.", + "restartOverlayUnexpected": "The service is back, but this update has not been confirmed. This page will keep checking. You can also refresh to see the current version.", "restartOverlayRefresh": "Refresh this page", "failureNoMessage": "The server gave no failure message", "resultTitle": "Update result", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index eb2fe9a1..cdfb2523 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -112,6 +112,7 @@ "rollbackTitle": "Откат", "root": "Каталог установки", "branch": "Локальная ветка", + "runningBuild": "Запущенная сборка", "commit": "Текущий коммит", "committedAt": "Дата коммита", "subject": "Заголовок коммита", @@ -180,6 +181,7 @@ "restartOverlayBody": "Когда новая версия будет готова, страница обновится автоматически.", "restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.", "restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.", + "restartOverlayUnexpected": "Служба снова доступна, но установка нужной сборки не подтверждена. Проверка продолжится. Текущую версию можно увидеть, обновив страницу.", "restartOverlayRefresh": "Обновить страницу", "failureNoMessage": "Сервер не передал сообщение об ошибке", "resultTitle": "Результат обновления", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 7f55a325..05374ddf 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -112,6 +112,7 @@ "rollbackTitle": "回滚", "root": "安装目录", "branch": "本机分支", + "runningBuild": "运行中的构建", "commit": "当前提交", "committedAt": "提交时间", "subject": "提交说明", @@ -180,6 +181,7 @@ "restartOverlayBody": "切换完成后,页面会自动恢复。", "restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。", "restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。", + "restartOverlayUnexpected": "服务已恢复,但未能确认本次更新的目标构建已生效。页面会继续检查,也可以刷新查看当前版本。", "restartOverlayRefresh": "立即刷新页面", "failureNoMessage": "服务端没有给出失败说明", "resultTitle": "更新结果", diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 4c885bd3..2d90d74a 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -96,6 +96,7 @@ function envelope(status, job, canRestart, source, flags) { supervised: !!(flags && flags.supervised), autoRestart: flags?.autoRestart, bootID: flags?.bootID, + activationTracking: flags?.activationTracking, needsRestart: !!(flags && flags.needsRestart), binaryBuiltAt: (flags && flags.binaryBuiltAt) || '', source: source || emptySource, @@ -316,7 +317,11 @@ function harness(options) { if (typeof answer === 'object') { return Promise.resolve({ status: answer.status, - headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null }, + headers: { get: name => ({ + 'X-CyberStrike-Boot-ID': answer.bootID, + 'X-CyberStrike-Activation-ID': answer.activationID, + 'X-CyberStrike-Build-Revision': answer.revision, + })[name] }, json: () => Promise.resolve(answer.body || {}), }); } @@ -407,8 +412,8 @@ function harness(options) { }, setConfirm(v) { confirmAnswer = v; }, liveTimers() { return timers.filter(t => !t.cleared && !t.deferred); }, - tick() { - const live = h.liveTimers(); + tick(ms) { + const live = h.liveTimers().filter(t => ms === undefined || t.ms === ms); if (!live.length) throw new Error('no poll timer is armed'); return live[live.length - 1].fn(); }, @@ -439,7 +444,7 @@ test('the console lives inside the settings page as its own section', () => { 'settings-section-update must live inside page-settings'); assert.match(template, /id="settings-section-update"[\s\S]{0,700}?id="update-console"/); assert.match(template, /id="update-console"/); - assert.match(template, / - +