diff --git a/.gitignore b/.gitignore index 066a334c..ba163412 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ coverage.html .update-staging/ .update-backup/ .update-state.json +.update-build-pending cyberstrike-ai.prev # Logs and temporary files diff --git a/cmd/server/update_cli.go b/cmd/server/update_cli.go index a9afa81a..a3f7c23d 100644 --- a/cmd/server/update_cli.go +++ b/cmd/server/update_cli.go @@ -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 { diff --git a/docs/zh-CN/deployment.md b/docs/zh-CN/deployment.md index 978309c3..6203bd96 100644 --- a/docs/zh-CN/deployment.md +++ b/docs/zh-CN/deployment.md @@ -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(环境里有对应启动标记)时,"更新完成后退出进程"默认勾选:更新成功后进程退出、 diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index a498368a..5b1430f5 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -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` 同样点名——回滚撤的是代码,不是你的内容。 ### 回滚 diff --git a/internal/update/adopt.go b/internal/update/adopt.go index cfa10e8f..73628c7f 100644 --- a/internal/update/adopt.go +++ b/internal/update/adopt.go @@ -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 diff --git a/internal/update/apply.go b/internal/update/apply.go index 2b3afb7d..243aca60 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -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, diff --git a/internal/update/pending_build_test.go b/internal/update/pending_build_test.go new file mode 100644 index 00000000..32dbe5f6 --- /dev/null +++ b/internal/update/pending_build_test.go @@ -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 ...` 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) + } +} diff --git a/internal/update/rollback_test.go b/internal/update/rollback_test.go new file mode 100644 index 00000000..1025e9d5 --- /dev/null +++ b/internal/update/rollback_test.go @@ -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) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 77d136b9..e738cc0c 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 7fec76bb..f8346cc0 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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.", diff --git a/web/static/i18n/ru-RU.json b/web/static/i18n/ru-RU.json index 623cfa1d..c5f87780 100644 --- a/web/static/i18n/ru-RU.json +++ b/web/static/i18n/ru-RU.json @@ -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}}). Изменения вступят в силу после перезапуска.", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index c3bdd758..d50aabdb 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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}})。重启后新版本才会生效。", diff --git a/web/static/js/update-ui.test.cjs b/web/static/js/update-ui.test.cjs index 21726329..7b52391e 100644 --- a/web/static/js/update-ui.test.cjs +++ b/web/static/js/update-ui.test.cjs @@ -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, /