mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
fix: a rollback keeps the operator's edits, and a retry really compiles
The review found two failure paths that reported success for work the engine had not done. Rollback: it refused only when *non-protected* source files were modified, and then ran `git reset --hard` to the previous commit - which discards every local change to tracked files, not just the update's. Edits to roles/skills/tools (the update itself keeps those via its own stash) and edits made after the update were silently wiped, while Result said nothing: deployment.md's promise that "work done afterwards is not swept away" did not hold. Rollback now copies every protected file that differs right now into `.update-backup/rollback_<ts>/` before the reset, sets the copies back after it, re-applies hand deletions, and names what it kept in Result.KeptContent like an update does. Retry: Apply merges and writes the state file before it builds, so a failed (or toolchain-less) build leaves HEAD on the new commit with the old binary still in place. The retry then took the `!snap.UpdateAvailable` no-op branch, aligned the version and returned err=nil - the job was recorded as succeeded, the page said "update finished", the CLI printed that source and binary were in place, and the old executable kept running. The tree now carries a `.update-build-pending` marker, written the moment the merge lands (and when an adoption commits to its work tree) and cleared only after the new binary is in place. The no-op branch reads it: with a toolchain it compiles and reports the build (commits=0, binaryBuilt=true); without one it keeps refusing exactly like the first attempt, so "install go and click again" is now a real instruction. The console stops treating "nothing to pull" as "nothing to do" (the retry button stays live, the confirm dialog and the done toast say the build is what will happen), and the CLI's printResult no longer swallows the build-only result. Both paths are covered by regression tests that fail on the pre-fix tree: two rollback scenarios driven through real git (edit before and after the update, a hand deletion), and four retry scenarios driven through a scripted go on PATH (failed build, state-write failure, an adoption's failed build retried by the next update, and no toolchain then toolchain).
This commit is contained in:
1 parent
22a069849c
commit
9c6928074d
14 files changed
+632
-41
No files matched your search
@@ -43,6 +43,7 @@ coverage.html
|
||||
.update-staging/
|
||||
.update-backup/
|
||||
.update-state.json
|
||||
.update-build-pending
|
||||
cyberstrike-ai.prev
|
||||
|
||||
# Logs and temporary files
|
||||
|
||||
@@ -101,17 +101,28 @@ func printSnapshot(s *update.Snapshot) {
|
||||
if !s.CanBuild {
|
||||
fmt.Println("本机没有 Go 工具链:更新只搬源码,不会重编译")
|
||||
}
|
||||
if s.BuildPending {
|
||||
fmt.Println("上一次更新留下了未完成的编译(源码已就位、二进制还是旧的):装好工具链后重跑更新即可补上")
|
||||
}
|
||||
if s.HasRollback {
|
||||
fmt.Printf("可回滚到 %s\n", s.RollbackTo)
|
||||
}
|
||||
}
|
||||
|
||||
func printResult(r *update.Result) {
|
||||
if r.Commits == 0 && r.ToCommit == r.FromCommit {
|
||||
moved := r.Commits > 0 || r.ToCommit != r.FromCommit
|
||||
if !moved && !r.BinaryBuilt {
|
||||
fmt.Println("已经是最新,没有改动")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s → %s(%d 个提交,涉及 %d 个文件,用时 %s)\n", r.FromCommit, r.ToCommit, r.Commits, r.FilesTouched, r.Duration)
|
||||
if moved {
|
||||
fmt.Printf("%s → %s(%d 个提交,涉及 %d 个文件,用时 %s)\n", r.FromCommit, r.ToCommit, r.Commits, r.FilesTouched, r.Duration)
|
||||
} else {
|
||||
// commits == 0 but a binary was built: a retry that finished a build the previous
|
||||
// attempt could not. Saying "已经是最新,没有改动" here would bury the one thing
|
||||
// that did happen.
|
||||
fmt.Printf("源码已经是最新(%s),本次补上了二进制\n", r.ToCommit)
|
||||
}
|
||||
if len(r.KeptContent) > 0 {
|
||||
fmt.Printf("保留了 %d 个本机内容文件(roles/skills/tools 等,没有被上游覆盖):\n", len(r.KeptContent))
|
||||
for _, p := range r.KeptContent {
|
||||
|
||||
@@ -185,6 +185,8 @@ SQLite 热备份时最好先停止服务,或至少复制 `*.db`、`*.db-wal`
|
||||
最新代码:fetch → 快进合并 → `go build` → 原子换二进制(旧的留作 `cyberstrike-ai.prev`),并把
|
||||
`roles/ skills/ tools/ agents/ knowledge_base/ data/ config.yaml` 等运维者内容先暂存再放回,结果里点名
|
||||
保留了哪些文件。本地源码有改动、或本地历史与更新源分叉时它会拒绝并说明原因,而不是硬来。
|
||||
构建失败或本机没有 Go 工具链时源码照样更新、二进制保持原样:欠编译会记在安装目录(`.update-build-pending`)
|
||||
并被页面标出,装好 go 后再点一次「一键更新」即补编译。
|
||||
细节见 [开发者指南](developer-guide.md) 的「一键更新」一节。
|
||||
|
||||
检测到 systemd/launchd(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、
|
||||
|
||||
@@ -97,6 +97,8 @@ update:
|
||||
`config.yaml` 的 `version` 字段(只动这一行、临时文件 + 原子替换;进度里会有一行「版本号已随新代码
|
||||
更新:v1.7.20 → v1.7.21」)。重启后页头的版本徽标与静态资源的 `?v=` 都跟代码对上——升级后前端缓存
|
||||
也因此自动失效。回滚会把版本号一起还原(前提是这期间没人手改过它);解压安装「接入」时同样同步一次。
|
||||
若 `version` 的写法无法行级替换(如 JSON 单行、多行标量),写入会安全跳过并提示,而不是产出打不开的
|
||||
配置——读取走 YAML 解析(认 `version` / `"version"` / `'version'` 三种写法),写入的行匹配与之口径一致。
|
||||
|
||||
### 四种拒绝场景
|
||||
|
||||
@@ -104,7 +106,7 @@ update:
|
||||
|---|---|---|
|
||||
| 本地改过**产品源码**(不在 protected 清单里的路径) | `local_source_edits` | 一键更新是"下载",不是"替你决定冲突"。点名文件,先提交或还原再重试 |
|
||||
| 分支与远端**分叉**(本地既领先又落后) | `diverged` | 那是合并决策,给出 ahead/behind 数字;手工处理后再点 |
|
||||
| 本机没有 Go 工具链 | `no_toolchain` | **源码照样更新**,只有二进制没换;装好 `go` 再点一次即可补上 |
|
||||
| 本机没有 Go 工具链 | `no_toolchain` | **源码照样更新**,只有二进制没换;欠编译记入 `.update-build-pending`(状态里 `buildPending`,页面照常开放「一键更新」),装好 `go` 再点一次会真的补编译 |
|
||||
| 这个目录不是 git 工作树 | `not_a_repo`(状态里 `installed: false`) | tarball 安装的目录没有可快进的远端,按发布包方式更新 |
|
||||
|
||||
`apply` 是异步的,所以它总是先回 `202`,上表的拒绝出现在轮询到的 `job.failure`(`reason`/`message`/`items`)里;
|
||||
@@ -119,6 +121,8 @@ update:
|
||||
`.update-backup/<时间戳>/` 并从工作树移开(你多出来的、上游没动的目录连碰都不碰),合并后再原样放回,
|
||||
于是"更新代码、留下我的工作"成立。结果里的 `keptContent` 逐个点名被保留的文件——绝不静默丢弃:
|
||||
当上游与你在同一个文件上都有改动,落地的是你的版本,而这件事写在结果里。
|
||||
**回滚遵循同一条规则**:`reset --hard` 会把受保护文件上现存的本地改动先暂存、重置后放回(更新前改的
|
||||
和更新后改的都算,本地删除的保持删除),`keptContent` 同样点名——回滚撤的是代码,不是你的内容。
|
||||
|
||||
### 回滚
|
||||
|
||||
|
||||
@@ -155,6 +155,12 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
// must leave it that way (the source has landed; only the binary has not), so the
|
||||
// cleanup above must not remove the repository any more.
|
||||
adopted = true
|
||||
// The binary is owed from this point, and the marker says so even if this run never
|
||||
// reaches the compile (a failed version write, a crash): the next update click reads
|
||||
// it and finishes the job instead of reporting "already up to date".
|
||||
if opts.binaryName() != "none" {
|
||||
markBuildPending(root)
|
||||
}
|
||||
|
||||
// The connected code carries its own release version; the live config gets it now, so
|
||||
// a tarball install stops showing the version it was unpacked from.
|
||||
@@ -188,22 +194,10 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
}
|
||||
}
|
||||
|
||||
step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)")
|
||||
staging := filepath.Join(root, ".update-staging")
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return res, &Error{Reason: "staging_failed", Message: err.Error()}
|
||||
if err := buildAndSwap(ctx, root, opts, res, step); err != nil {
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, err
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
newBin := filepath.Join(staging, opts.binaryName())
|
||||
if err := build(ctx, root, newBin); err != nil {
|
||||
return res, &Error{Reason: "build_failed", Message: "编译失败,目录已接入但二进制保持原版本:\n" + err.Error()}
|
||||
}
|
||||
prev, err := installBinary(newBin, bin)
|
||||
if err != nil {
|
||||
return res, &Error{Reason: "swap_failed", Message: err.Error()}
|
||||
}
|
||||
res.BinaryBuilt = true
|
||||
res.PrevBinary = prev
|
||||
step("done", fmt.Sprintf("目录已接入 %s/%s(%s)并换好二进制", source, branch, commit))
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, nil
|
||||
|
||||
+143
-11
@@ -31,6 +31,26 @@ type State struct {
|
||||
|
||||
func statePath(root string) string { return filepath.Join(root, stateFile) }
|
||||
|
||||
// buildPendingFile records "the source has moved (or was moved), but the binary has not
|
||||
// followed": a failed build, or a machine with no Go toolchain. It is written the moment
|
||||
// the tree moves and removed only once the new binary is in place, so a retry on an
|
||||
// already-current tree still knows there is a compile to finish. A file of its own rather
|
||||
// than a State field because an adoption owes a build too, and an adoption has no state
|
||||
// (there is no previous commit to roll back to).
|
||||
const buildPendingFile = ".update-build-pending"
|
||||
|
||||
func buildPendingPath(root string) string { return filepath.Join(root, buildPendingFile) }
|
||||
|
||||
// markBuildPending is best-effort on purpose: the marker only makes a later retry sharper,
|
||||
// and a bookkeeping write it cannot do must not turn a working update into a failed one.
|
||||
func markBuildPending(root string) {
|
||||
_ = os.WriteFile(buildPendingPath(root), []byte("source is ahead of the binary\n"), 0o644)
|
||||
}
|
||||
|
||||
func clearBuildPending(root string) { _ = os.Remove(buildPendingPath(root)) }
|
||||
|
||||
func buildPending(root string) bool { return fileExists(buildPendingPath(root)) }
|
||||
|
||||
func readState(root string) (State, bool) {
|
||||
var st State
|
||||
data, err := os.ReadFile(statePath(root))
|
||||
@@ -150,11 +170,27 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
// from a hand edit) is corrected here instead of waiting for the next commit.
|
||||
versionBefore, versionAfter, versionErr := syncVersionToConfig(snap.Root)
|
||||
versionStep(step, versionBefore, versionAfter, versionErr)
|
||||
return &Result{
|
||||
res := &Result{
|
||||
FromCommit: snap.Commit, ToCommit: snap.Commit,
|
||||
BinaryPath: filepath.Join(snap.Root, opts.binaryName()),
|
||||
Duration: time.Since(start).Round(time.Millisecond).String(),
|
||||
}, nil
|
||||
}
|
||||
// "Nothing to pull" is not "nothing to do": a build that failed - or could not run
|
||||
// at all - leaves exactly this state, and retrying is what the no_toolchain message
|
||||
// told the operator to do. Deciding by the marker is what keeps this branch from
|
||||
// reporting success while the old binary is still in place.
|
||||
if buildPending(snap.Root) && opts.binaryName() != "none" {
|
||||
// The attempt that parked the content is the one that knows the backup dir;
|
||||
// the retry reports it so "where did my files go" stays answerable.
|
||||
if st, ok := readState(snap.Root); ok {
|
||||
res.BackupDir = st.BackupDir
|
||||
}
|
||||
if err := finishPendingBuild(ctx, snap, opts, res, step); err != nil {
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
step("fetch", fmt.Sprintf("%s(默认分支 %s)有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.TargetBranch, snap.Behind, snap.Commit, snap.RemoteCommit))
|
||||
@@ -175,6 +211,14 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
restoreProtected(backupDir, kept)
|
||||
return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))}
|
||||
}
|
||||
// The working tree has moved: from this point the binary is owed, even if the state
|
||||
// write or the compile below never happens (a full disk, a crash, a killed process).
|
||||
// The marker is what the next update click reads to know that "nothing to pull" still
|
||||
// means "there is a compile to finish" - without it this window is the "retry reports
|
||||
// success without building" defect all over again.
|
||||
if opts.binaryName() != "none" {
|
||||
markBuildPending(root)
|
||||
}
|
||||
// The merge wrote upstream's version of the content paths; the operator's copies win
|
||||
// them back, which is what "update the code, keep my work" means.
|
||||
if err := restoreProtected(backupDir, kept); err != nil {
|
||||
@@ -219,28 +263,56 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
|
||||
Message: "源码已更新,但本机没有 Go 工具链,无法自动编译。装好 go 后再点一次更新即可补上二进制。",
|
||||
}
|
||||
}
|
||||
if err := buildAndSwap(ctx, root, opts, res, step); err != nil {
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, err
|
||||
}
|
||||
step("done", fmt.Sprintf("已更新 %d 个提交并换好二进制:%s → %s", res.Commits, res.FromCommit, res.ToCommit))
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// finishPendingBuild compiles the binary a previous update left missing. It only claims
|
||||
// success with a compile to show for it: without a toolchain it repeats the same refusal
|
||||
// the first attempt gave, because the environment has to change before this can happen.
|
||||
func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res *Result, step func(string, string)) error {
|
||||
if !snap.CanBuild {
|
||||
return &Error{
|
||||
Reason: "no_toolchain",
|
||||
Message: fmt.Sprintf("源码已经是更新源最新(%s),但二进制还是旧的:本机没有 Go 工具链,无法补编译。装好 go 后再点一次更新即可补上二进制。", snap.Commit),
|
||||
}
|
||||
}
|
||||
if err := buildAndSwap(ctx, snap.Root, opts, res, step); err != nil {
|
||||
return err
|
||||
}
|
||||
step("done", fmt.Sprintf("源码已经是更新源最新(%s),本次补上了二进制", snap.Commit))
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAndSwap compiles the tree and moves the new binary into place, keeping the previous
|
||||
// one as .prev. The pending marker comes off only after the swap: until the install holds
|
||||
// the new executable, "there is a build to finish" is still true.
|
||||
func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, step func(string, string)) error {
|
||||
step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)")
|
||||
staging := filepath.Join(root, ".update-staging")
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return res, &Error{Reason: "staging_failed", Message: err.Error()}
|
||||
return &Error{Reason: "staging_failed", Message: err.Error()}
|
||||
}
|
||||
defer os.RemoveAll(staging)
|
||||
|
||||
newBin := filepath.Join(staging, opts.binaryName())
|
||||
if err := build(ctx, root, newBin); err != nil {
|
||||
return res, &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()}
|
||||
return &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()}
|
||||
}
|
||||
prev, err := installBinary(newBin, bin)
|
||||
prev, err := installBinary(newBin, filepath.Join(root, opts.binaryName()))
|
||||
if err != nil {
|
||||
return res, &Error{Reason: "swap_failed", Message: err.Error()}
|
||||
return &Error{Reason: "swap_failed", Message: err.Error()}
|
||||
}
|
||||
res.BinaryBuilt = true
|
||||
res.PrevBinary = prev
|
||||
res.NeedsRestart = true
|
||||
step("done", fmt.Sprintf("已更新 %d 个提交并换好二进制:%s → %s", res.Commits, res.FromCommit, res.ToCommit))
|
||||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||||
return res, nil
|
||||
clearBuildPending(root)
|
||||
return nil
|
||||
}
|
||||
|
||||
// stashProtected copies every operator-owned file that stands in the way of the
|
||||
@@ -296,6 +368,40 @@ func stashProtected(ctx context.Context, root, ref string) (backupDir string, ke
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
// stashProtectedEdits copies the operator's local edits to protected files out of the
|
||||
// tree before a rollback resets it. Unlike an update's stash it leaves the working copy
|
||||
// alone - the reset is what replaces those files - because reset --hard does not know
|
||||
// which local changes are the update's and which are the operator's. A file deleted by
|
||||
// hand carries no content to copy, so its name is recorded instead and the deletion is
|
||||
// re-applied after the reset.
|
||||
func stashProtectedEdits(root string, changed []Change) (backupDir string, kept, deleted []string, err error) {
|
||||
ts := time.Now().Format("20060102_150405")
|
||||
for _, c := range changed {
|
||||
if !c.Protected {
|
||||
continue
|
||||
}
|
||||
if c.Status == "deleted" {
|
||||
deleted = append(deleted, c.Path)
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(root, filepath.FromSlash(c.Path))
|
||||
info, statErr := os.Stat(abs)
|
||||
if statErr != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
if backupDir == "" {
|
||||
backupDir = filepath.Join(root, ".update-backup", "rollback_"+ts)
|
||||
}
|
||||
if err := copyFile(abs, filepath.Join(backupDir, filepath.FromSlash(c.Path)), info.Mode()); err != nil {
|
||||
return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", c.Path, err)}
|
||||
}
|
||||
kept = append(kept, c.Path)
|
||||
}
|
||||
sort.Strings(kept)
|
||||
sort.Strings(deleted)
|
||||
return backupDir, kept, deleted, nil
|
||||
}
|
||||
|
||||
// restoreProtected puts the named operator-owned copies back over whatever the merge
|
||||
// wrote. The list is the one stashProtected returned: an adoption parks replaced
|
||||
// non-protected files in the same backup directory (under overwritten/), and those must
|
||||
@@ -370,7 +476,8 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) {
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if _, blocking := localChanges(ctx, root); len(blocking) > 0 {
|
||||
changes, blocking := localChanges(ctx, root)
|
||||
if len(blocking) > 0 {
|
||||
items := make([]string, 0, len(blocking))
|
||||
for _, c := range blocking {
|
||||
items = append(items, c.Path)
|
||||
@@ -378,12 +485,36 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) {
|
||||
return nil, &Error{Reason: "local_source_edits", Message: "有本地源码改动,回滚会覆盖它们", Items: items}
|
||||
}
|
||||
|
||||
// Content the operator owns is not the rollback's to discard either: reset --hard takes
|
||||
// every local change to tracked files, so protected files that differ right now - edits
|
||||
// made before the update and edits made after it alike - are copied aside first and set
|
||||
// back on top of the old commit.
|
||||
backupDir, kept, deleted, err := stashProtectedEdits(root, changes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := gitCmd(ctx, root, "reset", "--hard", "--quiet", st.PreviousCommit); err != nil {
|
||||
return nil, &Error{Reason: "reset_failed", Message: err.Error()}
|
||||
}
|
||||
if err := restoreProtected(backupDir, kept); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range deleted {
|
||||
// A protected file the operator deleted by hand stays deleted: the reset just
|
||||
// brought the old commit's copy back, and that deletion is a local change like any
|
||||
// other this rollback promised not to touch.
|
||||
if err := os.Remove(filepath.Join(root, filepath.FromSlash(p))); err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := restorePrevBinary(bin); err != nil {
|
||||
return nil, &Error{Reason: "swap_failed", Message: err.Error()}
|
||||
}
|
||||
// Source and binary agree again (both the previous commit's), so a pending-build
|
||||
// marker from a failed attempt no longer describes anything - keeping it would invite
|
||||
// a rebuild of the old tree.
|
||||
clearBuildPending(root)
|
||||
// The version number goes back with the code it belongs to (only when it is still the
|
||||
// one that update wrote - see restoreVersionAfterRollback).
|
||||
restoreVersionAfterRollback(root, st)
|
||||
@@ -393,6 +524,7 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) {
|
||||
return &Result{
|
||||
FromCommit: shortHash(head),
|
||||
ToCommit: shortHash(st.PreviousCommit),
|
||||
KeptContent: kept,
|
||||
BinaryPath: bin,
|
||||
BinaryBuilt: true,
|
||||
BackupDir: st.BackupDir,
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeGo is a scripted stand-in for the Go toolchain: `go env GOVERSION` answers, and
|
||||
// `go build -o <out> ...` either writes a recognisable file or fails, decided by a marker
|
||||
// file the test toggles. It goes first on PATH (the real PATH stays behind it, because git
|
||||
// still has to resolve), so the update's real build step runs without compiling the whole
|
||||
// server inside a test.
|
||||
type fakeGo struct {
|
||||
marker string
|
||||
}
|
||||
|
||||
func scriptFakeGo(t *testing.T) *fakeGo {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
marker := filepath.Join(dir, "fail-build")
|
||||
f := &fakeGo{marker: marker}
|
||||
script := "#!/bin/sh\n" +
|
||||
"if [ \"$1\" = env ]; then echo go1.23.4; exit 0; fi\n" +
|
||||
"out=\"\"\n" +
|
||||
"prev=\"\"\n" +
|
||||
"for a in \"$@\"; do\n" +
|
||||
" if [ \"$prev\" = -o ]; then out=\"$a\"; fi\n" +
|
||||
" prev=\"$a\"\n" +
|
||||
"done\n" +
|
||||
"if [ -f \"" + marker + "\" ]; then echo 'vet: internal/foo.go:12: fake failure' >&2; exit 1; fi\n" +
|
||||
"echo 'fake binary' > \"$out\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "go"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeGo) failBuild(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(f.marker, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGo) succeed(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.Remove(f.marker); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// hideToolchain takes `go` off the PATH child processes see while leaving everything else
|
||||
// (git) reachable: a directory holding a symlink to the real git and nothing else.
|
||||
func hideToolchain(t *testing.T) {
|
||||
t.Helper()
|
||||
realGit, err := exec.LookPath("git")
|
||||
if err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.Symlink(realGit, filepath.Join(dir, "git")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir)
|
||||
}
|
||||
|
||||
// buildOpts drives an update with a real binary to swap - the configuration of every
|
||||
// installed tree, where "the source moved but the binary did not" is even possible.
|
||||
func buildOpts(tr *tree) Options {
|
||||
return Options{Root: tr.install, Repo: tr.upstream, BinaryName: Binary}
|
||||
}
|
||||
|
||||
// TestRetryAfterFailedBuildCompilesTheBinary: a failed build leaves the source updated
|
||||
// (HEAD is on the new commit), so a retry sees "nothing to pull" - and used to report
|
||||
// success while the old binary stayed in place. The retry has to finish the build.
|
||||
func TestRetryAfterFailedBuildCompilesTheBinary(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
fake := scriptFakeGo(t)
|
||||
fake.failBuild(t)
|
||||
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "old binary bytes")
|
||||
tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"})
|
||||
|
||||
res, err := Apply(context.Background(), buildOpts(tr), nil)
|
||||
ue, ok := err.(*Error)
|
||||
if !ok || ue.Reason != "build_failed" {
|
||||
t.Fatalf("first apply: err=%v result=%+v, want build_failed", err, res)
|
||||
}
|
||||
if res.BinaryBuilt {
|
||||
t.Error("a failed build must not claim a binary")
|
||||
}
|
||||
if got := readFile(t, bin); got != "old binary bytes" {
|
||||
t.Errorf("binary after the failed build = %q, want the old one untouched", got)
|
||||
}
|
||||
snap, err := Status(context.Background(), buildOpts(tr))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snap.BuildPending {
|
||||
t.Error("after a failed build the tree owes a compile; Status must say so")
|
||||
}
|
||||
|
||||
// Same tree, a working toolchain: the retry compiles instead of reporting success for
|
||||
// work that never happened.
|
||||
fake.succeed(t)
|
||||
res, err = Apply(context.Background(), buildOpts(tr), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("retry failed: %v (result %+v)", err, res)
|
||||
}
|
||||
if !res.BinaryBuilt || res.Commits != 0 || res.FromCommit != res.ToCommit {
|
||||
t.Errorf("retry result = %+v, want a build with no commits moved", res)
|
||||
}
|
||||
if got := readFile(t, bin); !strings.Contains(got, "fake binary") {
|
||||
t.Errorf("binary after retry = %q, want the freshly built one", got)
|
||||
}
|
||||
if got := readFile(t, bin+".prev"); got != "old binary bytes" {
|
||||
t.Errorf("prev binary = %q, want the one the swap kept", got)
|
||||
}
|
||||
if buildPending(tr.install) {
|
||||
t.Error("a finished build must clear the pending marker")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryFinishesTheBuildWhenTheStateWriteFailed: the merge landed, but the update
|
||||
// aborted before the state file could be written (a full disk, a directory sitting where
|
||||
// the file goes). The tree still owes a binary, and the retry must know that - the marker
|
||||
// is written the moment the merge lands, not after the bookkeeping that may fail.
|
||||
func TestRetryFinishesTheBuildWhenTheStateWriteFailed(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
fake := scriptFakeGo(t)
|
||||
fake.succeed(t)
|
||||
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "old binary bytes")
|
||||
tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"})
|
||||
|
||||
// A directory where writeState's temp file goes makes the state write fail.
|
||||
blocked := filepath.Join(tr.install, ".update-state.json.tmp")
|
||||
if err := os.Mkdir(blocked, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := Apply(context.Background(), buildOpts(tr), nil)
|
||||
if ue, ok := err.(*Error); !ok || ue.Reason != "state_unwritable" {
|
||||
t.Fatalf("want state_unwritable, got %v (result %+v)", err, res)
|
||||
}
|
||||
if got := readFile(t, bin); got != "old binary bytes" {
|
||||
t.Errorf("no build may happen while the state write is broken: %q", got)
|
||||
}
|
||||
|
||||
// Clear the obstruction and retry: the owed build must happen, not a success report.
|
||||
if err := os.Remove(blocked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = Apply(context.Background(), buildOpts(tr), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("retry failed: %v (result %+v)", err, res)
|
||||
}
|
||||
if !res.BinaryBuilt {
|
||||
t.Fatalf("retry result = %+v, want the owed build", res)
|
||||
}
|
||||
if got := readFile(t, bin); !strings.Contains(got, "fake binary") {
|
||||
t.Errorf("binary after retry = %q, want the compiled one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdoptBuildFailureIsFinishedByTheNextUpdate: an adoption that landed the source but
|
||||
// failed its build leaves a work tree that has "nothing to pull" on the next click. The
|
||||
// same marker has to make that click compile rather than report "already up to date".
|
||||
func TestAdoptBuildFailureIsFinishedByTheNextUpdate(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr, dir := newAdoptFixture(t)
|
||||
fake := scriptFakeGo(t)
|
||||
fake.failBuild(t)
|
||||
|
||||
o := Options{Root: dir, Repo: tr.upstream, BinaryName: Binary}
|
||||
res, err := Adopt(context.Background(), o, nil)
|
||||
ue, ok := err.(*Error)
|
||||
if !ok || ue.Reason != "build_failed" {
|
||||
t.Fatalf("adopt: err=%v result=%+v, want build_failed", err, res)
|
||||
}
|
||||
snap, err := Status(context.Background(), o)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !snap.BuildPending {
|
||||
t.Error("the adopted tree owes a build; Status must say so")
|
||||
}
|
||||
|
||||
fake.succeed(t)
|
||||
res, err = Apply(context.Background(), o, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("retry failed: %v (result %+v)", err, res)
|
||||
}
|
||||
if !res.BinaryBuilt {
|
||||
t.Fatalf("retry result = %+v, want the owed build", res)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(dir, Binary)); !strings.Contains(got, "fake binary") {
|
||||
t.Errorf("binary after retry = %q, want the compiled one", got)
|
||||
}
|
||||
if buildPending(dir) {
|
||||
t.Error("a finished build must clear the pending marker")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryWithoutToolchainKeepsSayingSoAndThenBuilds: the no_toolchain message tells the
|
||||
// operator "安装 go 后再点一次更新即可补上二进制". Until the fix that retry answered
|
||||
// success without compiling; the promise is only real if the click after installing go
|
||||
// actually builds - and the clicks before it keep an honest refusal.
|
||||
func TestRetryWithoutToolchainKeepsSayingSoAndThenBuilds(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
hideToolchain(t)
|
||||
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "old binary bytes")
|
||||
tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"})
|
||||
|
||||
if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil {
|
||||
t.Fatal("a tree without a toolchain must refuse the build, not report success")
|
||||
} else if ue, ok := err.(*Error); !ok || ue.Reason != "no_toolchain" {
|
||||
t.Fatalf("want no_toolchain, got %v", err)
|
||||
}
|
||||
// Still no go: the retry keeps refusing.
|
||||
if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil {
|
||||
t.Fatal("a retry without a toolchain must keep refusing, not report success")
|
||||
}
|
||||
|
||||
// Toolchain installed: the same click now does what the message said it would.
|
||||
scriptFakeGo(t)
|
||||
res, err := Apply(context.Background(), buildOpts(tr), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("retry with a toolchain failed: %v (result %+v)", err, res)
|
||||
}
|
||||
if !res.BinaryBuilt {
|
||||
t.Errorf("result = %+v, want the binary built on retry", res)
|
||||
}
|
||||
if got := readFile(t, bin); !strings.Contains(got, "fake binary") {
|
||||
t.Errorf("binary = %q, want the compiled one", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRollbackKeepsOperatorEditsToProtectedFiles pins the promise docs make - a rollback
|
||||
// undoes the update, not the operator's content. reset --hard discards every local change
|
||||
// to tracked files, not just the update's, so both an edit made before the update and one
|
||||
// made after it have to be put back on top of the old commit.
|
||||
func TestRollbackKeepsOperatorEditsToProtectedFiles(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
|
||||
// Edited before the update: Apply itself keeps this one (its stash covers it), which is
|
||||
// exactly the shape the rollback then used to wipe.
|
||||
writeFile(t, filepath.Join(tr.install, "roles", "shipped.yaml"), "name: shipped\ndescription: mine before\n")
|
||||
tr.upstreamCommit(t, "upstream moves code and rewrites roles", map[string]string{
|
||||
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
|
||||
"roles/shipped.yaml": "name: shipped\ndescription: upstream rewrite\n",
|
||||
"roles/extra.yaml": "name: extra\ndescription: upstream copy\n",
|
||||
})
|
||||
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Edited after the update: no stash ever saw this one.
|
||||
writeFile(t, filepath.Join(tr.install, "roles", "extra.yaml"), "name: extra\ndescription: mine after\n")
|
||||
|
||||
// The binary pair a swap leaves behind, so the rollback has one to restore.
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "new binary bytes")
|
||||
writeFile(t, bin+".prev", "old binary bytes")
|
||||
|
||||
res, err := Rollback(context.Background(), Options{Root: tr.install})
|
||||
if err != nil {
|
||||
t.Fatalf("rollback failed: %v", err)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"1\"") {
|
||||
t.Errorf("code after rollback = %q, want the old version back", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(tr.install, "roles", "shipped.yaml")); !strings.Contains(got, "mine before") {
|
||||
t.Errorf("an edit made before the update was wiped by the rollback: %q", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(tr.install, "roles", "extra.yaml")); !strings.Contains(got, "mine after") {
|
||||
t.Errorf("an edit made after the update was wiped by the rollback: %q", got)
|
||||
}
|
||||
want := "roles/extra.yaml,roles/shipped.yaml"
|
||||
if got := strings.Join(res.KeptContent, ","); got != want {
|
||||
t.Errorf("keptContent = %v, want %s", res.KeptContent, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRollbackKeepsAHandDeletionOfAProtectedFile: a deletion is a local change like any
|
||||
// other. reset --hard would bring the old commit's copy back; the rollback has to re-apply
|
||||
// the deletion instead.
|
||||
func TestRollbackKeepsAHandDeletionOfAProtectedFile(t *testing.T) {
|
||||
requireGit(t)
|
||||
tr := newTree(t)
|
||||
tr.upstreamCommit(t, "upstream moves", map[string]string{"internal_service.go": "package service\n\nconst Version = \"2\"\n"})
|
||||
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gone := filepath.Join(tr.install, "roles", "shipped.yaml")
|
||||
if err := os.Remove(gone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bin := filepath.Join(tr.install, Binary)
|
||||
writeFile(t, bin, "new binary bytes")
|
||||
writeFile(t, bin+".prev", "old binary bytes")
|
||||
|
||||
res, err := Rollback(context.Background(), Options{Root: tr.install})
|
||||
if err != nil {
|
||||
t.Fatalf("rollback failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(gone); !os.IsNotExist(err) {
|
||||
t.Errorf("a file deleted by hand was resurrected by the rollback (%v); result %+v", err, res)
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,11 @@ type Snapshot struct {
|
||||
HasRollback bool `json:"hasRollback"`
|
||||
// RollbackTo is the commit the previous update came from, when there is one.
|
||||
RollbackTo string `json:"rollbackTo"`
|
||||
// BuildPending says the last update moved the source but never replaced the binary (a
|
||||
// failed compile, or a machine without a toolchain). Such a tree has nothing left to
|
||||
// pull and still owes a build, which is why this is visible without a network round
|
||||
// trip: the page has to offer the click the failure message told the operator to make.
|
||||
BuildPending bool `json:"buildPending"`
|
||||
|
||||
// CheckError carries a fetch or parse failure to the page instead of turning the
|
||||
// whole endpoint into a 500: "offline" and "your token expired" are answers, not
|
||||
@@ -303,6 +308,7 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) {
|
||||
snap.GoToolchain, snap.CanBuild = toolchain(ctx, root)
|
||||
bin := filepath.Join(root, opts.binaryName())
|
||||
snap.HasBinary = fileExists(bin)
|
||||
snap.BuildPending = buildPending(root)
|
||||
if st, ok := readState(root); ok {
|
||||
snap.HasRollback = fileExists(bin+".prev") && st.UpdatedCommit != ""
|
||||
snap.RollbackTo = st.PreviousCommit
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"applyBtn": "Update now",
|
||||
"jobRunningHint": "An update is already running; its progress is below.",
|
||||
"noToolchainWarn": "No Go toolchain here: the source will be updated but the binary will not be replaced; build it externally, then restart.",
|
||||
"buildPendingHint": "The last update moved the source but never replaced the binary (the build failed, or there was no Go toolchain): press Update to finish that build.",
|
||||
"blockedNotGitTree": "This directory is not a git work tree, so it cannot be updated automatically.",
|
||||
"blockedJobRunning": "An update is already running; wait for it to finish.",
|
||||
"blockedLocalEdits": "{{n}} product source files are edited locally and an update will not overwrite them: commit or revert them first.",
|
||||
@@ -149,6 +150,7 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"localChangesTitle": "Local changes",
|
||||
"localChangesHint": "Files marked local content are kept exactly as they are; files marked product source block the automatic update.",
|
||||
@@ -168,6 +170,7 @@
|
||||
"restartRequested": "Exit after completion requested",
|
||||
"jobPollFailed": "Failed to read the progress: {{reason}}",
|
||||
"jobDoneToast": "Update finished",
|
||||
"buildDoneToast": "Build finished",
|
||||
"jobFailedToast": "Update failed",
|
||||
"needsRestartToast": "The binary was replaced; it takes effect after a restart",
|
||||
"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.",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"applyBtn": "Обновить сейчас",
|
||||
"jobRunningHint": "Обновление уже выполняется; его ход показан ниже.",
|
||||
"noToolchainWarn": "Здесь нет инструментария Go: исходники будут обновлены, но бинарный файл не заменится; соберите его извне и перезапустите службу.",
|
||||
"buildPendingHint": "Прошлое обновление перенесло исходники, но не заменило бинарный файл (сборка не удалась или тогда не было инструментария Go): нажмите «Обновить», чтобы завершить эту сборку.",
|
||||
"blockedNotGitTree": "Этот каталог не является git-рабочим деревом, автоматическое обновление невозможно.",
|
||||
"blockedJobRunning": "Обновление уже выполняется; дождитесь его завершения.",
|
||||
"blockedLocalEdits": "{{n}} файлов исходного кода изменены локально, и обновление не будет их перезаписывать: сначала закоммитьте или отмените изменения.",
|
||||
@@ -149,6 +150,7 @@
|
||||
"confirmRestartYes": "Выбрано: процесс завершится после успешного обновления, и вернуть его сможет только внешний супервизор (systemd, run.sh, ...).",
|
||||
"confirmRestartNo": "Не выбрано: процесс продолжит работать со старым бинарным файлом, пока вы сами его не перезапустите.",
|
||||
"confirmApply": "Сейчас будет выполнено: обновление из {{repo}} до {{target}} ({{n}} новых коммитов, эта машина на {{commit}}) -> пересборка бинарного файла -> резервная копия и замена старого.",
|
||||
"confirmBuildPending": "Сейчас будет выполнено: сборка недостающего бинарного файла для уже установленных исходников (эта машина на {{commit}}) -> пересборка -> резервная копия и замена старого бинарного файла.",
|
||||
"applyAccepted": "Обновление запущено (задача {{id}}); ход обновляется каждые 1,5 секунды.",
|
||||
"localChangesTitle": "Локальные изменения",
|
||||
"localChangesHint": "Файлы, помеченные как локальное содержимое, сохраняются как есть; файлы, помеченные как исходный код продукта, блокируют автоматическое обновление.",
|
||||
@@ -168,6 +170,7 @@
|
||||
"restartRequested": "Запрошено завершение после выполнения",
|
||||
"jobPollFailed": "Не удалось прочитать ход выполнения: {{reason}}",
|
||||
"jobDoneToast": "Обновление завершено",
|
||||
"buildDoneToast": "Сборка завершена",
|
||||
"jobFailedToast": "Обновление не удалось",
|
||||
"needsRestartToast": "Бинарный файл заменён; он вступит в силу после перезапуска",
|
||||
"restartBanner": "Обновлено до {{commit}}, но процесс всё ещё работает на старой сборке (бинарник на диске собран {{builtAt}}). Изменения вступят в силу после перезапуска.",
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"applyBtn": "一键更新",
|
||||
"jobRunningHint": "已有一次更新在进行中,下面是它的进度。",
|
||||
"noToolchainWarn": "本机没有 Go 工具链:源码会更新,二进制不会更换,要由外部构建产出后再重启。",
|
||||
"buildPendingHint": "上一次更新只搬了源码、没能换二进制(编译失败或当时没有 Go 工具链):点「一键更新」会补上这次编译。",
|
||||
"blockedNotGitTree": "这个目录不是 git 工作树,无法自动更新。",
|
||||
"blockedJobRunning": "已有更新在进行中,请等它结束。",
|
||||
"blockedLocalEdits": "有 {{n}} 个产品源码文件被本地改过,自动更新不会覆盖它们:先提交或还原。",
|
||||
@@ -149,6 +150,7 @@
|
||||
"confirmRestartYes": "已勾选:更新成功后本进程会退出,只有外部守护(systemd/run.sh 等)才会把它再拉起来。",
|
||||
"confirmRestartNo": "未勾选:进程不退出,运行的仍是旧二进制,需要你自行重启。",
|
||||
"confirmApply": "将要执行:从更新源 {{repo}} 更新到 {{target}}({{n}} 个新提交,本机当前 {{commit}})→ 重新编译二进制 → 备份并替换旧二进制。",
|
||||
"confirmBuildPending": "将要执行:为已经就位的源码补上二进制(本机当前 {{commit}})→ 重新编译 → 备份并替换旧二进制。",
|
||||
"applyAccepted": "更新已开始(任务 {{id}}),进度每 1.5 秒刷新一次。",
|
||||
"localChangesTitle": "本机改动",
|
||||
"localChangesHint": "标为「本机内容」的文件更新后原样保留;标为「产品源码」的文件会阻止自动更新。",
|
||||
@@ -168,6 +170,7 @@
|
||||
"restartRequested": "已要求在完成后退出",
|
||||
"jobPollFailed": "进度读取失败:{{reason}}",
|
||||
"jobDoneToast": "更新完成",
|
||||
"buildDoneToast": "补编译完成",
|
||||
"jobFailedToast": "更新失败",
|
||||
"needsRestartToast": "二进制已换新,重启后才会生效",
|
||||
"restartBanner": "已更新到 {{commit}},但当前进程仍在运行旧版本(磁盘上的二进制构建于 {{builtAt}})。重启后新版本才会生效。",
|
||||
|
||||
@@ -696,6 +696,92 @@ test('local edits, a diverged tree and a clean-but-unchecked tree each block the
|
||||
assert.equal(busy.liveTimers().length, 1, 'opening onto a running job resumes the progress read');
|
||||
});
|
||||
|
||||
test('a tree that only owes a build still offers the button, and says it is a build', async () => {
|
||||
// Source already at the update source's tip, binary stale: the retry the no_toolchain
|
||||
// message talks about has to be reachable from this page. "0 new commits" is not a
|
||||
// reason to disable the button, and the confirm dialog must not claim nothing will
|
||||
// happen.
|
||||
const builtJob = {
|
||||
id: 'upd-9',
|
||||
state: 'succeeded',
|
||||
started: '2026-10-08T10:00:00+08:00',
|
||||
finished: '2026-10-08T10:00:40+08:00',
|
||||
steps: [{ phase: 'build', message: '开始编译二进制(首次会下载依赖,可能需要几分钟)', at: '10:00:01' }],
|
||||
result: {
|
||||
fromCommit: 'a1b2c3d', toCommit: 'a1b2c3d',
|
||||
commits: 0, filesTouched: 0, keptContent: [],
|
||||
binaryPath: '/srv/csai/cyberstrike-ai',
|
||||
binaryBuilt: true, prevBinary: '/srv/csai/cyberstrike-ai.prev',
|
||||
backupDir: '/srv/csai/.update-backup/20261008_100000',
|
||||
needsRestart: true, duration: '40s',
|
||||
},
|
||||
restartRequested: false,
|
||||
};
|
||||
const pendingStatus = statusOf({
|
||||
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0, buildPending: true,
|
||||
});
|
||||
const h = await harness({
|
||||
status: pendingStatus,
|
||||
responses: {
|
||||
'GET /api/system/update/job': [{ status: 200, body: { job: builtJob } }],
|
||||
'GET /api/system/update': [
|
||||
{ status: 200, body: envelope(pendingStatus, null, true) },
|
||||
{ status: 200, body: envelope(statusOf({ updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0, commit: 'a1b2c3d' }), builtJob, true) },
|
||||
],
|
||||
},
|
||||
});
|
||||
const html = h.html();
|
||||
assert.match(html, /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/,
|
||||
'a pending build must not read as "nothing to update" and disable the button');
|
||||
assert.match(html, /补上这次编译/, 'the page must say what pressing update will do');
|
||||
assert.doesNotMatch(html, /更新源没有比本机更新的提交/);
|
||||
|
||||
h.calls.length = 0;
|
||||
h.toasts.length = 0;
|
||||
h.fire('startUpdateApply');
|
||||
await h.flush();
|
||||
const apply = h.calls.find(c => c.method === 'POST' && c.url === '/api/system/update/apply');
|
||||
assert.ok(apply, 'the retry must reach the server: ' + h.calls.map(c => c.method + ' ' + c.url));
|
||||
assert.match(h.confirms[h.confirms.length - 1], /补上二进制/,
|
||||
'the confirm dialog describes a build, not "0 new commits"');
|
||||
const success = h.toasts.filter(t => t.type === 'success' && /补编译完成/.test(t.msg)).pop();
|
||||
assert.ok(success, 'a finished build retry is announced as a build: ' + JSON.stringify(h.toasts));
|
||||
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /a1b2c3d → a1b2c3d/.test(t.msg)), [],
|
||||
'a build-only retry must not be announced as "update a1b2c3d → a1b2c3d"');
|
||||
});
|
||||
|
||||
test('a pending build with no toolchain fails with the server sentence, never a success', async () => {
|
||||
const failed = {
|
||||
id: 'upd-3',
|
||||
state: 'failed',
|
||||
started: '2026-10-08T11:00:00+08:00',
|
||||
finished: '2026-10-08T11:00:01+08:00',
|
||||
steps: [],
|
||||
failure: {
|
||||
reason: 'no_toolchain',
|
||||
message: '源码已经是更新源最新(a1b2c3d),但二进制还是旧的:本机没有 Go 工具链,无法补编译。装好 go 后再点一次更新即可补上二进制。',
|
||||
},
|
||||
restartRequested: false,
|
||||
};
|
||||
const h = await harness({
|
||||
status: statusOf({
|
||||
updateAvailable: false, behind: 0, incoming: [], incomingTotal: 0,
|
||||
buildPending: true, canBuild: false, goToolchain: '',
|
||||
}),
|
||||
responses: {
|
||||
'GET /api/system/update/job': [{ status: 200, body: { job: failed } }],
|
||||
},
|
||||
});
|
||||
// 按钮仍可点:缺工具链是环境事实,让操作员看到服务端那句"装好 go 再来"才是出路。
|
||||
assert.match(h.html(), /<button class="btn-primary update-apply-btn" data-require-permission="update:apply" onclick="startUpdateApply\(\)">/);
|
||||
await h.api.startUpdateApply();
|
||||
await h.flush();
|
||||
assert.deepEqual(h.toasts.filter(t => t.type === 'success' && /更新完成|补编译完成/.test(t.msg)), [],
|
||||
'a build that did not happen must never be announced as done: ' + JSON.stringify(h.toasts));
|
||||
assert.ok(h.toasts.some(t => t.type === 'error' && /Go 工具链/.test(t.msg)),
|
||||
'the server sentence must be shown: ' + JSON.stringify(h.toasts));
|
||||
});
|
||||
|
||||
test('applying posts {"restart":false} and then reads the job every 1.5 seconds', async () => {
|
||||
// This test is about the poll cadence and the final re-read, so the job finishes without
|
||||
// having asked for a restart; the restart hand-off has its own test below.
|
||||
|
||||
+27
-12
@@ -332,9 +332,7 @@ async function pollUpdateJob() {
|
||||
if (job.state === 'succeeded' && job.restartRequested) {
|
||||
// 进程马上要让位给新二进制:先把结论播报掉,再进自恢复视图;不再回头
|
||||
// 去读一个即将消失的服务(reportFinishedJob 会去读,这里跳过它)。
|
||||
const result = job.result || null;
|
||||
const tail = result && result.toCommit ? ' ' + result.fromCommit + ' → ' + result.toCommit : '';
|
||||
notify(updateT('jobDoneToast') + tail, 'success');
|
||||
notify(updateDoneToast(job.result || null), 'success');
|
||||
startUpdateWatchdog();
|
||||
return;
|
||||
}
|
||||
@@ -343,13 +341,22 @@ async function pollUpdateJob() {
|
||||
}
|
||||
}
|
||||
|
||||
// 完成时的这一句只念服务端写下来的结论,但补编译与普通更新是两种完成:commits=0 却换了
|
||||
// 二进制时念"a1b2c3d → a1b2c3d"等于没说,得说"补编译完成"。
|
||||
function updateDoneToast(result) {
|
||||
if (result && result.binaryBuilt && result.fromCommit && result.fromCommit === result.toCommit) {
|
||||
return updateT('buildDoneToast');
|
||||
}
|
||||
const tail = result && result.toCommit ? ' ' + result.fromCommit + ' → ' + result.toCommit : '';
|
||||
return updateT('jobDoneToast') + tail;
|
||||
}
|
||||
|
||||
// 播报只念服务端写下来的结论:成功念 result 的两个提交号,失败念 failure.message,
|
||||
// 页面自己不在这里编"更新完成"。
|
||||
function reportFinishedJob(job) {
|
||||
const result = job.result || null;
|
||||
if (job.state === 'succeeded') {
|
||||
const tail = result && result.toCommit ? ' ' + result.fromCommit + ' → ' + result.toCommit : '';
|
||||
notify(updateT('jobDoneToast') + tail, 'success');
|
||||
notify(updateDoneToast(result), 'success');
|
||||
if (result && result.needsRestart) notify(updateT('needsRestartToast'), 'info');
|
||||
} else {
|
||||
const reason = (job.failure && job.failure.message) ? job.failure.message : updateT('failureNoMessage');
|
||||
@@ -418,7 +425,7 @@ function updateApplyBlockers(status, job) {
|
||||
behind: status.behind || 0,
|
||||
}));
|
||||
}
|
||||
if (!status.updateAvailable) blockers.push(updateT('blockedNoUpdate'));
|
||||
if (!status.updateAvailable && !status.buildPending) blockers.push(updateT('blockedNoUpdate'));
|
||||
return blockers;
|
||||
}
|
||||
|
||||
@@ -436,12 +443,15 @@ function updateRestartRequested() {
|
||||
}
|
||||
|
||||
function updateConfirmText(status, restart) {
|
||||
const base = updateT('confirmApply', {
|
||||
repo: status.repo || '-',
|
||||
target: status.remoteCommit || '-',
|
||||
commit: status.commit || '-',
|
||||
n: updateBehindCount(status),
|
||||
});
|
||||
// 补编译和"更新 N 个提交"是两件事:一句说"0 个新提交"的确认框会让人以为白点。
|
||||
const base = (!status.updateAvailable && status.buildPending)
|
||||
? updateT('confirmBuildPending', { commit: status.commit || '-' })
|
||||
: updateT('confirmApply', {
|
||||
repo: status.repo || '-',
|
||||
target: status.remoteCommit || '-',
|
||||
commit: status.commit || '-',
|
||||
n: updateBehindCount(status),
|
||||
});
|
||||
return base + ' ' + (restart ? updateT('confirmRestartYes') : updateT('confirmRestartNo'));
|
||||
}
|
||||
|
||||
@@ -769,6 +779,11 @@ function renderUpdateApplyBody(status, job) {
|
||||
if (!status.canBuild && status.installed) {
|
||||
parts.push('<div class="update-warn">' + escapeHtml(updateT('noToolchainWarn')) + '</div>');
|
||||
}
|
||||
// 源码已经在更新源尖端、只欠一次编译:这不是"没有可用更新",按钮必须照常可点,而且
|
||||
// 得说清楚点它会去补编译——否则 no_toolchain 里那句"再点一次更新"在页面上根本没有落点。
|
||||
if (status.buildPending && status.installed) {
|
||||
parts.push('<div class="update-warn">' + escapeHtml(updateT('buildPendingHint')) + '</div>');
|
||||
}
|
||||
|
||||
// Both halves matter: `disabled` for the browser, `data-state-disabled` so the platform's
|
||||
// permission gate (which writes el.disabled for every [data-require-permission] element)
|
||||
|
||||
Reference in new issue
Block a user