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) {