From b645914e1f341b6e4ba28452603cf0398cd6e0ba Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:46:17 +0800 Subject: [PATCH] fix: a rollback keeps a recreated role, and an owed build is finished first Three ways the installation could still end up with its source, its binary and the operator's content describing different things. Rollback copied aside only *tracked* protected files, so a role upstream had deleted and the operator had recreated at the same path was untracked - and `git reset --hard` wrote the old commit's copy over it. The stash now also covers untracked protected paths the target commit writes to, and names them in keptContent like any other file it saved. The pending-build marker said only "a compile is owed". An update that found new commits therefore merged them and built the *new* tree, leaving `.prev` holding a binary for a commit the state file no longer named - a rollback then paired old source with the original binary. And a marker left behind after a successful swap rebuilt and overwrote that same `.prev`. The marker now names its target commit and the hashes on both sides of the swap, the state records which commit the live binary came from, an owed build is finished before the tree moves again, and a retry that finds the swap already done clears the marker instead of repeating it. Rollback verifies that pairing before touching anything and refuses (binary_changed) rather than reset the source beside a binary it cannot account for; an update whose build failed never swapped one, so its rollback moves the source only and no longer demands a `.prev`. The version pair is recorded before the config is written, so a crash in between still leaves a rollback that can put the old number back. --- .gitignore | 2 + docs/en-US/developer-guide.md | 9 +- docs/zh-CN/developer-guide.md | 8 +- internal/update/adopt.go | 45 ++- internal/update/apply.go | 532 ++++++++++++++++++++++---- internal/update/build.go | 34 ++ internal/update/pending_build_test.go | 330 +++++++++++++++- internal/update/rollback_test.go | 31 ++ internal/update/update.go | 3 +- internal/update/version.go | 20 + 10 files changed, 910 insertions(+), 104 deletions(-) diff --git a/.gitignore b/.gitignore index ba163412..ed0dd309 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,9 @@ coverage.html .update-staging/ .update-backup/ .update-state.json +.update-state.json.tmp .update-build-pending +.update-build-pending.tmp cyberstrike-ai.prev # Logs and temporary files diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index 84544a3c..b365e8f1 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -132,9 +132,12 @@ version that lands is yours, and the result says so. ### Rollback `./cyberstrike-ai -update-rollback`, or the button on the page: `git reset --hard` back to the commit -the update came from, the binary kept as `cyberstrike-ai.prev` is put back, and `.update-state.json` -is removed. It refuses when there is no update record (`no_state`), no kept binary (`no_binary`), the -recorded commit is not in this repository (`bad_state`), **HEAD has moved since that update** +the update came from, the binary kept as `cyberstrike-ai.prev` is put back when that update swapped +one, and `.update-state.json` is removed. An update whose build failed never swapped a binary, so its +rollback moves the source only - the binary on disk already belongs to the commit it returns to. It +refuses when there is no update record (`no_state`), a swap is owed but no `cyberstrike-ai.prev` is +left (`no_binary`), a recorded commit is not in this repository (`bad_state`), the binary on disk is +not the one the record pairs with that commit (`binary_changed`), **HEAD has moved since that update** (`moved_since_update` - a rollback undoes the update, not the work done after it), or source is modified locally (`local_source_edits`). diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index 5b1430f5..5a3e393e 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -127,9 +127,11 @@ update: ### 回滚 `./cyberstrike-ai -update-rollback` 或页面按钮:`git reset --hard` 回到那次更新前的提交, -并把换二进制时留下的 `cyberstrike-ai.prev` 放回原位,随后删除 `.update-state.json`。 -它会拒绝的情形:没有更新记录(`no_state`)、没有留下旧二进制(`no_binary`)、记录里的提交在本仓库不存在 -(`bad_state`)、**HEAD 自那次更新之后又动过**(`moved_since_update`:回滚只该撤到更新前,不该顺手抹掉之后的工作)、 +换过二进制的那次会把 `cyberstrike-ai.prev` 放回原位,随后删除 `.update-state.json`。 +编译失败的那次更新从没换过二进制,所以它的回滚只搬源码——磁盘上的二进制本来就属于要回去的那个提交。 +它会拒绝的情形:没有更新记录(`no_state`)、需要换回却没有留下 `cyberstrike-ai.prev`(`no_binary`)、 +记录里的提交在本仓库不存在(`bad_state`)、磁盘上的二进制不是记录里与该提交配对的那一个(`binary_changed`)、 +**HEAD 自那次更新之后又动过**(`moved_since_update`:回滚只该撤到更新前,不该顺手抹掉之后的工作)、 以及存在本地源码改动(`local_source_edits`)。 ### 重启的两种情形 diff --git a/internal/update/adopt.go b/internal/update/adopt.go index 73628c7f..fa14cf3f 100644 --- a/internal/update/adopt.go +++ b/internal/update/adopt.go @@ -142,28 +142,47 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error step("protect", fmt.Sprintf("%d 个文件已留底(被替换的在 %s/overwritten/,运维者的会原样放回)", len(backupAll), backupDir)) } + targetCommit, err := gitCmd(ctx, root, "rev-parse", ref) + if err != nil { + return nil, err + } + pending := buildPendingState{ + Commit: targetCommit, + BackupDir: backupDir, + KeptContent: append([]string(nil), scan.kept...), + } + if opts.binaryName() != "none" { + bin := filepath.Join(root, opts.binaryName()) + if fileExists(bin) { + pending.PreviousBinarySHA256, err = fileSHA256(bin) + if err != nil { + return nil, err + } + } + } if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", branch, ref); err != nil { return nil, &Error{Reason: "checkout_failed", Message: "落地目标内容失败:" + err.Error()} } + // The source has landed. Keep the repository from here onward so any failure can be + // resumed by the normal update path instead of hiding the moved tree by deleting .git. + adopted = true + // The connected code carries its own release version and the live config gets it below; + // the pair is recorded in the marker first so a crash during that write still leaves a + // recovery record that can put the old number back. + pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) + if opts.binaryName() != "none" { + if err := writeBuildPending(root, pending); err != nil { + _ = restoreProtected(backupDir, scan.kept) + return nil, &Error{Reason: "state_unwritable", Message: "无法写入待编译状态:" + err.Error()} + } + } if err := restoreProtected(backupDir, scan.kept); err != nil { return nil, err } if _, err := gitCmd(ctx, root, "branch", "--set-upstream-to="+ref); err != nil { return nil, &Error{Reason: "upstream_failed", Message: "设置分支跟踪失败:" + err.Error()} } - // From here the directory IS a work tree tracking the source: a later build failure - // 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. versionBefore, versionAfter, versionErr := syncVersionToConfig(root) versionStep(step, versionBefore, versionAfter, versionErr) @@ -194,7 +213,7 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error } } - if err := buildAndSwap(ctx, root, opts, res, step); err != nil { + if err := buildAndSwap(ctx, root, opts, res, step, targetCommit, nil, pending); err != nil { res.Duration = time.Since(start).Round(time.Millisecond).String() return res, err } diff --git a/internal/update/apply.go b/internal/update/apply.go index 243aca60..618783b8 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -22,6 +22,12 @@ type State struct { UpdatedCommit string `json:"updated_commit"` BackupDir string `json:"backup_dir"` UpdatedAt string `json:"updated_at"` + // BinaryCommit says which source commit produced the live binary. Before a build it is + // PreviousCommit; after a successful swap it is UpdatedCommit. Old state files omit it + // and keep the legacy assumption that their .prev binary must be restored. + BinaryCommit string `json:"binary_commit,omitempty"` + BinarySHA256 string `json:"binary_sha256,omitempty"` + PreviousBinarySHA256 string `json:"previous_binary_sha256,omitempty"` // VersionBefore/VersionAfter record a version-line change this update made, so a // rollback can put the old number back next to the old code. Absent for updates that // touched no version (old state files simply have no such fields). @@ -39,15 +45,56 @@ func statePath(root string) string { return filepath.Join(root, stateFile) } // (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) +type buildPendingState struct { + Commit string `json:"commit"` + PreviousCommit string `json:"previous_commit,omitempty"` + BackupDir string `json:"backup_dir,omitempty"` + KeptContent []string `json:"kept_content,omitempty"` + VersionBefore string `json:"version_before,omitempty"` + VersionAfter string `json:"version_after,omitempty"` + BinarySHA256 string `json:"binary_sha256,omitempty"` + PreviousBinarySHA256 string `json:"previous_binary_sha256,omitempty"` + Legacy bool `json:"-"` } -func clearBuildPending(root string) { _ = os.Remove(buildPendingPath(root)) } +func buildPendingPath(root string) string { return filepath.Join(root, buildPendingFile) } + +func readBuildPending(root string) (buildPendingState, bool, error) { + var st buildPendingState + data, err := os.ReadFile(buildPendingPath(root)) + if os.IsNotExist(err) { + return st, false, nil + } + if err != nil { + return st, true, err + } + if err := json.Unmarshal(data, &st); err != nil { + if strings.TrimSpace(string(data)) == "source is ahead of the binary" { + return buildPendingState{Legacy: true}, true, nil + } + return buildPendingState{}, true, fmt.Errorf("待编译状态格式损坏: %w", err) + } + return st, true, nil +} + +func writeBuildPending(root string, st buildPendingState) error { + data, err := json.Marshal(st) + if err != nil { + return err + } + tmp := buildPendingPath(root) + ".tmp" + if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil { + return err + } + return os.Rename(tmp, buildPendingPath(root)) +} + +func clearBuildPending(root string) error { + if err := os.Remove(buildPendingPath(root)); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} func buildPending(root string) bool { return fileExists(buildPendingPath(root)) } @@ -164,31 +211,26 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error Message: fmt.Sprintf("本地领先 %d 个提交、落后 %d 个提交:这是合并而不是下载,请手工处理后重试。", snap.Ahead, snap.Behind), } } + + res := &Result{ + FromCommit: snap.Commit, ToCommit: snap.Commit, + BinaryPath: filepath.Join(snap.Root, opts.binaryName()), + } + if buildPending(snap.Root) && opts.binaryName() != "none" { + 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 + } + } if !snap.UpdateAvailable { // Nothing to move - but the installation's own bookkeeping still gets aligned: a // version left over from a tree that was updated before this behaviour existed (or // 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) - res := &Result{ - FromCommit: snap.Commit, ToCommit: snap.Commit, - BinaryPath: filepath.Join(snap.Root, opts.binaryName()), - } - // "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 } @@ -207,17 +249,49 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error } step("protect", fmt.Sprintf("已暂存 %d 个本地内容文件(roles/skills/tools/数据/配置),更新完成后放回", len(kept))) - if out, err := gitCmd(ctx, root, "merge", "--ff-only", "--no-verify", ref); err != nil { + previousCommit, err := gitCmd(ctx, root, "rev-parse", "HEAD") + if err != nil { restoreProtected(backupDir, kept) - return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))} + return nil, err + } + updatedCommit, err := gitCmd(ctx, root, "rev-parse", ref) + if err != nil { + restoreProtected(backupDir, kept) + return nil, err + } + bin := filepath.Join(root, opts.binaryName()) + pending := buildPendingState{ + Commit: updatedCommit, + PreviousCommit: previousCommit, + BackupDir: backupDir, + KeptContent: kept, + } + if opts.binaryName() != "none" && fileExists(bin) { + pending.PreviousBinarySHA256, err = fileSHA256(bin) + if err != nil { + restoreProtected(backupDir, kept) + return nil, err + } } - // 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) + if err := writeBuildPending(root, pending); err != nil { + return nil, &Error{Reason: "state_unwritable", Message: "无法写入待编译状态:" + err.Error()} + } + } + if err := removeProtected(root, kept); err != nil { + if restoreErr := restoreProtected(backupDir, kept); restoreErr != nil { + return nil, &Error{Reason: "backup_failed", Message: fmt.Sprintf("%v;恢复本地内容又失败:%v", err, restoreErr)} + } + _ = clearBuildPending(root) + return nil, err + } + + if out, err := gitCmd(ctx, root, "merge", "--ff-only", "--no-verify", ref); err != nil { + if restoreErr := restoreProtected(backupDir, kept); restoreErr != nil { + return nil, &Error{Reason: "backup_failed", Message: fmt.Sprintf("快进合并失败,恢复本地内容又失败:%v", restoreErr)} + } + _ = clearBuildPending(root) + return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))} } // 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. @@ -225,30 +299,44 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error return nil, err } - files, _ := gitRaw(ctx, root, "diff", "--name-only", "-z", snap.Commit+".."+snap.RemoteCommit) - res := &Result{ - FromCommit: snap.Commit, - ToCommit: snap.RemoteCommit, - Commits: snap.Behind, - KeptContent: kept, - BackupDir: backupDir, - FilesTouched: len(nulList(files)), - } + files, _ := gitRaw(ctx, root, "diff", "--name-only", "-z", previousCommit+".."+updatedCommit) + res.FromCommit = snap.Commit + res.ToCommit = snap.RemoteCommit + res.Commits = snap.Behind + res.KeptContent = kept + res.BackupDir = backupDir + res.FilesTouched = len(nulList(files)) + res.BinaryPath = bin // The new code carries its own release version; the live config (which no merge ever // touches) gets it now, so the page header and the static cache buster follow the code. + // The pair is recorded in the marker first: a crash during the write below would + // otherwise leave the new number on disk with nothing saying what it replaced. + pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) + if opts.binaryName() != "none" { + if err := writeBuildPending(root, pending); err != nil { + return res, &Error{Reason: "state_unwritable", Message: "无法更新待编译状态:" + err.Error()} + } + } versionBefore, versionAfter, versionErr := syncVersionToConfig(root) versionStep(step, versionBefore, versionAfter, versionErr) - if err := writeState(root, State{ - PreviousCommit: snap.Commit, UpdatedCommit: snap.RemoteCommit, BackupDir: backupDir, - VersionBefore: versionBefore, VersionAfter: versionAfter, - }); err != nil { - return nil, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()} + state := State{ + PreviousCommit: previousCommit, + UpdatedCommit: updatedCommit, + BackupDir: backupDir, + VersionBefore: versionBefore, + VersionAfter: versionAfter, + } + if opts.binaryName() != "none" { + state.BinaryCommit = previousCommit + state.BinarySHA256 = pending.PreviousBinarySHA256 + state.PreviousBinarySHA256 = pending.PreviousBinarySHA256 + } + if err := writeState(root, state); err != nil { + return res, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()} } - bin := filepath.Join(root, opts.binaryName()) - res.BinaryPath = bin if opts.binaryName() == "none" { step("build", "按请求跳过重新编译;源码已更新,重启后由外部构建流程产出二进制") res.NeedsRestart = true @@ -263,7 +351,7 @@ 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 { + if err := buildAndSwap(ctx, root, opts, res, step, updatedCommit, &state, pending); err != nil { res.Duration = time.Since(start).Round(time.Millisecond).String() return res, err } @@ -272,27 +360,160 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error 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. +// finishPendingBuild compiles the binary a previous update left missing. The marker carries +// the target commit and hashes around the swap, so a retry can distinguish "still owed" +// from "the swap finished and only marker cleanup failed" without overwriting .prev. func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res *Result, step func(string, string)) error { + pending, present, err := readBuildPending(snap.Root) + if err != nil { + return &Error{Reason: "state_unreadable", Message: "无法读取待编译状态:" + err.Error()} + } + if !present { + return nil + } + head, err := gitCmd(ctx, snap.Root, "rev-parse", "HEAD") + if err != nil { + return err + } + bin := filepath.Join(snap.Root, opts.binaryName()) + state, hasState := readState(snap.Root) + if pending.Legacy && hasState { + updated, resolveErr := gitCmd(ctx, snap.Root, "rev-parse", state.UpdatedCommit) + if resolveErr != nil { + return &Error{Reason: "bad_state", Message: "旧待编译标记对应的更新提交不存在:" + state.UpdatedCommit} + } + if updated != head { + return &Error{Reason: "build_state_mismatch", Message: fmt.Sprintf("旧待编译标记属于 %s,但当前源码已经是 %s,拒绝重解释回滚记录", shortHash(updated), shortHash(head))} + } + pending.Commit = updated + pending.PreviousCommit = state.PreviousCommit + pending.BackupDir = state.BackupDir + pending.VersionBefore = state.VersionBefore + pending.VersionAfter = state.VersionAfter + } + + if pending.Commit == "" { + if hasState && state.BinaryCommit != "" && state.BinarySHA256 != "" { + binaryCommit, commitErr := gitCmd(ctx, snap.Root, "rev-parse", state.BinaryCommit) + binaryHash, hashErr := fileSHA256(bin) + if commitErr == nil && hashErr == nil && binaryCommit == head && binaryHash == state.BinarySHA256 { + if err := clearBuildPending(snap.Root); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已经是当前版本,但无法清除待编译标记:" + err.Error()} + } + res.BinaryBuilt = true + if fileExists(bin + ".prev") { + res.PrevBinary = bin + ".prev" + } + res.NeedsRestart = true + return nil + } + } + if revision := binaryRevision(bin); revision != "" { + resolved, resolveErr := gitCmd(ctx, snap.Root, "rev-parse", revision) + if resolveErr == nil && resolved == head { + if err := clearBuildPending(snap.Root); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已经是当前版本,但无法清除旧待编译标记:" + err.Error()} + } + res.BinaryBuilt = true + if fileExists(bin + ".prev") { + res.PrevBinary = bin + ".prev" + } + res.NeedsRestart = true + return nil + } + } + pending.Commit = head + } + + target, err := gitCmd(ctx, snap.Root, "rev-parse", pending.Commit) + if err != nil { + return &Error{Reason: "bad_state", Message: "待编译状态里的提交不存在:" + pending.Commit} + } + if target != head { + if pending.PreviousCommit != "" { + previous, previousErr := gitCmd(ctx, snap.Root, "rev-parse", pending.PreviousCommit) + if previousErr == nil && previous == head { + if err := restoreInterruptedProtected(ctx, snap.Root, pending, false); err != nil { + return err + } + if err := clearBuildPending(snap.Root); err != nil { + return &Error{Reason: "state_unwritable", Message: "本地内容已恢复,但无法清除待编译标记:" + err.Error()} + } + return nil + } + } + return &Error{Reason: "build_state_mismatch", Message: fmt.Sprintf("待编译目标是 %s,但当前源码是 %s,拒绝用错版本覆盖二进制", shortHash(target), shortHash(head))} + } + if err := restoreInterruptedProtected(ctx, snap.Root, pending, true); err != nil { + return err + } + if len(pending.KeptContent) > 0 { + res.KeptContent = append([]string(nil), pending.KeptContent...) + res.BackupDir = pending.BackupDir + } + if pending.BinarySHA256 != "" && fileExists(bin) { + currentHash, hashErr := fileSHA256(bin) + if hashErr == nil && currentHash == pending.BinarySHA256 { + if pending.PreviousCommit != "" { + state = State{ + PreviousCommit: pending.PreviousCommit, + UpdatedCommit: target, + BackupDir: pending.BackupDir, + BinaryCommit: target, + BinarySHA256: currentHash, + PreviousBinarySHA256: pending.PreviousBinarySHA256, + VersionBefore: pending.VersionBefore, + VersionAfter: pending.VersionAfter, + } + if err := writeState(snap.Root, state); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已换入,但无法补写回滚状态:" + err.Error()} + } + } + if err := clearBuildPending(snap.Root); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已换入,但无法清除待编译标记:" + err.Error()} + } + res.BinaryBuilt = true + if fileExists(bin + ".prev") { + res.PrevBinary = bin + ".prev" + } + res.NeedsRestart = true + return nil + } + } + if !snap.CanBuild { return &Error{ Reason: "no_toolchain", - Message: fmt.Sprintf("源码已经是更新源最新(%s),但二进制还是旧的:本机没有 Go 工具链,无法补编译。装好 go 后再点一次更新即可补上二进制。", snap.Commit), + Message: fmt.Sprintf("源码已经就位(%s),但二进制还是旧的:本机没有 Go 工具链,无法补编译。装好 go 后再点一次更新即可补上二进制。", shortHash(head)), } } - if err := buildAndSwap(ctx, snap.Root, opts, res, step); err != nil { + var targetState *State + if pending.PreviousCommit != "" { + state = State{ + PreviousCommit: pending.PreviousCommit, + UpdatedCommit: target, + BackupDir: pending.BackupDir, + BinaryCommit: pending.PreviousCommit, + BinarySHA256: pending.PreviousBinarySHA256, + PreviousBinarySHA256: pending.PreviousBinarySHA256, + VersionBefore: pending.VersionBefore, + VersionAfter: pending.VersionAfter, + } + targetState = &state + } else if hasState { + targetState = &state + } + if err := buildAndSwap(ctx, snap.Root, opts, res, step, target, targetState, pending); err != nil { return err } - step("done", fmt.Sprintf("源码已经是更新源最新(%s),本次补上了二进制", snap.Commit)) + step("done", fmt.Sprintf("源码已经就位(%s),本次补上了二进制", shortHash(head))) 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 { +// one as .prev. The marker records the compiled file's hash before the rename; after a crash +// a retry can prove whether the swap happened instead of doing it twice. +func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, step func(string, string), commit string, state *State, pending buildPendingState) error { step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)") staging := filepath.Join(root, ".update-staging") if err := os.MkdirAll(staging, 0o755); err != nil { @@ -304,20 +525,67 @@ func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, s if err := build(ctx, root, newBin); err != nil { return &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()} } - prev, err := installBinary(newBin, filepath.Join(root, opts.binaryName())) + newHash, err := fileSHA256(newBin) + if err != nil { + return &Error{Reason: "build_failed", Message: "无法校验新二进制:" + err.Error()} + } + bin := filepath.Join(root, opts.binaryName()) + if pending.Legacy && fileExists(bin) && fileExists(bin+".prev") { + currentHash, currentErr := fileSHA256(bin) + previousHash, previousErr := fileSHA256(bin + ".prev") + if currentErr == nil && previousErr == nil && currentHash == newHash { + res.BinaryBuilt = true + res.PrevBinary = bin + ".prev" + res.NeedsRestart = true + if state != nil { + state.BinaryCommit = commit + state.BinarySHA256 = currentHash + state.PreviousBinarySHA256 = previousHash + if err := writeState(root, *state); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已经是当前版本,但无法更新回滚状态:" + err.Error()} + } + } + if err := clearBuildPending(root); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已经是当前版本,但无法清除旧待编译标记:" + err.Error()} + } + return nil + } + } + if pending.PreviousBinarySHA256 != "" && fileExists(bin) { + currentHash, hashErr := fileSHA256(bin) + if hashErr != nil || currentHash != pending.PreviousBinarySHA256 { + return &Error{Reason: "binary_changed", Message: "编译期间现有二进制发生变化,拒绝覆盖;请确认没有另一场更新在运行"} + } + } + pending.Commit = commit + pending.BinarySHA256 = newHash + if err := writeBuildPending(root, pending); err != nil { + return &Error{Reason: "state_unwritable", Message: "无法记录待换入二进制:" + err.Error()} + } + prev, err := installBinary(newBin, bin) if err != nil { return &Error{Reason: "swap_failed", Message: err.Error()} } res.BinaryBuilt = true res.PrevBinary = prev res.NeedsRestart = true - clearBuildPending(root) + if state != nil { + state.BinaryCommit = commit + state.BinarySHA256 = newHash + state.PreviousBinarySHA256 = pending.PreviousBinarySHA256 + if err := writeState(root, *state); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已换入,但无法更新回滚状态:" + err.Error()} + } + } + if err := clearBuildPending(root); err != nil { + return &Error{Reason: "state_unwritable", Message: "二进制已换入,但无法清除待编译标记:" + err.Error()} + } return nil } // stashProtected copies every operator-owned file that stands in the way of the -// fast-forward into backupDir and removes it from the tree. Only paths the update -// actually touches are removed - a user's extra skill directory is never disturbed. +// fast-forward into backupDir. The caller persists the recovery record before removing +// these paths, so a crash can never strand content without saying where its copy lives. func stashProtected(ctx context.Context, root, ref string) (backupDir string, kept []string, err error) { ts := time.Now().Format("20060102_150405") backupDir = filepath.Join(root, ".update-backup", ts) @@ -344,7 +612,6 @@ func stashProtected(ctx context.Context, root, ref string) (backupDir string, ke targets[p] = true } - written := false for p := range targets { abs := filepath.Join(root, p) info, statErr := os.Stat(abs) @@ -355,12 +622,8 @@ func stashProtected(ctx context.Context, root, ref string) (backupDir string, ke return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} } kept = append(kept, p) - if err := os.Remove(abs); err != nil { - return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("移开 %s 失败:%v", p, err)} - } - written = true } - if written || len(kept) > 0 { + if len(kept) > 0 { sort.Strings(kept) return backupDir, kept, nil } @@ -368,14 +631,24 @@ func stashProtected(ctx context.Context, root, ref string) (backupDir string, ke return "", nil, nil } +func removeProtected(root string, kept []string) error { + for _, rel := range kept { + if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) { + return &Error{Reason: "backup_failed", Message: fmt.Sprintf("移开 %s 失败:%v", rel, err)} + } + } + return 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) { +func stashProtectedEdits(ctx context.Context, root, target string, changed []Change) (backupDir string, kept, deleted []string, err error) { ts := time.Now().Format("20060102_150405") + files := map[string]bool{} for _, c := range changed { if !c.Protected { continue @@ -384,7 +657,23 @@ func stashProtectedEdits(root string, changed []Change) (backupDir string, kept, deleted = append(deleted, c.Path) continue } - abs := filepath.Join(root, filepath.FromSlash(c.Path)) + files[c.Path] = true + } + + incoming, diffErr := gitRaw(ctx, root, "diff", "--name-only", "-z", "HEAD.."+target) + if diffErr != nil { + return "", nil, nil, &Error{Reason: "diff_failed", Message: diffErr.Error()} + } + willWrite := map[string]bool{} + for _, p := range nulList(incoming) { + willWrite[filepath.ToSlash(p)] = true + } + for _, p := range untrackedUnder(ctx, root, willWrite) { + files[p] = true + } + + for p := range files { + abs := filepath.Join(root, filepath.FromSlash(p)) info, statErr := os.Stat(abs) if statErr != nil || !info.Mode().IsRegular() { continue @@ -392,10 +681,10 @@ func stashProtectedEdits(root string, changed []Change) (backupDir string, kept, 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)} + if err := copyFile(abs, filepath.Join(backupDir, filepath.FromSlash(p)), info.Mode()); err != nil { + return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} } - kept = append(kept, c.Path) + kept = append(kept, p) } sort.Strings(kept) sort.Strings(deleted) @@ -424,6 +713,41 @@ func restoreProtected(backupDir string, kept []string) error { return nil } +func restoreInterruptedProtected(ctx context.Context, root string, pending buildPendingState, merged bool) error { + if pending.BackupDir == "" || len(pending.KeptContent) == 0 { + return nil + } + for _, rel := range pending.KeptContent { + rel = filepath.ToSlash(rel) + dst := filepath.Join(root, filepath.FromSlash(rel)) + restore := false + if !fileExists(dst) { + if !merged { + restore = true + } else { + _, headErr := gitCmd(ctx, root, "rev-parse", "HEAD:"+rel) + restore = headErr != nil + } + } else if merged { + workHash, workErr := gitCmd(ctx, root, "hash-object", "--", rel) + headHash, headErr := gitCmd(ctx, root, "rev-parse", "HEAD:"+rel) + restore = workErr == nil && headErr == nil && workHash == headHash + } + if !restore { + continue + } + src := filepath.Join(pending.BackupDir, filepath.FromSlash(rel)) + info, err := os.Stat(src) + if err != nil { + return &Error{Reason: "backup_missing", Message: fmt.Sprintf("待恢复内容的备份不存在:%s", rel)} + } + if err := copyFile(src, dst, info.Mode()); err != nil { + return &Error{Reason: "backup_failed", Message: fmt.Sprintf("恢复 %s 失败:%v", rel, err)} + } + } + return nil +} + // untrackedUnder lists untracked files under the given repository paths - the operator's // own additions, which must be backed up but never deleted by an update. func untrackedUnder(ctx context.Context, root string, willWrite map[string]bool) []string { @@ -457,8 +781,45 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { return nil, &Error{Reason: "no_state", Message: "没有可回滚的更新记录"} } bin := filepath.Join(root, opts.binaryName()) - if !fileExists(bin + ".prev") { - return nil, &Error{Reason: "no_binary", Message: "上一次更新没有留下旧二进制,无法回滚二进制(源码仍可用 git 处理)"} + restoreBinary := true + if st.BinaryCommit != "" { + binaryCommit, resolveErr := gitCmd(ctx, root, "rev-parse", st.BinaryCommit) + if resolveErr != nil { + return nil, &Error{Reason: "bad_state", Message: "更新记录里的二进制提交不存在:" + st.BinaryCommit} + } + previousCommit, resolveErr := gitCmd(ctx, root, "rev-parse", st.PreviousCommit) + if resolveErr != nil { + return nil, &Error{Reason: "bad_state", Message: "更新记录里的回滚提交不存在:" + st.PreviousCommit} + } + updatedCommit, resolveErr := gitCmd(ctx, root, "rev-parse", st.UpdatedCommit) + if resolveErr != nil { + return nil, &Error{Reason: "bad_state", Message: "更新记录里的更新提交不存在:" + st.UpdatedCommit} + } + switch binaryCommit { + case previousCommit: + restoreBinary = false + if st.BinarySHA256 != "" { + got, hashErr := fileSHA256(bin) + if hashErr != nil || got != st.BinarySHA256 { + return nil, &Error{Reason: "binary_changed", Message: "当前二进制与更新前记录不一致,拒绝在无法配对版本时回滚源码"} + } + } + case updatedCommit: + restoreBinary = true + default: + return nil, &Error{Reason: "bad_state", Message: "更新记录里的二进制提交不属于这次更新"} + } + } + if restoreBinary { + if !fileExists(bin + ".prev") { + return nil, &Error{Reason: "no_binary", Message: "上一次更新没有留下旧二进制,无法回滚二进制(源码仍可用 git 处理)"} + } + if st.PreviousBinarySHA256 != "" { + got, hashErr := fileSHA256(bin + ".prev") + if hashErr != nil || got != st.PreviousBinarySHA256 { + return nil, &Error{Reason: "binary_changed", Message: "回滚二进制与更新记录不一致,拒绝把源码回到无法配对的版本"} + } + } } head, err := gitCmd(ctx, root, "rev-parse", "HEAD") if err != nil { @@ -488,8 +849,9 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { // 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) + // back on top of the old commit. An untracked file at a path the old commit tracked is + // included too: reset would otherwise overwrite a role recreated after upstream deleted it. + backupDir, kept, deleted, err := stashProtectedEdits(ctx, root, st.PreviousCommit, changes) if err != nil { return nil, err } @@ -508,13 +870,17 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { return nil, err } } - if err := restorePrevBinary(bin); err != nil { - return nil, &Error{Reason: "swap_failed", Message: err.Error()} + if restoreBinary { + 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) + if err := clearBuildPending(root); err != nil { + return nil, &Error{Reason: "state_unwritable", Message: "回滚完成,但无法清除待编译标记:" + err.Error()} + } // 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) @@ -526,7 +892,7 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { ToCommit: shortHash(st.PreviousCommit), KeptContent: kept, BinaryPath: bin, - BinaryBuilt: true, + BinaryBuilt: restoreBinary, BackupDir: st.BackupDir, NeedsRestart: true, Duration: time.Since(start).Round(time.Millisecond).String(), diff --git a/internal/update/build.go b/internal/update/build.go index e9c887a3..10fa074c 100644 --- a/internal/update/build.go +++ b/internal/update/build.go @@ -2,6 +2,9 @@ package update import ( "context" + "crypto/sha256" + "debug/buildinfo" + "encoding/hex" "fmt" "io" "os" @@ -106,6 +109,32 @@ func restorePrevBinary(bin string) error { return os.Rename(prev, bin) } +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func binaryRevision(path string) string { + info, err := buildinfo.ReadFile(path) + if err != nil { + return "" + } + for _, setting := range info.Settings { + if setting.Key == "vcs.revision" { + return strings.TrimSpace(setting.Value) + } + } + return "" +} + // tail keeps the last bytes of a compiler's output: the end of a build log is where the // error is, while the beginning is where the module downloads are. func tail(s string, n int) string { @@ -134,6 +163,11 @@ func copyFile(src, dst string, mode os.FileMode) error { if err != nil { return err } + if err := out.Chmod(mode.Perm()); err != nil { + out.Close() + os.Remove(tmp) + return err + } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(tmp) diff --git a/internal/update/pending_build_test.go b/internal/update/pending_build_test.go index da78367a..068a6caf 100644 --- a/internal/update/pending_build_test.go +++ b/internal/update/pending_build_test.go @@ -39,7 +39,8 @@ func scriptFakeGo(t *testing.T) *fakeGo { " 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" + "commit=$(git rev-parse --short HEAD) || exit 1\n" + + "echo \"fake binary for $commit\" > \"$out\"\n" if err := os.WriteFile(filepath.Join(dir, "go"), []byte(script), 0o755); err != nil { t.Fatal(err) } @@ -144,6 +145,49 @@ func TestRetryAfterFailedBuildCompilesTheBinary(t *testing.T) { // 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 TestRollbackAfterFailedBuildKeepsTheMatchingPreviousBinary(t *testing.T) { + requireGit(t) + tr := newTree(t) + fake := scriptFakeGo(t) + fake.failBuild(t) + + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + before := mustGit(t, tr.install, "rev-parse", "HEAD") + 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("the build must fail") + } + if fileExists(bin + ".prev") { + t.Fatal("a failed build must not create a rollback binary") + } + snap, err := Status(context.Background(), buildOpts(tr)) + if err != nil { + t.Fatal(err) + } + if !snap.HasRollback { + t.Fatal("source can roll back to the commit already matching the live binary") + } + + res, err := Rollback(context.Background(), buildOpts(tr)) + if err != nil { + t.Fatalf("rollback failed: %v", err) + } + if got := mustGit(t, tr.install, "rev-parse", "HEAD"); got != before { + t.Fatalf("rollback source = %s, want %s", got, before) + } + if got := readFile(t, bin); got != "original binary" { + t.Fatalf("rollback changed an already-matching binary: %q", got) + } + if res.BinaryBuilt { + t.Fatalf("rollback result must not claim a binary swap: %+v", res) + } + if buildPending(tr.install) { + t.Fatal("rollback must clear the pending build marker") + } +} + func TestRetryFinishesTheBuildWhenTheStateWriteFailed(t *testing.T) { requireGit(t) tr := newTree(t) @@ -258,3 +302,287 @@ func TestRetryWithoutToolchainKeepsSayingSoAndThenBuilds(t *testing.T) { t.Errorf("binary = %q, want the compiled one", got) } } + +func TestNewUpdateFinishesThePendingCommitBeforeAdvancing(t *testing.T) { + requireGit(t) + tr := newTree(t) + fake := scriptFakeGo(t) + fake.failBuild(t) + + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + tr.upstreamCommit(t, "second", 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("the first build must fail") + } + second := mustGit(t, tr.install, "rev-parse", "--short", "HEAD") + + tr.upstreamCommit(t, "third", map[string]string{"internal_service.go": "package service\n\nconst Version = \"3\"\n"}) + third := mustGit(t, tr.upstream, "rev-parse", "--short", "HEAD") + fake.succeed(t) + res, err := Apply(context.Background(), buildOpts(tr), nil) + if err != nil { + t.Fatalf("second apply failed: %v (result %+v)", err, res) + } + if got := readFile(t, bin); !strings.Contains(got, third) { + t.Fatalf("live binary = %q, want third commit %s", got, third) + } + if got := readFile(t, bin+".prev"); !strings.Contains(got, second) { + t.Fatalf("rollback binary = %q, want second commit %s", got, second) + } + + if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { + t.Fatalf("rollback failed: %v", err) + } + if head := mustGit(t, tr.install, "rev-parse", "--short", "HEAD"); head != second { + t.Fatalf("rollback source = %s, want %s", head, second) + } + if got := readFile(t, bin); !strings.Contains(got, second) { + t.Fatalf("rollback binary = %q, want the same commit as source %s", got, second) + } +} + +func TestResidualPendingMarkerDoesNotReplaceTheRollbackBinary(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + before := mustGit(t, tr.install, "rev-parse", "--short", "HEAD") + tr.upstreamCommit(t, "second", 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(err) + } + updated := mustGit(t, tr.install, "rev-parse", "--short", "HEAD") + + legacy, ok := readState(tr.install) + if !ok { + t.Fatal("successful update must leave rollback state") + } + legacy.BinaryCommit = "" + legacy.BinarySHA256 = "" + legacy.PreviousBinarySHA256 = "" + if err := writeState(tr.install, legacy); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(buildPendingPath(tr.install), []byte("source is ahead of the binary\n"), 0o644); err != nil { + t.Fatal(err) + } + res, err := Apply(context.Background(), buildOpts(tr), nil) + if err != nil { + t.Fatalf("retry with a residual marker failed: %v", err) + } + if !res.BinaryBuilt || !res.NeedsRestart { + t.Fatalf("the already-installed binary must still be reported as awaiting restart: %+v", res) + } + if got := readFile(t, bin+".prev"); got != "original binary" { + t.Fatalf("residual marker replaced rollback binary with %q", got) + } + + if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { + t.Fatalf("rollback failed: %v", err) + } + if head := mustGit(t, tr.install, "rev-parse", "--short", "HEAD"); head != before { + t.Fatalf("rollback source = %s, want %s (updated was %s)", head, before, updated) + } + if got := readFile(t, bin); got != "original binary" { + t.Fatalf("rollback binary = %q, want original binary", got) + } +} + +func TestLegacyPendingMarkerRefusesAHeadThatMovedAgain(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + tr.upstreamCommit(t, "second", 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(err) + } + rollbackBinary := readFile(t, bin+".prev") + legacy, ok := readState(tr.install) + if !ok { + t.Fatal("successful update must leave rollback state") + } + legacy.BinaryCommit = "" + legacy.BinarySHA256 = "" + legacy.PreviousBinarySHA256 = "" + if err := writeState(tr.install, legacy); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(buildPendingPath(tr.install), []byte("source is ahead of the binary\n"), 0o644); err != nil { + t.Fatal(err) + } + + writeFile(t, filepath.Join(tr.install, "local.go"), "package service\n") + mustGit(t, tr.install, "add", "local.go") + mustGit(t, tr.install, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "local move after update") + if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { + t.Fatal("a legacy marker must not be reinterpreted after HEAD moved") + } else if ue, ok := err.(*Error); !ok || ue.Reason != "build_state_mismatch" { + t.Fatalf("want build_state_mismatch, got %v", err) + } + if got := readFile(t, bin+".prev"); got != rollbackBinary { + t.Fatalf("refused migration changed rollback binary: %q", got) + } +} + +// A crash between the version write and the state write leaves the new number in the live +// config with no rollback record beside it. The marker is written *before* the config, so it +// still carries the pair - which is what lets the retry rebuild that record and the rollback +// put the old number back next to the old code. +func TestRetryRebuildsTheRollbackRecordTheCrashNeverWrote(t *testing.T) { + requireGit(t) + tr := newTree(t) + fake := scriptFakeGo(t) + fake.failBuild(t) + + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n") + tr.upstreamCommit(t, "release: bump version", map[string]string{ + "config.example.yaml": "version: \"v1.1.0\"\n", + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + }) + if _, err := Apply(context.Background(), buildOpts(tr), nil); err == nil { + t.Fatal("the first build must fail") + } + pending, present, err := readBuildPending(tr.install) + if err != nil || !present { + t.Fatalf("marker = %+v present=%v err=%v", pending, present, err) + } + if pending.VersionBefore != "v1.0.0" || pending.VersionAfter != "v1.1.0" { + t.Fatalf("the marker must carry the version pair before the config is written: %+v", pending) + } + + // The crash: the config already carries the new number, the state file never landed. + if err := os.Remove(statePath(tr.install)); err != nil { + t.Fatal(err) + } + fake.succeed(t) + if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { + t.Fatalf("retry failed: %v", err) + } + st, ok := readState(tr.install) + if !ok || st.VersionBefore != "v1.0.0" || st.VersionAfter != "v1.1.0" { + t.Fatalf("the retry must rebuild the rollback record from the marker: ok=%v state=%+v", ok, st) + } + if _, err := Rollback(context.Background(), buildOpts(tr)); err != nil { + t.Fatalf("rollback failed: %v", err) + } + live := readFile(t, filepath.Join(tr.install, "config.yaml")) + if !strings.Contains(live, `version: "v1.0.0"`) || !strings.Contains(live, "port: 8088") { + t.Fatalf("live config after rollback = %q, want the old version back with the settings intact", live) + } +} + +func TestRetryRestoresProtectedContentWhenInterruptedBeforeMerge(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + role := filepath.Join(tr.install, "roles", "shipped.yaml") + writeFile(t, role, "name: shipped\ndescription: mine\n") + tr.upstreamCommit(t, "second", map[string]string{ + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + "roles/shipped.yaml": "name: shipped\ndescription: upstream\n", + }) + + snap, err := Check(context.Background(), buildOpts(tr)) + if err != nil || snap.CheckError != "" { + t.Fatalf("check: %v, %s", err, snap.CheckError) + } + backupDir, kept, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) + if err != nil { + t.Fatal(err) + } + if err := removeProtected(tr.install, kept); err != nil { + t.Fatal(err) + } + if fileExists(role) { + t.Fatal("fixture must stop after the operator role was moved aside") + } + previous := mustGit(t, tr.install, "rev-parse", "HEAD") + target := mustGit(t, tr.install, "rev-parse", snap.sourceRef()) + oldHash, err := fileSHA256(bin) + if err != nil { + t.Fatal(err) + } + if err := writeBuildPending(tr.install, buildPendingState{ + Commit: target, PreviousCommit: previous, BackupDir: backupDir, + KeptContent: kept, PreviousBinarySHA256: oldHash, + }); err != nil { + t.Fatal(err) + } + + if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { + t.Fatalf("retry failed: %v", err) + } + if got := readFile(t, role); !strings.Contains(got, "description: mine") { + t.Fatalf("operator role was not restored: %q", got) + } +} + +func TestRetryAfterMergeRestoresOnlyUntouchedProtectedContent(t *testing.T) { + for _, tc := range []struct { + name string + afterMerge string + want string + }{ + {name: "merge copy untouched", want: "description: mine before crash"}, + {name: "operator edited after crash", afterMerge: "name: shipped\ndescription: mine after crash\n", want: "description: mine after crash"}, + } { + t.Run(tc.name, func(t *testing.T) { + requireGit(t) + tr := newTree(t) + scriptFakeGo(t) + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "original binary") + role := filepath.Join(tr.install, "roles", "shipped.yaml") + writeFile(t, role, "name: shipped\ndescription: mine before crash\n") + tr.upstreamCommit(t, "second", map[string]string{ + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + "roles/shipped.yaml": "name: shipped\ndescription: upstream\n", + }) + + snap, err := Check(context.Background(), buildOpts(tr)) + if err != nil || snap.CheckError != "" { + t.Fatalf("check: %v, %s", err, snap.CheckError) + } + backupDir, kept, err := stashProtected(context.Background(), tr.install, snap.sourceRef()) + if err != nil { + t.Fatal(err) + } + if err := removeProtected(tr.install, kept); err != nil { + t.Fatal(err) + } + previous := mustGit(t, tr.install, "rev-parse", "HEAD") + target := mustGit(t, tr.install, "rev-parse", snap.sourceRef()) + oldHash, err := fileSHA256(bin) + if err != nil { + t.Fatal(err) + } + if err := writeBuildPending(tr.install, buildPendingState{ + Commit: target, PreviousCommit: previous, BackupDir: backupDir, + KeptContent: kept, PreviousBinarySHA256: oldHash, + }); err != nil { + t.Fatal(err) + } + mustGit(t, tr.install, "merge", "--ff-only", "--no-verify", snap.sourceRef()) + if tc.afterMerge != "" { + writeFile(t, role, tc.afterMerge) + } + + if _, err := Apply(context.Background(), buildOpts(tr), nil); err != nil { + t.Fatalf("retry failed: %v", err) + } + if got := readFile(t, role); !strings.Contains(got, tc.want) { + t.Fatalf("protected content after retry = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/update/rollback_test.go b/internal/update/rollback_test.go index 1025e9d5..6cf8f52f 100644 --- a/internal/update/rollback_test.go +++ b/internal/update/rollback_test.go @@ -81,3 +81,34 @@ func TestRollbackKeepsAHandDeletionOfAProtectedFile(t *testing.T) { t.Errorf("a file deleted by hand was resurrected by the rollback (%v); result %+v", err, res) } } + +func TestRollbackKeepsProtectedFileRecreatedAfterUpstreamDeletedIt(t *testing.T) { + requireGit(t) + tr := newTree(t) + if err := os.Remove(filepath.Join(tr.upstream, "roles", "shipped.yaml")); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(tr.upstream, "internal_service.go"), "package service\n\nconst Version = \"2\"\n") + mustGit(t, tr.upstream, "add", "-A") + mustGit(t, tr.upstream, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "upstream deletes the shipped role") + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { + t.Fatal(err) + } + + recreated := filepath.Join(tr.install, "roles", "shipped.yaml") + writeFile(t, recreated, "name: shipped\ndescription: mine after upstream deleted it\n") + 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, recreated); !strings.Contains(got, "mine after upstream deleted it") { + t.Fatalf("rollback overwrote the operator's recreated role: %q", got) + } + if got := strings.Join(res.KeptContent, ","); got != "roles/shipped.yaml" { + t.Fatalf("keptContent = %v, want the recreated role named", res.KeptContent) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index e738cc0c..fed60bd0 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -310,7 +310,8 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { snap.HasBinary = fileExists(bin) snap.BuildPending = buildPending(root) if st, ok := readState(root); ok { - snap.HasRollback = fileExists(bin+".prev") && st.UpdatedCommit != "" + binaryAlreadyMatchesTarget := st.BinaryCommit != "" && st.BinaryCommit == st.PreviousCommit + snap.HasRollback = st.UpdatedCommit != "" && (binaryAlreadyMatchesTarget || fileExists(bin+".prev")) snap.RollbackTo = st.PreviousCommit } return snap, nil diff --git a/internal/update/version.go b/internal/update/version.go index 68c281b5..bde24079 100644 --- a/internal/update/version.go +++ b/internal/update/version.go @@ -68,6 +68,26 @@ func syncVersionToConfig(root string) (before, after string, err error) { return current, target, nil } +// plannedVersionChange reports the change a sync is about to make, before it makes it: the +// live config's current number and the one the code on disk carries. Empty when there is +// nothing to do. An update records this in its pending marker *before* writing the config, +// so a crash between the two still leaves a rollback that can put the old number back. +func plannedVersionChange(root string) (before, after string) { + path := liveConfigFile(root) + if path == "" { + return "", "" + } + target := installVersion(root) + if target == "" { + return "", "" + } + current, err := config.FileVersion(path) + if err != nil || current == target { + return "", "" + } + return current, target +} + // versionStep renders the sync outcome as one progress line; no lines for "nothing to // do", a warning when the write failed (the update itself still stands). func versionStep(step func(string, string), before, after string, err error) {