From 46c38a2d10bc81ddd69de390ae42a7e4d324fef2 Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:34:13 +0800 Subject: [PATCH] fix(update): activate new binaries without a supervisor (cherry picked from commit 9ed4e19f5ea600e185771368c540025df80b11bf) --- docs/en-US/deployment.md | 11 +-- docs/en-US/developer-guide.md | 26 +++--- docs/zh-CN/deployment.md | 7 +- docs/zh-CN/developer-guide.md | 18 ++-- internal/app/app.go | 18 ++-- internal/app/update_restart_other.go | 5 ++ internal/app/update_restart_unix.go | 59 ++++++++++++ internal/app/update_restart_unix_test.go | 85 ++++++++++++++++++ internal/handler/openapi.go | 4 +- internal/handler/update.go | 110 ++++++++++++++--------- internal/handler/update_http_test.go | 10 ++- web/static/i18n/ar-SA.json | 18 ++-- web/static/i18n/en-US.json | 18 ++-- web/static/i18n/ru-RU.json | 18 ++-- web/static/i18n/zh-CN.json | 18 ++-- web/static/js/update-ui.test.cjs | 70 ++++++++++----- web/static/js/update.js | 91 +++++++++++++------ 17 files changed, 407 insertions(+), 179 deletions(-) create mode 100644 internal/app/update_restart_other.go create mode 100644 internal/app/update_restart_unix.go create mode 100644 internal/app/update_restart_unix_test.go diff --git a/docs/en-US/deployment.md b/docs/en-US/deployment.md index 0e9d82cc..f91bc7df 100644 --- a/docs/en-US/deployment.md +++ b/docs/en-US/deployment.md @@ -87,11 +87,12 @@ data/ config.yaml` - is put aside and restored, and the result names every file is modified locally or the local history has diverged from the source it refuses and says why instead of forcing. See [the developer guide](developer-guide.md) for the full semantics. -When a supervisor is detected (systemd/launchd startup markers in the environment), the "exit after -updating" tick is pre-ticked: the process stands down after a successful update, the supervisor brings -the new binary back, and the page reloads itself once the service answers (sessions live in memory, so -one fresh login follows). Forgot the tick, or swapped the binary from the CLI? The console carries a -persistent banner with **Restart now** - no shell login needed. +On macOS and Linux, when started from the `cyberstrike-ai` binary alongside its config, the console +defaults to switching to the new binary after a successful build. The old service remains available +during the build. Connections briefly drop at the switch and the page reloads when the new service +answers. Sign in again afterward because sessions live in memory. A supervisor is still useful for +starting at boot and recovering from crashes. If you defer the switch or replace the binary from the +CLI, the console offers **Restart now** on installations that support the switch. The update source is a single repository address in the `update` section of `config.yaml` (`repo`), or right on the console page: the official repository, your own fork, somebody else's repository or a diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index 96081f18..9aabe98e 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -48,7 +48,7 @@ disagree about why an update was refused. | entry | how it is used | |---|---| -| console | System settings -> One-click update (`#system-update`, the old deep link still works). Opening this section reads the local tree only and does not go online; **Check** is what performs the fetch; **Update** starts a job the page polls; the "exit after updating" tick is pre-ticked when a supervisor is detected (launchd/systemd startup markers), and while the process is down the page watches for its return and reloads itself (sessions live in memory, so a fresh login follows); a rollback button sits below | +| console | System settings -> One-click update (`#system-update`, the old deep link still works). **Update** starts a job the page polls. Supported installations switch to the newly built binary by default, and the page reloads once the service comes back. A rollback button sits below. | | REST | `GET /api/system/update` (state on disk, no network), `POST /api/system/update/check` (fetch, then report the gap), `POST /api/system/update/apply` (`202` with `job_id`, polled through `GET /api/system/update/job`), `POST /api/system/update/rollback` | | CLI | `./cyberstrike-ai -check-update`, `./cyberstrike-ai -update`, `./cyberstrike-ai -update-rollback`. The install root is the directory holding `--config`, or the current directory when `--config` was not given | @@ -150,28 +150,24 @@ again after an interruption to finish recovery; the console reports `rollbackPen updates. Keep the recovery record, backup directory and `.prev` file. If recorded binary hashes do not match the files on disk, recovery refuses to overwrite them. -### The two restart cases +### Switching to the new binary -The process only stands down (graceful `Shutdown`, then exit 0) when the request explicitly says -`restart: true` **and** a restart hook was wired at startup. Whether it actually comes back is up to a -supervisor (systemd, `run.sh`), and the page says exactly that rather than promising a boot that may -not happen. Asking for a restart with no hook wired is a `400`, instead of stopping the service and -claiming it restarted. +On macOS and Linux, when started from the `cyberstrike-ai` binary alongside its config, the console +sends `restart: true` by default. After a successful build and binary swap, the service closes its +application resources and executes the new binary with the same PID, arguments, environment and +working directory. Installations that cannot switch in place return `400` on an explicit restart +request. CLI updates and REST updates without `restart: true` only replace files on disk. **A pending binary is a durable state, and it can be activated later.** The status endpoint compares the binary identity captured at startup (size + nanosecond mtime) with what is on disk now: after an update, a rollback or a CLI run swapped the file without a restart, `needsRestart` is true, `binaryBuiltAt` says when it was built, and the console grows a persistent banner with **Restart now** (`POST /api/system/update/restart`; `409 nothing_pending` when there is nothing to activate, `409` -while a job runs, `400` without a hook). The `supervised` field comes from environment markers -(launchd's `XPC_SERVICE_NAME`, systemd's `INVOCATION_ID`/`JOURNAL_STREAM`) and only decides the tick's -default. +while a job runs, `400` when unavailable). The console uses the `autoRestart` field as its default. -**And the page recovers by itself.** While the process is down the console becomes a recovery view -that probes the status endpoint every 2 seconds: a 200 means the old process is still answering (keep -waiting), a dead connection means it is on its way out, and a 401 from a process that no longer knows -the session is the proof the restart happened - the page then `location.replace`s itself back to -`#system-update`. Leaving the console stops the watching instead of yanking the user back. +**The page reloads automatically.** The console polls the session validation endpoint every two +seconds for the new boot marker. Sessions live in memory, so users sign in again after the switch. +Leaving the console stops the polling. ## Adding a Business Module diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index 1cd0c344..0ec3c1b1 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -189,9 +189,10 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal` 并被页面标出,装好 go 后再点一次「一键更新」即补编译。 细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。 -检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、 -由守护拉起新二进制,页面会在服务回来时自动刷新(会话在内存里,需要重新登录一次)。忘勾、或在命令行 -换过二进制时,控制台顶部有常驻横幅和「立即重启服务」补上这一步——不必再登录服务器手动重启。 +在 macOS/Linux 上,从配置文件同目录的 `cyberstrike-ai` 启动时,控制台默认在构建成功后自动切换到新二进制。 +构建期间旧服务继续提供服务;切换时连接会短暂中断,页面在新服务应答后自动刷新(内存中的登录会话需重新登录)。 +这一步不依赖 systemd 或 launchd;长期部署仍建议用守护进程做开机自启和异常恢复。取消自动切换、或在命令行换过 +二进制后,可以在支持自动切换的安装上用控制台的「立即重启服务」启用磁盘上的版本。 更新源是 `config.yaml` `update` 段里的**一个仓库地址**(`repo`),也可以直接在控制台「一键更新」页填写保存: 想跟随官方仓库、自己的二开、别人的二开或镜像,改的都是这一处;不写就是官方仓库。**用 Release 包解压安装、 diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index c9d752bf..d6b14415 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -54,7 +54,7 @@ go run ./cmd/server --config config.yaml | 入口 | 怎么用 | |---|---| -| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。打开这一区只读本机状态、不联网;点「检查更新」才去 fetch;「一键更新」发起任务并轮询进度;"更新完成后退出进程"在检测到守护进程(launchd/systemd 的启动标记)时默认勾选,重启期间页面守着重连、新进程一应答就自动刷新(会话在内存里,刷新后需重新登录);另有回滚按钮 | +| 控制台 | 「系统设置 → 一键更新」(`#system-update` 老深链仍可用)。「一键更新」发起任务并轮询进度;支持自动切换的安装默认在构建成功后切到新二进制,页面在服务恢复后自动刷新;另有回滚按钮 | | REST | `GET /api/system/update`(磁盘现状,不联网)、`POST /api/system/update/check`(fetch 后报告差集)、`POST /api/system/update/apply`(`202` 返回 `job_id`,用 `GET /api/system/update/job` 轮询)、`POST /api/system/update/rollback` | | CLI | `./cyberstrike-ai -check-update`、`./cyberstrike-ai -update`、`./cyberstrike-ai -update-rollback`。安装目录 = `--config` 所在目录,未给 `--config` 时是当前目录 | @@ -141,21 +141,19 @@ update: `rollbackPending` 提示会暂停新更新。保留恢复记录、备份目录和 `.prev`,不要手动删除它们。 如果记录中的二进制哈希无法与现场对应,会拒绝覆盖并保留现场。 -### 重启的两种情形 +### 切换到新二进制 -进程只有在请求显式带 `restart: true`、且启动时装配了重启钩子时才会优雅退出(`Shutdown` 后退出码 0)。 -能不能被拉起来取决于外部守护(systemd、`run.sh`),页面如实这么写,而不是承诺一次可能不发生的启动; -没装配钩子时要求重启是 `400`,而不是先停掉服务再自称重启了。 +macOS/Linux 上,从配置文件同目录的 `cyberstrike-ai` 启动时,控制台默认发送 `restart: true`。 +只有成功构建并换入二进制才会切换:服务先关闭应用资源,再用相同 PID、启动参数、环境和工作目录执行新二进制。 +不支持的启动方式不提供自动切换;REST 显式请求重启时返回 `400`。REST 不传该字段和 CLI 更新都只换磁盘文件。 **待生效状态是持久的、可补重启。** 状态接口对比"启动时记下的二进制身份(大小 + 纳秒 mtime)"与磁盘 现值:更新、回滚或 CLI 换过二进制而没重启时,`needsRestart` 为真、`binaryBuiltAt` 给出构建时间, 控制台顶部出现常驻横幅与「立即重启服务」(`POST /api/system/update/restart`;没有待生效版本时 -`409 nothing_pending`、有任务在跑 `409`、没钩子 `400`)。`supervised` 字段来自环境标记(launchd 的 -`XPC_SERVICE_NAME`、systemd 的 `INVOCATION_ID`/`JOURNAL_STREAM`),只用来决定勾选框的默认值。 +`409 nothing_pending`、有任务在跑 `409`、不支持时 `400`)。控制台根据 `autoRestart` 决定默认值。 -**重启后页面自己回来。** 重启期间控制台换成自恢复视图,每 2 秒探一次状态接口:旧进程还在应答(200) -就继续等;连不上说明正在退出;新进程接客但对旧会话只回 401——这就是"重启已完成"的判据,页面随即 -`location.replace` 回 `#system-update` 整页刷新。离开控制台则静默停表,不把已经走开的用户拽回来。 +**重启后页面自动刷新。** 控制台每 2 秒通过会话验证接口探测服务启动标记,收到新标记才刷新。 +现有登录会话保存在内存中,切换后需要重新登录。离开控制台则停止探测。 ## 数据库 diff --git a/internal/app/app.go b/internal/app/app.go index 891c22c1..9e80ff39 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -44,6 +44,7 @@ import ( // App 应用 type App struct { + shutdownOnce sync.Once config *config.Config logger *logger.Logger router *gin.Engine @@ -611,14 +612,8 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error return nil }) - // The one-click update acts on this installation's own tree, rooted where the live config - // file is. Its restart hook exits after a graceful shutdown, which is only "a restart" when - // something supervises the process - so the endpoint reports whether it is standing down or - // whether somebody has to start it again, instead of promising a boot that may not happen. - updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, func() { - app.Shutdown() - os.Exit(0) - }, configHandler.UpdateSource, configHandler.SetUpdateSource) + updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, + app.updateRestart(configDir), configHandler.UpdateSource, configHandler.SetUpdateSource) // 设置路由(使用 App 实例以便动态获取 handler) setupRoutes( @@ -802,6 +797,11 @@ func (a *App) RunWithContext(ctx context.Context) error { // Shutdown 关闭应用 func (a *App) Shutdown() { + + a.shutdownOnce.Do(a.shutdown) +} + +func (a *App) shutdown() { if a.agentHandler != nil { a.agentHandler.ShutdownTasks() } @@ -970,7 +970,7 @@ func setupRoutes( authRoutes.POST("/login", security.RateLimitMiddleware(loginRL), authHandler.Login) authRoutes.POST("/logout", security.AuthMiddleware(authManager), authHandler.Logout) authRoutes.POST("/change-password", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), authHandler.ChangePassword) - authRoutes.GET("/validate", security.AuthMiddleware(authManager), authHandler.Validate) + authRoutes.GET("/validate", updateHandler.SetBootHeader, security.AuthMiddleware(authManager), authHandler.Validate) authRoutes.POST("/robot-binding-code", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.CreateRobotBindingCode) authRoutes.GET("/robot-bindings", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.ListMyRobotBindings) authRoutes.DELETE("/robot-bindings/:id", security.AuthMiddleware(authManager), security.RequirePermission("auth:self"), robotHandler.DeleteMyRobotBinding) diff --git a/internal/app/update_restart_other.go b/internal/app/update_restart_other.go new file mode 100644 index 00000000..9a7b82e8 --- /dev/null +++ b/internal/app/update_restart_other.go @@ -0,0 +1,5 @@ +//go:build !darwin && !linux + +package app + +func (a *App) updateRestart(string) func() (func(), error) { return nil } diff --git a/internal/app/update_restart_unix.go b/internal/app/update_restart_unix.go new file mode 100644 index 00000000..1d0678f1 --- /dev/null +++ b/internal/app/update_restart_unix.go @@ -0,0 +1,59 @@ +//go:build darwin || linux + +package app + +import ( + "fmt" + "os" + "path/filepath" + + "cyberstrike-ai/internal/update" + + "go.uber.org/zap" + "golang.org/x/sys/unix" +) + +// 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) { + dir, err := filepath.Abs(root) + if err != nil { + return nil + } + dir, err = filepath.EvalSymlinks(dir) + if err != nil { + return nil + } + path := filepath.Join(dir, update.Binary) + self, err := os.Executable() + if err != nil { + return nil + } + self, err = filepath.EvalSymlinks(self) + if err != nil || self != path { + return nil + } + return func() (func(), error) { + info, err := os.Lstat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("不是普通可执行文件:%s", path) + } + if err := unix.Access(path, unix.X_OK); err != nil { + return nil, err + } + return func() { + a.Shutdown() + // Keep the same PID, arguments, environment and working directory. + // This works both in a terminal and under an existing supervisor. + if err := unix.Exec(path, os.Args, os.Environ()); err != nil { + a.logger.Error("无法启动更新后的二进制", zap.Error(err)) + _ = a.logger.Sync() + os.Exit(1) + } + }, nil + } +} diff --git a/internal/app/update_restart_unix_test.go b/internal/app/update_restart_unix_test.go new file mode 100644 index 00000000..9ba8e215 --- /dev/null +++ b/internal/app/update_restart_unix_test.go @@ -0,0 +1,85 @@ +//go:build darwin || linux + +package app + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "cyberstrike-ai/internal/update" +) + +func TestStandaloneUpdateRestartExecsReplacement(t *testing.T) { + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + binary := filepath.Join(root, update.Binary) + src, err := os.Open(self) + if err != nil { + t.Fatal(err) + } + defer src.Close() + dst, err := os.OpenFile(binary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o755) + if err != nil { + t.Fatal(err) + } + _, copyErr := io.Copy(dst, src) + if closeErr := dst.Close(); closeErr != nil { + t.Fatal(closeErr) + } + if copyErr != nil { + t.Fatal(copyErr) + } + replacement := filepath.Join(root, "next") + if err := os.WriteFile(replacement, []byte("#!/bin/sh\nprintf 'replacement:%s:%s:%s:%s\\n' \"$$\" \"$PWD\" \"$CSAI_RESTART_TEST\" \"$1\"\n"), 0o755); err != nil { + t.Fatal(err) + } + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "-test.run=^TestUpdateRestartChild$") + cmd.Dir = canonicalRoot + // No launchd/systemd markers or supervisor in this child. + cmd.Env = []string{"CSAI_RESTART_TEST=standalone"} + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("restart helper: %v\n%s", err, out) + } + want := fmt.Sprintf("replacement:%d:%s:standalone:-test.run=^TestUpdateRestartChild$", cmd.Process.Pid, canonicalRoot) + if !strings.Contains(string(out), want) { + t.Fatalf("new executable did not inherit PID, cwd, environment and arguments: %q", out) + } +} + +func TestUpdateRestartChild(t *testing.T) { + if os.Getenv("CSAI_RESTART_TEST") != "standalone" { + return + } + var app App + prepare := app.updateRestart(".") + if prepare == nil { + t.Fatal("standalone install did not offer restart") + } + if err := os.Rename("next", update.Binary); err != nil { + t.Fatal(err) + } + restart, err := prepare() + if err != nil { + t.Fatal(err) + } + restart() + t.Fatal("exec returned instead of switching executables") +} diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index 26b22115..e8cae135 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -3457,7 +3457,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "type": "object", "properties": map[string]interface{}{ "confirm": map[string]interface{}{"type": "boolean", "description": "false/缺省=只预览;true=确认执行"}, - "restart": map[string]interface{}{"type": "boolean", "description": "接入成功后退出进程交给外部守护拉起,默认 false"}, + "restart": map[string]interface{}{"type": "boolean", "description": "接入并构建成功后自动切换到新二进制,需要 canRestart,默认 false"}, }, }, }, @@ -6549,7 +6549,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { "properties": map[string]interface{}{ "restart": map[string]interface{}{ "type": "boolean", - "description": "更新成功后优雅关闭并退出进程;只在有外部守护时才会被拉起,默认 false", + "description": "更新并构建成功后自动切换到新二进制,需要 canRestart,默认 false", }, }, }, diff --git a/internal/handler/update.go b/internal/handler/update.go index 3cdcfce6..657be816 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -12,6 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "go.uber.org/zap" ) @@ -32,10 +33,9 @@ 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() + // prepareRestart validates the replacement before the running service shuts down. + prepareRestart func() (func(), error) + bootID 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 @@ -59,15 +59,16 @@ type UpdateHandler struct { // 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"` - Kind string `json:"kind"` // apply | adopt - 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"` + ID string `json:"id"` + Kind string `json:"kind"` // apply | adopt + 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"` + RestartError string `json:"restartError,omitempty"` } // view copies a job for transport, because its fields are written by the job's own @@ -81,17 +82,18 @@ 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, restart func(), +func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, prepareRestart func() (func(), error), source func() string, saveSource func(repo string) error) *UpdateHandler { return &UpdateHandler{ - root: root, - logger: logger, - audit: auditSvc, - restart: restart, - source: source, - saveSource: saveSource, - baseline: update.StampBinary(root, update.Options{Root: root}), - jobs: map[string]*updateJob{}, + root: root, + logger: logger, + audit: auditSvc, + prepareRestart: prepareRestart, + bootID: uuid.NewString(), + source: source, + saveSource: saveSource, + baseline: update.StampBinary(root, update.Options{Root: root}), + jobs: map[string]*updateJob{}, } } @@ -109,10 +111,8 @@ func (h *UpdateHandler) restartPending() (bool, string) { return true, builtAt } -// restartSupervised reports whether something outside this process is set up to bring it -// back: launchd (XPC_SERVICE_NAME) and systemd (INVOCATION_ID / JOURNAL_STREAM) both leave -// a marker in the environment. It is only the page's default for the restart tick - the -// operator's own checkbox remains the decision. +// restartSupervised is a legacy status hint. These environment markers do not +// prove that the supervisor is configured to restart the service. func restartSupervised() bool { return strings.TrimSpace(os.Getenv("XPC_SERVICE_NAME")) != "" || strings.TrimSpace(os.Getenv("INVOCATION_ID")) != "" || @@ -154,7 +154,9 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": snap, "job": h.latest(), - "canRestart": h.restart != nil, + "canRestart": h.prepareRestart != nil, + "autoRestart": h.prepareRestart != nil, + "bootID": h.bootID, "supervised": restartSupervised(), "needsRestart": needsRestart, "binaryBuiltAt": binaryBuiltAt, @@ -162,6 +164,13 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { }) } +// SetBootHeader runs before auth on the validation route so a client can tell +// 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("Cache-Control", "no-store") +} + // 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. @@ -235,7 +244,7 @@ func (h *UpdateHandler) Adopt(c *gin.Context) { return } } - if body.Confirm && body.Restart && h.restart == nil { + if body.Confirm && body.Restart && h.prepareRestart == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,接入完成后请由外部进程管理器重启服务"}) return } @@ -271,7 +280,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) { return } } - if body.Restart && h.restart == nil { + if body.Restart && h.prepareRestart == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,更新完成后请由外部进程管理器重启服务"}) return } @@ -286,7 +295,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) { c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State}) } -// Restart answers POST /api/system/update/restart: stand this process down so the binary +// Restart answers POST /api/system/update/restart: replace this process so the binary // already on disk - an update or a rollback that ran without one - becomes the thing // answering requests. It refuses when there is nothing to activate, because a bounce that // cannot change anything is a dropped service for no reason. @@ -306,7 +315,7 @@ func (h *UpdateHandler) Restart(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": "更新进行中,等它结束再重启", "job": busy}) return } - if h.restart == nil { + if h.prepareRestart == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,请由外部进程管理器重启服务"}) return } @@ -318,10 +327,16 @@ func (h *UpdateHandler) Restart(c *gin.Context) { c.JSON(http.StatusConflict, gin.H{"error": "磁盘上的二进制不存在,无法重启", "reason": "no_binary"}) return } + restart, err := h.prepareRestart() + 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}) scheduled = true - time.AfterFunc(updateRestartDelay, func() { defer release(); h.restart() }) + time.AfterFunc(updateRestartDelay, func() { defer release(); restart() }) } // Job answers GET /api/system/update/job: the running job, or the most recent one, with @@ -411,22 +426,29 @@ 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 { + if job.Restart && finish == "succeeded" && res != nil && res.BinaryBuilt && h.prepareRestart != nil { 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 + restart, prepareErr := h.prepareRestart() + if prepareErr == nil { + h.mu.Unlock() + // Keep both CLI and HTTP writers out until the old executable is replaced. + time.AfterFunc(updateRestartDelay, func() { + defer release() + defer func() { h.mu.Lock(); h.active = ""; h.mu.Unlock() }() + restart() + }) + return + } + release() + job.RestartError = prepareErr.Error() + } else { + // Another writer acquired the lock after the build. + job.RestartError = lockErr.Error() } - // Another process began an operation after the build. The binary is - // ready, but shutting down now would interrupt that operation. - job.Restart = false } + // A completed job only reports restartRequested if it actually scheduled one. + job.Restart = false h.active = "" h.mu.Unlock() }() diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index 3f26cd76..4a164be6 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -36,7 +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) - h := NewUpdateHandler(root, nil, nil, restart, source, save) + var prepare func() (func(), error) + if restart != nil { + prepare = func() (func(), error) { return restart, nil } + } + 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) @@ -313,7 +317,7 @@ func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) { t.Fatalf("replacing the binary must be reported as pending, with when it was built: %+v", after) } - // Supervision is read from the markers launchd/systemd leave in the environment. + // Retain the supervision hint for older clients; it does not control autoRestart. t.Setenv("XPC_SERVICE_NAME", "") t.Setenv("INVOCATION_ID", "") t.Setenv("JOURNAL_STREAM", "") @@ -322,7 +326,7 @@ func TestUpdateStatusReportsAPendingBinaryAndSupervision(t *testing.T) { } t.Setenv("XPC_SERVICE_NAME", "com.example.thing") if !read().Supervised { - t.Error("XPC_SERVICE_NAME is launchd's own marker; the page's default must follow it") + t.Error("XPC_SERVICE_NAME is launchd's own marker") } } diff --git a/web/static/i18n/ar-SA.json b/web/static/i18n/ar-SA.json index 735612a6..a796963f 100644 --- a/web/static/i18n/ar-SA.json +++ b/web/static/i18n/ar-SA.json @@ -147,10 +147,10 @@ "blockedLocalEdits": "{{n}} من ملفّات مصدر المنتج معدّلة محلّيًا ولن يستبدلها التحديث: أودِعها أو تراجع عنها أوّلًا.", "blockedDiverged": "السجلّ المحلّي متقدّم بمقدار {{ahead}} إيداع ومتأخّر بمقدار {{behind}}: هذا دمج وليس تنزيلًا، لذا عالجه يدويًا.", "blockedNoUpdate": "مصدر التحديث لا يحتوي على أيّ شيء أحدث من هذا الجهاز (اضغط على التحقّق من التحديثات إن لم تكن قد راجعت بعد).", - "restartChoiceLabel": "إنهاء العملية بعد التحديث (لن يعيد تشغيلها إلّا مشرف خارجي مثل systemd أو run.sh؛ أعد تشغيلها بنفسك إن لم يشرف عليها شيء)", - "restartNotWired": "لم يوفّر هذا التشغيل خطّاف إعادة تشغيل: أعد تشغيل الخدمة بنفسك بعد التحديث.", - "confirmRestartYes": "محدّد: تنتهي العملية بمجرّد نجاح التحديث، ولن يعيد تشغيلها إلّا مشرف خارجي (systemd، run.sh، ...).", - "confirmRestartNo": "غير محدّد: تستمرّ العملية في تشغيل الملف الثنائي القديم حتّى تعيد تشغيلها بنفسك.", + "restartChoiceLabel": "التبديل تلقائيًا إلى الإصدار الجديد", + "restartNotWired": "لا تدعم طريقة التشغيل الحالية التبديل التلقائي. بعد استبدال الملف التنفيذي، أعد تشغيل الخدمة بالطريقة المعتادة في بيئتك.", + "confirmRestartYes": "تظلّ الخدمة متاحة أثناء البناء، ثم تعيد التشغيل لفترة وجيزة للتبديل إلى الإصدار الجديد. تُعاد الصفحة تلقائيًا.", + "confirmRestartNo": "يستمر الإصدار القديم في العمل إلى أن تعيد تشغيل الخدمة.", "confirmApply": "على وشك التنفيذ: التحديث من {{repo}} إلى {{target}} ({{n}} إيداع جديد، هذا الجهاز عند {{commit}}) -> إعادة بناء الملف الثنائي -> نسخ القديم احتياطيًا واستبداله.", "confirmBuildPending": "على وشك التنفيذ: بناء الملف الثنائي المفقود للمصدر الموجود بالفعل (هذا الجهاز عند {{commit}}) -> إعادة البناء -> نسخ القديم احتياطيًا واستبدال الملف الثنائي القديم.", "applyAccepted": "بدأ التحديث (المهمّة {{id}})؛ يتحدّث التقدّم كلّ 1.5 ثانية.", @@ -169,7 +169,7 @@ "stateSucceeded": "نجح", "stateFailed": "فشل", "stateUnknown": "حالة غير معروفة", - "restartRequested": "طُلب الإنهاء بعد الاكتمال", + "restartRequested": "جرت جدولة التبديل التلقائي", "jobPollFailed": "تعذّرت قراءة التقدّم: {{reason}}", "jobDoneToast": "انتهى التحديث", "buildDoneToast": "انتهى البناء", @@ -178,13 +178,13 @@ "restartBanner": "تمّ التحديث إلى {{commit}}، لكنّ هذه العملية لا تزال تشغّل البناء القديم (بُني الملف الثنائي على القرص في {{builtAt}}). يسري المفعول بعد إعادة التشغيل.", "restartBannerNoCommit": "تمّ استبدال الملف الثنائي على القرص (بُني في {{builtAt}})، لكنّ هذه العملية لا تزال تشغّل البناء القديم؛ يسري المفعول بعد إعادة التشغيل.", "restartNowBtn": "إعادة التشغيل الآن", - "restartNowConfirm": "ستنتهي عملية الخدمة؛ مع وجود مشرف (systemd/launchd) تعود النسخة الجديدة خلال ثوانٍ وتعيد هذه الصفحة تحميل نفسها. ومن دونه تبقى الخدمة متوقّفة حتّى تشغّلها. إعادة التشغيل الآن؟", - "restartRequestedToast": "طُلبت إعادة التشغيل: يعيدها المشرف وتتعافى هذه الصفحة من تلقاء نفسها", + "restartNowConfirm": "ستعيد الخدمة تشغيلها لفترة وجيزة لتفعيل الإصدار الموجود على القرص، ثم تُعاد الصفحة تلقائيًا. هل تريد التبديل الآن؟", + "restartRequestedToast": "جارٍ تبديل الإصدار. تُعاد الصفحة عند عودة الخدمة.", "restartFailed": "فشل طلب إعادة التشغيل: {{reason}}", "restartOverlayTitle": "جارٍ إعادة تشغيل الخدمة…", - "restartOverlayBody": "تعيد هذه الصفحة تحميل نفسها بمجرّد أن تستجيب العملية الجديدة (ستنتهي جلستك، لذا سيتبع ذلك تسجيل دخول جديد).", + "restartOverlayBody": "تُعاد الصفحة عند عودة الخدمة. ستحتاج إلى تسجيل الدخول مرة أخرى.", "restartOverlaySlowRunning": "لا تزال الخدمة تستجيب بوصفها العملية القديمة - يبدو أنّ إعادة التشغيل لم تحدث. استخدم الزرّ أدناه لمعاينة الحالة الحالية.", - "restartOverlaySlowDown": "لم تعد الخدمة. إن لم يشرف عليها شيء (systemd/launchd) فلن تبدأ من تلقاء نفسها؛ شغّلها ثمّ حدّث هذه الصفحة.", + "restartOverlaySlowDown": "لم تعد الخدمة بعد. ستواصل الصفحة المحاولة. راجع سجلّات الخدمة إذا استمر الانقطاع.", "restartOverlayRefresh": "تحديث هذه الصفحة", "failureNoMessage": "لم يقدّم الخادم أيّ رسالة فشل", "resultTitle": "نتيجة التحديث", diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 01b85ae0..8e6a91af 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -147,10 +147,10 @@ "blockedLocalEdits": "{{n}} product source files are edited locally and an update will not overwrite them: commit or revert them first.", "blockedDiverged": "Local history is {{ahead}} commits ahead and {{behind}} behind: that is a merge, not a download, so handle it by hand.", "blockedNoUpdate": "The update source has nothing newer than this machine (press Check for updates if you have not looked yet).", - "restartChoiceLabel": "Exit the process after the update (only an external supervisor such as systemd or run.sh will bring it back; restart it yourself if nothing supervises it)", - "restartNotWired": "This start did not provide a restart hook: restart the service yourself after the update.", - "confirmRestartYes": "Selected: the process exits once the update succeeds, and only an external supervisor (systemd, run.sh, ...) will bring it back.", - "confirmRestartNo": "Not selected: the process keeps running the old binary until you restart it yourself.", + "restartChoiceLabel": "Switch to the new version automatically", + "restartNotWired": "This installation cannot switch versions automatically. Restart the service through your deployment setup after the binary is replaced.", + "confirmRestartYes": "The service stays available during the build, then briefly restarts to switch versions. This page reloads automatically.", + "confirmRestartNo": "The old version keeps running until you restart the service.", "confirmApply": "About to run: update from {{repo}} to {{target}} ({{n}} new commits, this machine is at {{commit}}) -> rebuild the binary -> back up and replace the old one.", "confirmBuildPending": "About to run: build the missing binary for the source already in place (this machine is at {{commit}}) -> rebuild -> back up and replace the old binary.", "applyAccepted": "Update started (job {{id}}); progress refreshes every 1.5 seconds.", @@ -169,7 +169,7 @@ "stateSucceeded": "Succeeded", "stateFailed": "Failed", "stateUnknown": "Unknown state", - "restartRequested": "Exit after completion requested", + "restartRequested": "Automatic switch scheduled", "jobPollFailed": "Failed to read the progress: {{reason}}", "jobDoneToast": "Update finished", "buildDoneToast": "Build finished", @@ -178,13 +178,13 @@ "restartBanner": "Updated to {{commit}}, but this process is still running the old build (the binary on disk was built at {{builtAt}}). It takes effect after a restart.", "restartBannerNoCommit": "The binary on disk was replaced (built at {{builtAt}}), but this process is still running the old build; it takes effect after a restart.", "restartNowBtn": "Restart now", - "restartNowConfirm": "The service process will exit; with a supervisor (systemd/launchd) the new version is back in seconds and this page reloads itself. Without one the service stays stopped until you start it. Restart now?", - "restartRequestedToast": "Restart requested: the supervisor brings it back and this page recovers by itself", + "restartNowConfirm": "The service will briefly restart to activate the version on disk. This page reloads automatically. Switch now?", + "restartRequestedToast": "Switching versions. This page reloads when the service is back.", "restartFailed": "Restart request failed: {{reason}}", "restartOverlayTitle": "Restarting the service…", - "restartOverlayBody": "This page reloads by itself once the new process answers (your session will be gone, so a fresh login follows).", + "restartOverlayBody": "This page reloads when the service is back. You will need to sign in again.", "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 has not come back. If nothing supervises it (systemd/launchd) it will not start by itself; start it, then refresh this page.", + "restartOverlaySlowDown": "The service is taking longer to come back. This page will keep trying. Check the service logs if it remains unavailable.", "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 5d2e6fc1..e690c5f4 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -147,10 +147,10 @@ "blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.", "blockedDiverged": "Локальная история впереди на {{ahead}} коммитов и отстаёт на {{behind}}: это слияние, а не загрузка, разберитесь вручную.", "blockedNoUpdate": "В источнике обновлений нет ничего новее этой машины (нажмите «Проверить обновления», если ещё не смотрели).", - "restartChoiceLabel": "Завершить процесс после обновления (вернуть его сможет только внешний супервизор, например systemd или run.sh; если ничего не супервизит — запустите сами)", - "restartNotWired": "При этом запуске хук перезапуска не предоставлен: перезапустите службу сами после обновления.", - "confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).", - "confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.", + "restartChoiceLabel": "Автоматически перейти на новую версию", + "restartNotWired": "Этот способ запуска не поддерживает автоматический переход. После замены файла перезапустите службу штатным способом.", + "confirmRestartYes": "Во время сборки служба продолжит работу. Для перехода она кратковременно перезапустится, и страница обновится сама.", + "confirmRestartNo": "Старая версия продолжит работать до перезапуска службы.", "confirmApply": "Сейчас будет выполнено: обновление из {{repo}} до {{target}} ({{n}} новых коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.", "confirmBuildPending": "Сейчас будет выполнено: сборка недостающего бинарного файла для уже установленных исходников (эта машина на {{commit}}) -> пересборка -> резервная копия и замена старого бинарного файла.", "applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.", @@ -169,7 +169,7 @@ "stateSucceeded": "Успешно", "stateFailed": "Не удалось", "stateUnknown": "Неизвестное состояние", - "restartRequested": "Запрошено завершение после выполнения", + "restartRequested": "Автоматический переход запланирован", "jobPollFailed": "Не удалось прочитать ход выполнения: {{reason}}", "jobDoneToast": "Обновление завершено", "buildDoneToast": "Сборка завершена", @@ -178,13 +178,13 @@ "restartBanner": "Обновлено до {{commit}}, но процесс всё ещё работает на старой сборке (бинарник на диске собран {{builtAt}}). Изменения вступят в силу после перезапуска.", "restartBannerNoCommit": "Бинарник на диске заменён (собран {{builtAt}}), но процесс всё ещё работает на старой сборке; изменения вступят в силу после перезапуска.", "restartNowBtn": "Перезапустить сейчас", - "restartNowConfirm": "Процесс службы завершится; при наличии супервизора (systemd/launchd) новая версия поднимется через несколько секунд, и страница обновится сама. Без супервизора служба останется остановленной. Перезапустить сейчас?", - "restartRequestedToast": "Перезапуск запрошен: супервизор поднимет службу, страница восстановится сама", + "restartNowConfirm": "Служба кратковременно перезапустится для запуска версии на диске. Страница обновится сама. Перейти сейчас?", + "restartRequestedToast": "Переключаем версию. Страница обновится, когда служба снова будет доступна.", "restartFailed": "Не удалось запросить перезапуск: {{reason}}", "restartOverlayTitle": "Перезапуск службы…", - "restartOverlayBody": "Страница обновится сама, как только новый процесс начнёт отвечать (сессия будет сброшена — потребуется войти заново).", + "restartOverlayBody": "Страница обновится, когда служба снова будет доступна. Потребуется войти заново.", "restartOverlaySlowRunning": "Служба по-прежнему отвечает старым процессом — перезапуск, похоже, не произошёл. Нажмите кнопку ниже, чтобы увидеть текущее состояние.", - "restartOverlaySlowDown": "Служба не вернулась. Если её ничто не супервизит (systemd/launchd), она не запустится сама — запустите её и обновите страницу.", + "restartOverlaySlowDown": "Служба пока недоступна. Страница продолжит попытки. Если служба долго не отвечает, проверьте её журналы.", "restartOverlayRefresh": "Обновить страницу", "failureNoMessage": "Сервер не передал сообщение об ошибке", "resultTitle": "Результат обновления", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 039fcb6d..e977c734 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -147,10 +147,10 @@ "blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。", "blockedDiverged": "本地领先 {{ahead}} 个提交、落后 {{behind}} 个提交:这是合并而不是下载,请先手工处理。", "blockedNoUpdate": "更新源没有比本机更新的提交(还没查过就先点「检查更新」)。", - "restartChoiceLabel": "更新完成后退出进程(需 systemd/run.sh 等外部守护才会自动拉起;无人守护请手动重启)", - "restartNotWired": "本次启动没有提供重启钩子:更新完成后请自己重启服务。", - "confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。", - "confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。", + "restartChoiceLabel": "更新成功后自动切换到新版本", + "restartNotWired": "当前启动方式不支持自动切换。换入二进制后,请通过部署方式重启服务。", + "confirmRestartYes": "构建期间服务照常运行;新版本就绪后会短暂重启,页面随后自动刷新。", + "confirmRestartNo": "构建完成后仍运行旧版本,待你重启再切换。", "confirmApply": "将要执行:从更新源 {{repo}} 更新到 {{target}}({{n}} 个新提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。", "confirmBuildPending": "将要执行:为已经就位的源码补上二进制(本机当前 {{commit}})→ 重新编译 → 备份并替换旧二进制。", "applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。", @@ -169,7 +169,7 @@ "stateSucceeded": "已完成", "stateFailed": "失败", "stateUnknown": "未知状态", - "restartRequested": "已要求在完成后退出", + "restartRequested": "已安排自动切换", "jobPollFailed": "进度读取失败:{{reason}}", "jobDoneToast": "更新完成", "buildDoneToast": "补编译完成", @@ -178,13 +178,13 @@ "restartBanner": "已更新到 {{commit}},但当前进程仍在运行旧版本(磁盘上的二进制构建于 {{builtAt}})。重启后新版本才会生效。", "restartBannerNoCommit": "磁盘上的二进制已换新(构建于 {{builtAt}}),但当前进程仍在运行旧版本;重启后新版本才会生效。", "restartNowBtn": "立即重启服务", - "restartNowConfirm": "服务进程会退出;有守护进程(systemd/launchd)时几秒内会自动拉起新版本,本页随后自动刷新。没有守护时服务会保持停止,需要手动启动。确定现在重启?", - "restartRequestedToast": "已请求重启:进程退出后由守护进程拉起,页面会自动恢复", + "restartNowConfirm": "服务将短暂重启以启用磁盘上的版本,页面会自动刷新。现在切换吗?", + "restartRequestedToast": "正在切换版本,服务恢复后页面会自动刷新。", "restartFailed": "重启请求失败:{{reason}}", "restartOverlayTitle": "正在重启服务…", - "restartOverlayBody": "新进程接客后本页会自动刷新(会话会失效,需要重新登录)。无需手动操作。", + "restartOverlayBody": "服务恢复后页面会自动刷新。当前登录会话会结束,请重新登录。", "restartOverlaySlowRunning": "服务仍在以旧进程应答——重启似乎没有发生。可点下方按钮刷新页面查看当前状态。", - "restartOverlaySlowDown": "服务还没有回来。若本机没有守护进程(systemd/launchd),它不会自己启动;手动启动服务后再刷新本页。", + "restartOverlaySlowDown": "服务仍未恢复,页面会继续重试。如果长时间无法连接,请检查服务日志。", "restartOverlayRefresh": "立即刷新页面", "failureNoMessage": "服务端没有给出失败说明", "resultTitle": "更新结果", diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 1e42d24b..f80bba7a 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -94,6 +94,8 @@ function envelope(status, job, canRestart, source, flags) { job: job === undefined ? null : job, canRestart: canRestart !== false, supervised: !!(flags && flags.supervised), + autoRestart: flags?.autoRestart, + bootID: flags?.bootID, needsRestart: !!(flags && flags.needsRestart), binaryBuiltAt: (flags && flags.binaryBuiltAt) || '', source: source || emptySource, @@ -166,7 +168,8 @@ function harness(options) { const el = { id, innerHTML: '', - checked: false, + checked: id === 'update-restart-choice' && + /id="update-restart-choice" checked/.test(nodes.get('update-console')?.innerHTML || ''), insertAdjacentHTML(_pos, html) { this.innerHTML = html + this.innerHTML; }, }; // 控制台住在系统设置页的「一键更新」分区里:页面和分区都得是 active,它才算在前台。 @@ -192,10 +195,7 @@ function harness(options) { 'POST /api/system/update/adopt': [{ status: 200, body: { plan: adoptPlan } }], }, opts.responses || {}); - // The watchdog probes with plain fetch (the app's apiFetch turns a 401 into "logged out", - // and the whole point here is to read that 401 as "the new process is up"). Tests script - // the answers: 'ok' (old process still answering), 'unauthorized' (a new process without - // our session), 'down' (nothing is listening), 'bad gateway'. + // Plain fetch observes boot headers without apiFetch's logout side effect. const probeQueue = (opts.probes || ['ok']).slice(); const fetchProbes = []; @@ -308,7 +308,14 @@ function harness(options) { return Promise.reject(new TypeError('Failed to fetch')); } if (answer === 'unauthorized') { - return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) }); + return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({ error: '未授权访问,请先登录' }) }); + } + if (typeof answer === 'object') { + return Promise.resolve({ + status: answer.status, + headers: { get: name => name === 'X-CyberStrike-Boot-ID' ? answer.bootID : null }, + json: () => Promise.resolve(answer.body || {}), + }); } if (answer === 'bad gateway') { return Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({}) }); @@ -884,7 +891,7 @@ test('the restart tick only appears when the process can actually be restarted', const off = await harness({ canRestart: false }); assert.doesNotMatch(off.html(), /id="update-restart-choice"/, 'offering a promise the platform cannot keep is a lie'); - assert.match(off.html(), /本次启动没有提供重启钩子/); + assert.match(off.html(), /当前启动方式不支持自动切换/); const on = await harness({ canRestart: true, @@ -896,19 +903,19 @@ test('the restart tick only appears when the process can actually be restarted', assert.ok(!/id="update-restart-choice" checked/.test(on.html()), 'with nothing supervising the process, the tick must default to off: ticking it would just stop the platform'); - // The default follows the environment: launchd/systemd markers mean a restart is a - // restart, so one click should be the whole update. - const supervised = await harness({ + const standalone = await harness({ canRestart: true, - flags: { supervised: true }, - responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: null } }] }, + flags: { autoRestart: true, supervised: false }, + responses: { 'GET /api/system/update/job': [{ status: 200, body: { job: { ...runningJob, restartRequested: true } } }] }, }); - assert.match(supervised.html(), /id="update-restart-choice" checked/, - 'detected supervision must pre-tick the box'); + assert.match(standalone.html(), /id="update-restart-choice" checked/, + 'the standalone executable supports automatic activation'); + await standalone.api.startUpdateApply(); + assert.deepEqual(JSON.parse(standalone.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true }); // Once the operator decides for themselves, the default stops second-guessing them. - supervised.sandbox.document.getElementById('update-restart-choice').checked = false; - supervised.fire('updateRestartChoiceChanged', { checked: false }); - assert.ok(!/id="update-restart-choice" checked/.test(supervised.html())); + standalone.sandbox.document.getElementById('update-restart-choice').checked = false; + standalone.fire('updateRestartChoiceChanged', { checked: false }); + assert.ok(!/id="update-restart-choice" checked/.test(standalone.html())); on.calls.length = 0; on.fire('startUpdateApply'); @@ -1356,7 +1363,7 @@ test('restart now asks first, posts once, and hands the page to the watchdog', a h.fire('startRestartNow'); await h.flush(); assert.equal(h.confirms.length, 1, 'standing the service down is confirmed, never implied'); - assert.match(h.confirms[0], /守护/, 'the confirm has to say what brings it back: ' + h.confirms[0]); + assert.match(h.confirms[0], /页面会自动刷新/); assert.deepEqual(h.calls, [], 'a cancelled restart must not post'); assert.equal(h.vars.getWatchdogTimer(), null, 'nothing may be watched for a restart nobody asked for'); @@ -1389,8 +1396,8 @@ test('a refused restart is reported with the server sentence and arms nothing', test('the watchdog reloads only when the NEW process answers, never while the old one still does', async () => { const h = await harness({ - flags: { needsRestart: true }, - probes: ['ok', 'down', 'down', 'unauthorized'], + flags: { needsRestart: true, bootID: 'old' }, + probes: [{ status: 401, bootID: 'old' }, 'down', { status: 401 }, { status: 200, bootID: 'new' }], responses: { 'POST /api/system/update/restart': [{ status: 202, body: { restarting: true } }] }, }); await h.api.startRestartNow(); @@ -1399,18 +1406,18 @@ test('the watchdog reloads only when the NEW process answers, never while the ol await h.tick(); await h.flush(); - assert.equal(h.location.replaced.length, 0, 'a 200 from the old process is not "it is back"'); + assert.equal(h.location.replaced.length, 0, 'an expired session on the old boot is not a restart'); await h.tick(); await h.flush(); await h.tick(); await h.flush(); - assert.equal(h.location.replaced.length, 0, 'a dead service is not a recovered one'); + assert.equal(h.location.replaced.length, 0, 'a dead service or proxy 401 does not prove recovery'); await h.tick(); await h.flush(); assert.equal(h.location.replaced.length, 1, - 'a 401 is the proof: only a new process can fail to know our session'); + 'the new boot can be recognized independently of authentication status'); assert.match(h.location.replaced[0], /\?restarted=\d+/); assert.match(h.location.replaced[0], /#system-update$/); assert.equal(h.vars.getWatchdogTimer(), null, 'the page is leaving: the timer must be down'); @@ -1427,7 +1434,7 @@ test('a restart that does not come back says so instead of spinning forever', as downCase.vars.expireWatchdog(); await downCase.tick(); await downCase.flush(); - assert.match(downCase.html(), /没有守护进程|不会自己启动/, 'the down advice must name the supervision question'); + assert.match(downCase.html(), /服务仍未恢复,页面会继续重试/); const aliveCase = await harness({ flags: { needsRestart: true }, @@ -1495,6 +1502,21 @@ test('a job poll that dies while the restart is in flight is not a failure', asy assert.equal(h.location.replaced.length, 1); }); +test('an update that did not build a binary stays on the progress page', async () => { + const h = await harness({ + flags: { autoRestart: true }, + responses: { + 'GET /api/system/update/job': [{ + status: 200, + body: { job: { ...succeededJob, result: { ...succeededJob.result, binaryBuilt: false } } }, + }], + }, + }); + await h.api.startUpdateApply(); + assert.deepEqual(JSON.parse(h.calls.find(c => c.url === '/api/system/update/apply').body), { restart: true }); + assert.equal(h.vars.getWatchdogTimer(), null, 'a source-only update does not cause an outage'); +}); + 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(), /上一次回滚尚未完成/); diff --git a/web/static/js/update.js b/web/static/js/update.js index 0332809a..ea1d7eec 100644 --- a/web/static/js/update.js +++ b/web/static/js/update.js @@ -20,14 +20,15 @@ const UPDATE_FAILURE_KEYS = { restart: 'restartFailed', }; -let updateConsoleState = null; // GET /api/system/update 的整包:{status, job, canRestart, supervised, needsRestart} +let updateConsoleState = null; let updateCheck = { done: false, error: '' }; let updateBusy = false; // 页面自己发着的动作请求(检查 / 启动更新 / 回滚 / 重启) let updateChecking = false; // 只影响「检查更新」按钮的措辞 let updatePollTimer = null; -let updateRestartChoice = true; // 勾选框要在轮询重绘之后仍然是勾着的;默认值跟随 supervised +let updateRestartChoice = false; let updateRestartChoiceSet = false; // 操作员本次会话手动改过之后,默认值不再顶掉他的选择 -let updateRestartPending = false; // 已请求重启:进程退出/新进程接客就在眼前 +let updateRestartExpected = false; // 更新结束后预计重启,构建阶段仍照常读进度 +let updateRestartPending = false; // 正在等待服务恢复 let updateWatchdogTimer = null; // 重启守侧自己的定时器,跟任务进度轮询互不干扰 let updateWatchdogDeadline = 0; let updateWatchdogSlow = null; // null=还没超时;'running'=仍由旧进程应答;'down'=服务没回来 @@ -145,7 +146,9 @@ async function fetchUpdateStatus() { status: r.data.status || {}, job: r.data.job || null, canRestart: !!r.data.canRestart, - supervised: !!r.data.supervised, + // Old backends have no self-restart capability field. + autoRestart: r.data.autoRestart ?? !!r.data.supervised, + bootID: r.data.bootID || '', needsRestart: !!r.data.needsRestart, binaryBuiltAt: r.data.binaryBuiltAt || '', source: r.data.source || { repo: '', configured: false, defaultRepo: '' }, @@ -154,11 +157,9 @@ async function fetchUpdateStatus() { return updateConsoleState; } -// 勾选框的默认值:有守护(launchd/systemd 的启动标记)才默认勾上——没人守护时默认勾上 -// 等于"一键把服务点停"。操作员自己动过勾选框之后,服务端的值不再覆盖他的选择。 function noteRestartDefault() { if (updateRestartChoiceSet) return; - updateRestartChoice = !!(updateConsoleState && updateConsoleState.supervised); + updateRestartChoice = updateCanRestart() && updateConsoleState.autoRestart; } // notify 只在平台真的载了 showNotification 时才动。它绝不能成为"操作看起来失败"的原因: @@ -213,7 +214,7 @@ async function loadUpdateConsole() { } resetUpdateCheck(); updateRestartChoiceSet = false; - updateRestartChoice = true; // 之后由 noteRestartDefault 按 supervised 修正 + updateRestartChoice = false; updateActionError = ''; // 重新进页 = 干净画面,上一次动作的红字不跟过来 const el = document.getElementById('update-console'); if (!el) return; @@ -226,7 +227,12 @@ async function loadUpdateConsole() { return; } renderUpdateConsole(); - // 开页就有一次在跑的更新(比如另开一个标签页点的),接着看它,别让人以为没在动。 + const job = updateJobOf(); + updateRestartExpected = !!(job && job.restartRequested && job.state === 'running'); + if (job && job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) { + startUpdateWatchdog(); + return; + } if (isUpdateJobRunning()) startUpdatePolling(); scheduleUpdateAutoCheck(); } @@ -246,8 +252,7 @@ function stopUpdatePolling() { // 重启守侧:请求重启之后,盯着"新进程是否已经接客",接上了就整页刷新 // --------------------------------------------------------------------------- -// 只认一个新进程已经应答的事实:会话只活在旧进程内存里,新进程对旧令牌只会回 401。 -// 200 说明还在跟旧进程说话(还没退出),连不上/502 说明正在退出或还没起来——都继续等。 +// 服务启动标记变了才刷新。会话过期和代理的错误页都不代表换版。 // 离开控制台就静默停表:不把已经走开的用户从别的页面拽回更新页。 let updateWatchdogProbing = false; @@ -259,28 +264,48 @@ async function watchdogProbe() { if (updateWatchdogProbing) return; updateWatchdogProbing = true; let status = 0; + let bootID = ''; + let legacyUnauthorized = false; + const previousBoot = updateConsoleState && updateConsoleState.bootID; try { - const resp = await fetch('/api/system/update', { + const headers = { Accept: 'application/json' }; + if (typeof authToken === 'string' && authToken) headers.Authorization = 'Bearer ' + authToken; + const resp = await fetch('/api/auth/validate', { method: 'GET', credentials: 'same-origin', cache: 'no-store', - headers: { Accept: 'application/json' }, + redirect: 'error', + headers: headers, + signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined, }); status = resp.status; + bootID = resp.headers?.get('X-CyberStrike-Boot-ID') || ''; + if (status === 401 && !bootID) { + // A downgrade may return to a build that predates the boot header. + const data = await resp.json().catch(() => null); + legacyUnauthorized = data?.error === '未授权访问,请先登录'; + } } catch (err) { status = 0; } finally { updateWatchdogProbing = false; } - if (status === 401) { - // 新进程已经接客、且不认我们的会话:整页刷新落到新版本,带 hash 回到本页。 - // 先停表:页面马上要整体换掉,定时器不该再有第二次动作。 + if (!isUpdateConsoleActive() || !updateWatchdogTimer) return; + if ((bootID && bootID !== previousBoot && (status === 200 || status === 401)) || legacyUnauthorized) { stopUpdateWatchdog(); location.replace(location.pathname + '?restarted=' + Date.now() + '#system-update'); return; } + if (updateRestartExpected && previousBoot && bootID === previousBoot && status === 200) { + // The progress request may have hit a transient network error during the build. + stopUpdateWatchdog(); + updateRestartPending = false; + renderUpdateConsole(); + startUpdatePolling(); + return; + } if (updateWatchdogSlow === null && Date.now() > updateWatchdogDeadline) { - updateWatchdogSlow = status === 200 ? 'running' : 'down'; + updateWatchdogSlow = bootID || status === 200 ? 'running' : 'down'; renderUpdateWatchdog(); } } @@ -316,8 +341,8 @@ async function pollUpdateJob() { } if (!r.ok) { stopUpdatePolling(); - if (updateRestartPending) { - // 进程按请求退了:这不是"读进度失败",是重启进入了下一幕。 + if (updateRestartExpected) { + // The page may have missed the last progress response before handoff. startUpdateWatchdog(); return; } @@ -327,12 +352,16 @@ async function pollUpdateJob() { const job = r.data && r.data.job ? r.data.job : null; if (updateConsoleState) updateConsoleState.job = job; renderUpdateConsole(); + if (job && job.state === 'running') updateRestartExpected = !!job.restartRequested; + if (!job && updateRestartExpected) { + stopUpdatePolling(); + startUpdateWatchdog(); + return; + } if (job && job.state !== 'running') { stopUpdatePolling(); - if (job.state === 'succeeded' && job.restartRequested) { - // 进程马上要让位给新二进制:先把结论播报掉,再进自恢复视图;不再回头 - // 去读一个即将消失的服务(reportFinishedJob 会去读,这里跳过它)。 - notify(updateDoneToast(job.result || null), 'success'); + updateRestartExpected = false; + if (job.state === 'succeeded' && job.restartRequested && job.result?.binaryBuilt) { startUpdateWatchdog(); return; } @@ -357,7 +386,10 @@ function reportFinishedJob(job) { const result = job.result || null; if (job.state === 'succeeded') { notify(updateDoneToast(result), 'success'); - if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info'); + if (job.restartError) { + updateActionError = updateT('restartFailed', { reason: job.restartError }); + notify(updateActionError, 'error'); + } else if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info'); } else { const reason = (job.failure && job.failure.message) ? job.failure.message : updateT('failureNoMessage'); notify(updateT('jobFailedToast') + ': ' + reason, 'error'); @@ -479,7 +511,7 @@ async function startUpdateApply() { } if (!r.ok) throw new Error(r.error); resetUpdateCheck(); - if (restart) updateRestartPending = true; + updateRestartExpected = restart; startUpdatePolling(); notify(updateT('applyAccepted', { id: r.data.job_id || '-' }), 'success'); await pollUpdateJob(); @@ -496,6 +528,7 @@ async function startRestartNow() { const r = await runUpdateRequest('POST', '/api/system/update/restart'); if (!r.ok) throw new Error(r.error); stopUpdatePolling(); + updateRestartExpected = false; notify(updateT('restartRequestedToast'), 'info'); startUpdateWatchdog(); }, 'restart'); @@ -587,7 +620,7 @@ async function confirmAdoptSource() { if (!r.ok) throw new Error(r.error); updateAdoptPlan = null; resetUpdateCheck(); - if (restart) updateRestartPending = true; + updateRestartExpected = restart; startUpdatePolling(); notify(updateT('adoptAccepted', { id: r.data.job_id || '-' }), 'success'); await pollUpdateJob(); @@ -949,8 +982,10 @@ function renderRestartBanner() { }); return '
'; + (updateCanRestart() ? + '' : '') + + ''; } // 自恢复视图:重启请求发出后控制台画的就是它,直到新进程应答触发整页刷新。