Files
CyberStrikeAI/internal/update/apply.go
T

1120 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package update
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// stateFile records what the last update moved, so a rollback has a target that is not
// guessed at. It lives in the install tree and is deliberately not in git: it describes
// this machine's installation.
const stateFile = ".update-state.json"
// State is the rollback contract of one installation.
type State struct {
PreviousCommit string `json:"previous_commit"`
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"`
PreviousBinaryAbsent bool `json:"previous_binary_absent,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).
VersionBefore string `json:"version_before,omitempty"`
VersionAfter string `json:"version_after,omitempty"`
}
func statePath(root string) string { return filepath.Join(root, stateFile) }
// buildPendingFile records "the source has moved (or was moved), but the binary has not
// followed": a failed build, or a machine with no Go toolchain. It is written the moment
// the tree moves and removed only once the new binary is in place, so a retry on an
// already-current tree still knows there is a compile to finish. A file of its own rather
// than a State field because an adoption owes a build too, and an adoption has no state
// (there is no previous commit to roll back to).
const buildPendingFile = ".update-build-pending"
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"`
DeletedContent []string `json:"deleted_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"`
AdoptBranch string `json:"adopt_branch,omitempty"`
OverwrittenContent []string `json:"overwritten_content,omitempty"`
PreviousBinaryAbsent bool `json:"previous_binary_absent,omitempty"`
Legacy bool `json:"-"`
}
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 := decodeRecoveryJSON(data, &st); err != nil {
if strings.TrimSpace(string(data)) == "source is ahead of the binary" {
if _, _, stateErr := recoveryState(root); stateErr != nil {
return st, true, stateErr
}
return buildPendingState{Legacy: true}, true, nil
}
return buildPendingState{}, true, fmt.Errorf("待编译状态格式损坏: %w", err)
}
if err := validateBuildEvidence(root, st); err != nil {
return st, true, 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)) }
func readState(root string) (State, bool) {
var st State
data, err := os.ReadFile(statePath(root))
if err != nil {
return st, false
}
if err := json.Unmarshal(data, &st); err != nil || st.PreviousCommit == "" || st.UpdatedCommit == "" {
return st, false
}
return st, true
}
func writeState(root string, st State) error {
st.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
tmp := statePath(root) + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil {
return err
}
return os.Rename(tmp, statePath(root))
}
// Step is one progress line. A build takes minutes, so Apply reports where it is rather
// than appearing to hang; the console renders these directly.
type Step struct {
Phase string `json:"phase"`
Message string `json:"message"`
At string `json:"at"`
}
// Result is what one update attempt produced. Everything the operator would otherwise
// have to go and inspect by hand is in here.
type Result struct {
FromCommit string `json:"fromCommit"`
ToCommit string `json:"toCommit"`
Commits int `json:"commits"`
FilesTouched int `json:"filesTouched"`
// KeptContent names the operator-owned files that were restored over the update.
// Upstream may well have changed them; saying which ones is the difference between
// "my roles disappeared" and "my roles are still mine, and these upstream files did
// not land".
KeptContent []string `json:"keptContent"`
// Overwritten names local files whose content the target repository's version replaced
// during an adoption; copies are kept under <BackupDir>/overwritten/ so nothing is lost
// without a way back.
Overwritten []string `json:"overwritten,omitempty"`
// Adopted marks a result that comes from connecting a plain directory to a source
// rather than from moving an existing work tree.
Adopted bool `json:"adopted,omitempty"`
BinaryPath string `json:"binaryPath"`
BinaryBuilt bool `json:"binaryBuilt"`
PrevBinary string `json:"prevBinary"`
BackupDir string `json:"backupDir"`
NeedsRestart bool `json:"needsRestart"`
Duration string `json:"duration"`
}
// Error is a refusal with the information needed to act on it. Callers render the
// Message; Details is for the page to list file names or commits.
type Error struct {
Reason string `json:"reason"`
Message string `json:"message"`
Items []string `json:"items,omitempty"`
}
func (e *Error) Error() string { return e.Message }
// Apply carries an installation to the tip of the branch it tracks, then rebuilds and
// swaps the binary. It is all-or-nothing up to the point of the working-tree move: a
// failed build leaves the tree updated (source and previous binary are both recoverable),
// while a refused precondition leaves nothing touched at all.
func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) {
release, err := LockInstall(opts)
if err != nil {
return nil, err
}
defer release()
if onStep == nil {
onStep = func(Step) {}
}
step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) }
root, rootErr := opts.installRoot()
if rootErr != nil {
return nil, rootErr
}
if pending, err := readRollbackPending(root); err != nil || pending != nil {
return nil, &Error{Reason: "rollback_pending", Message: "上一次回滚尚未完成,请先重试回滚,再执行更新"}
}
if err := recoverAdoption(ctx, opts); err != nil {
return nil, err
}
snap, err := check(ctx, opts)
if err != nil {
return nil, err
}
start := time.Now()
// A refusal is not the end of the timeline: the page renders these lines, so the first
// one says what was looked at even when the answer is "nothing to do".
step("preflight", fmt.Sprintf("检查安装目录 %s(本机分支 %s / 更新源 %s / 提交 %s)", snap.Root, snap.Branch, snap.sourceLabel(), snap.Commit))
if !snap.Installed {
return nil, &Error{Reason: "not_a_repo", Message: "这个目录不是 git 工作树,无法自动更新"}
}
if snap.CheckError != "" {
return nil, &Error{Reason: "check_failed", Message: "检查更新失败:" + snap.CheckError}
}
if len(snap.BlockingChanges) > 0 {
items := make([]string, 0, len(snap.BlockingChanges))
for _, c := range snap.BlockingChanges {
items = append(items, fmt.Sprintf("%s (%s)", c.Path, c.Status))
}
return nil, &Error{
Reason: "local_source_edits",
Message: fmt.Sprintf("有 %d 个源码文件被本地改过,自动更新不会覆盖它们。先提交或还原这些文件再更新。", len(items)),
Items: items,
}
}
if snap.Diverged {
return nil, &Error{
Reason: "diverged",
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.Duration = time.Since(start).Round(time.Millisecond).String()
return res, nil
}
step("fetch", fmt.Sprintf("%s(默认分支 %s)有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.TargetBranch, snap.Behind, snap.Commit, snap.RemoteCommit))
// Put operator-owned content aside, and clear the paths the incoming change would
// write to, so the fast-forward cannot be refused by "your local changes would be
// overwritten". Nothing here deletes a file the operator made: every path is copied
// out first and copied back after.
root = snap.Root
ref := snap.sourceRef()
backupDir, kept, deleted, err := stashProtected(ctx, root, ref)
if err != nil {
return nil, err
}
step("protect", fmt.Sprintf("已暂存 %d 个本地内容文件(roles/skills/tools/数据/配置),更新完成后放回", len(kept)))
previousCommit, err := gitCmd(ctx, root, "rev-parse", "HEAD")
if err != nil {
restoreProtected(backupDir, kept)
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,
DeletedContent: deleted,
}
pending.PreviousBinaryAbsent = !fileExists(bin)
if opts.binaryName() != "none" && fileExists(bin) {
pending.PreviousBinarySHA256, err = fileSHA256(bin)
if err != nil {
restoreProtected(backupDir, kept)
return nil, err
}
}
if opts.binaryName() != "none" {
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.
if err := restoreProtected(backupDir, kept); err != nil {
return nil, err
}
if err := restoreDeleted(root, deleted); err != nil {
return nil, err
}
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)
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
state.PreviousBinaryAbsent = pending.PreviousBinaryAbsent
}
if err := writeState(root, state); err != nil {
return res, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()}
}
if opts.binaryName() == "none" {
step("build", "按请求跳过重新编译;源码已更新,重启后由外部构建流程产出二进制")
res.NeedsRestart = true
res.Duration = time.Since(start).Round(time.Millisecond).String()
return res, nil
}
if !snap.CanBuild {
res.NeedsRestart = true
res.Duration = time.Since(start).Round(time.Millisecond).String()
return res, &Error{
Reason: "no_toolchain",
Message: "源码已更新,但本机没有 Go 工具链,无法自动编译。装好 go 后再点一次更新即可补上二进制。",
}
}
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
}
step("done", fmt.Sprintf("已更新 %d 个提交并换好二进制:%s → %s", res.Commits, res.FromCommit, res.ToCommit))
res.Duration = time.Since(start).Round(time.Millisecond).String()
return res, nil
}
// finishPendingBuild compiles the binary a previous update left missing. 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
pending.PreviousBinarySHA256 = state.PreviousBinarySHA256
pending.PreviousBinaryAbsent = state.PreviousBinaryAbsent
if state.BinaryCommit != "" {
binaryCommit, resolveErr := gitCmd(ctx, snap.Root, "rev-parse", state.BinaryCommit)
previous, previousErr := gitCmd(ctx, snap.Root, "rev-parse", state.PreviousCommit)
if resolveErr != nil || previousErr != nil || (binaryCommit != updated && binaryCommit != previous) {
return &Error{Reason: "bad_state", Message: "旧恢复记录的二进制提交不属于这次更新,已保留现场"}
}
if state.BinarySHA256 != "" {
currentHash, hashErr := fileSHA256(bin)
if hashErr != nil || currentHash != state.BinarySHA256 {
return &Error{Reason: "binary_changed", Message: "当前二进制与旧恢复记录不一致,已保留现场"}
}
}
if binaryCommit == updated {
pending.BinarySHA256 = state.BinarySHA256
} else {
pending.PreviousBinarySHA256 = state.BinarySHA256
}
}
}
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}
}
fileEvidence := pending
if target != head {
// Before the source move only the original binary/absence is valid.
fileEvidence.BinarySHA256 = ""
}
if err := validateBuildFiles(bin, fileEvidence); err != nil {
return &Error{Reason: "bad_state", Message: "恢复证据不足,已保留二进制和记录:" + err.Error()}
}
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,
PreviousBinaryAbsent: pending.PreviousBinaryAbsent,
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 后再点一次更新即可补上二进制。", shortHash(head)),
}
}
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,
PreviousBinaryAbsent: pending.PreviousBinaryAbsent,
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),本次补上了二进制", shortHash(head)))
return nil
}
// buildAndSwap compiles the tree and moves the new binary into place, keeping the previous
// 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", "开始编译二进制(首次会下载依赖,可能需要几分钟)")
if err := verifyBuildSource(ctx, root, commit); err != nil {
return err
}
staging := filepath.Join(root, ".update-staging")
if err := os.MkdirAll(staging, 0o755); err != nil {
return &Error{Reason: "staging_failed", Message: err.Error()}
}
defer os.RemoveAll(staging)
newBin := filepath.Join(staging, opts.binaryName())
if err := build(ctx, root, newBin); err != nil {
return &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()}
}
if err := verifyBuildSource(ctx, root, commit); err != nil {
return err
}
newHash, err := fileSHA256(newBin)
if err != nil {
return &Error{Reason: "build_failed", Message: "无法校验新二进制:" + err.Error()}
}
bin := filepath.Join(root, opts.binaryName())
if err := validateBuildFiles(bin, pending); err != nil {
return &Error{Reason: "binary_changed", Message: "编译后恢复证据不再匹配,已保留现场:" + err.Error()}
}
if pending.Legacy && fileExists(bin) && fileExists(bin+".prev") {
currentHash, currentErr := fileSHA256(bin)
previousHash, previousErr := fileSHA256(bin + ".prev")
if currentErr == nil && previousErr == nil && currentHash == newHash {
if pending.PreviousBinarySHA256 != "" && previousHash != pending.PreviousBinarySHA256 {
return &Error{Reason: "binary_changed", Message: "旧二进制备份与恢复记录不一致,已保留现场,拒绝改写回滚记录"}
}
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
}
}
// A legacy marker without hashes is ambiguous when a rebuild differs from the
// installed binary. Only a clean VCS stamp for the previous commit can prove
// that another swap will preserve the right rollback binary.
if (pending.Legacy || pending.PreviousCommit != "") && pending.PreviousBinarySHA256 == "" && !pending.PreviousBinaryAbsent {
revision := binaryRevision(bin)
previous, resolveErr := gitCmd(ctx, root, "rev-parse", pending.PreviousCommit)
if revision == "" || resolveErr != nil || revision != previous {
return &Error{Reason: "binary_state_unknown", Message: "旧恢复记录缺少二进制哈希,无法确认当前二进制属于更新前版本;已保留当前二进制和 .prev,请人工核对后恢复"}
}
hash, err := fileSHA256(bin)
if err != nil {
return &Error{Reason: "binary_state_unknown", Message: "无法校验更新前二进制:" + err.Error()}
}
pending.PreviousBinarySHA256 = hash
}
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
if state != nil {
state.BinaryCommit = commit
state.BinarySHA256 = newHash
state.PreviousBinarySHA256 = pending.PreviousBinarySHA256
state.PreviousBinaryAbsent = pending.PreviousBinaryAbsent
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. 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, deleted []string, err error) {
ts := time.Now().Format("20060102_150405")
backupDir = ""
tracked, _, changeErr := localChanges(ctx, root)
if changeErr != nil {
return "", nil, nil, changeErr
}
incoming, err := gitRaw(ctx, root, "diff", "--name-only", "-z", "HEAD.."+ref)
if err != nil {
return "", nil, nil, &Error{Reason: "diff_failed", Message: err.Error()}
}
willWrite := map[string]bool{}
for _, f := range nulList(incoming) {
willWrite[filepath.ToSlash(f)] = true
}
if err := validateIncomingFiles(ctx, root, willWrite); err != nil {
return "", nil, nil, err
}
// A locally modified content file would refuse the merge; an untracked content file
// whose path upstream now adds would also refuse it. Both are put aside.
targets := map[string]bool{}
for _, c := range tracked {
if c.Protected && willWrite[c.Path] {
if c.Status == "deleted" {
deleted = append(deleted, c.Path)
} else {
targets[c.Path] = true
}
}
}
for _, p := range untrackedUnder(ctx, root, willWrite) {
targets[p] = true
}
sort.Strings(deleted)
for p := range targets {
if err := validateFilePath(root, p); err != nil {
return backupDir, kept, deleted, err
}
abs := filepath.Join(root, p)
info, statErr := os.Stat(abs)
if statErr != nil || !info.Mode().IsRegular() {
return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: "备份前本地文件发生变化,未执行更新:" + p}
}
if backupDir == "" {
backupDir, err = newBackupDir(root, ts)
if err != nil {
return "", nil, deleted, err
}
}
if err := copyFile(abs, filepath.Join(backupDir, p), info.Mode()); err != nil {
return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)}
}
kept = append(kept, p)
}
if len(kept) > 0 {
sort.Strings(kept)
return backupDir, kept, deleted, nil
}
// Nothing to put aside: don't leave an empty backup directory behind.
return "", nil, deleted, 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(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
}
if c.Status == "deleted" {
deleted = append(deleted, c.Path)
continue
}
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
}
if err := validateIncomingFiles(ctx, root, willWrite); err != nil {
return "", nil, nil, err
}
for _, p := range untrackedUnder(ctx, root, willWrite) {
files[p] = true
}
for p := range files {
if err := validateFilePath(root, p); err != nil {
return backupDir, kept, deleted, err
}
abs := filepath.Join(root, filepath.FromSlash(p))
info, statErr := os.Stat(abs)
if statErr != nil || !info.Mode().IsRegular() {
return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: "备份前本地文件发生变化,未执行回滚:" + p}
}
if backupDir == "" {
backupDir, err = newBackupDir(root, "rollback_"+ts)
if err != nil {
return "", nil, deleted, 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, p)
}
sort.Strings(kept)
sort.Strings(deleted)
return backupDir, kept, deleted, nil
}
// restoreProtected puts the named operator-owned copies back over whatever the merge
// wrote. The list is the one stashProtected returned: an adoption parks replaced
// non-protected files in the same backup directory (under overwritten/), and those must
// NOT be restored over the target repository's version.
func restoreProtected(backupDir string, kept []string) error {
if backupDir == "" {
return nil
}
root := filepath.Join(filepath.Dir(backupDir), "..")
for _, rel := range kept {
abs := filepath.Join(backupDir, filepath.FromSlash(rel))
info, err := os.Stat(abs)
if err != nil {
return err
}
if err := copyFile(abs, filepath.Join(root, filepath.FromSlash(rel)), info.Mode()); err != nil {
return err
}
}
return nil
}
func restoreInterruptedProtected(ctx context.Context, root string, pending buildPendingState, merged bool) error {
if merged {
// Reapply only untouched copies written by the merge. A file recreated
// by the operator after the crash belongs to them.
for _, rel := range pending.DeletedContent {
workHash, workErr := gitCmd(ctx, root, "hash-object", "--", rel)
headHash, headErr := gitCmd(ctx, root, "rev-parse", "HEAD:"+rel)
if workErr == nil && headErr == nil && workHash == headHash {
if err := restoreDeleted(root, []string{rel}); err != nil {
return err
}
}
}
}
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 {
// With no exclude patterns ls-files includes ignored files too. git merge
// may overwrite an ignored config or database without an untracked warning.
out, err := gitRaw(ctx, root, "ls-files", "--others", "-z")
if err != nil {
return nil
}
var list []string
for _, entry := range nulList(out) {
p := filepath.ToSlash(entry)
if willWrite[p] && Protected(p) {
list = append(list, p)
}
}
return list
}
// Rollback returns an installation to the commit it came from, using the binary kept
// before the swap. It refuses unless HEAD is still exactly what the update wrote, so it
// can never discard work done after that update.
func Rollback(ctx context.Context, opts Options) (*Result, error) {
release, err := LockInstall(opts)
if err != nil {
return nil, err
}
defer release()
root, err := opts.installRoot()
if err != nil {
return nil, err
}
interrupted, err := readRollbackPending(root)
if err != nil {
return nil, &Error{Reason: "state_unreadable", Message: "无法读取回滚恢复记录:" + err.Error()}
}
if interrupted != nil {
if err := repositoryRoot(ctx, root); err != nil {
return nil, err
}
head, err := gitCmd(ctx, root, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
if head == interrupted.State.PreviousCommit {
return finishRollback(ctx, opts, *interrupted)
}
if head != interrupted.State.UpdatedCommit {
return nil, &Error{Reason: "moved_since_update", Message: "回滚中断后 HEAD 已经变了,拒绝覆盖后续提交"}
}
}
st, ok, stateErr := recoveryState(root)
if stateErr != nil {
return nil, &Error{Reason: "state_unreadable", Message: "无法读取回滚状态:" + stateErr.Error()}
}
if !ok {
return nil, &Error{Reason: "no_state", Message: "没有可回滚的更新记录"}
}
if err := repositoryRoot(ctx, root); err != nil {
return nil, err
}
bin := filepath.Join(root, opts.binaryName())
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 st.PreviousBinaryAbsent {
return nil, &Error{Reason: "no_binary", Message: "这次更新之前没有二进制,不能使用其它更新留下的 .prev 回滚"}
}
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 {
return nil, err
}
updated, err := gitCmd(ctx, root, "rev-parse", st.UpdatedCommit)
if err != nil {
return nil, &Error{Reason: "bad_state", Message: "更新记录里的提交在本仓库不存在:" + st.UpdatedCommit}
}
if head != updated {
return nil, &Error{
Reason: "moved_since_update",
Message: fmt.Sprintf("自那次更新之后 HEAD 已经变了(现在是 %s),回滚只会撤到更新前的提交,因此拒绝执行。", shortHash(head)),
}
}
start := time.Now()
changes, blocking, changeErr := localChanges(ctx, root)
if changeErr != nil {
return nil, changeErr
}
if len(blocking) > 0 {
items := make([]string, 0, len(blocking))
for _, c := range blocking {
items = append(items, c.Path)
}
return nil, &Error{Reason: "local_source_edits", Message: "有本地源码改动,回滚会覆盖它们", Items: items}
}
// Content the operator owns is not the rollback's to discard either: reset --hard takes
// every local change to tracked files, so protected files that differ right now - edits
// made before the update and edits made after it alike - are copied aside first and set
// back on top of the old commit. 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
}
pending := rollbackPendingState{State: st, BackupDir: backupDir, Kept: kept, Deleted: deleted, RestoreBinary: restoreBinary}
if fileExists(bin) {
pending.LiveSHA256, err = fileSHA256(bin)
if err != nil {
return nil, err
}
}
if restoreBinary {
pending.PreviousSHA256, err = fileSHA256(bin + ".prev")
if err != nil {
return nil, err
}
}
if err := writeRollbackPending(root, pending); err != nil {
return nil, &Error{Reason: "state_unwritable", Message: "无法写入回滚恢复记录,未修改源码或二进制:" + err.Error()}
}
if _, err := gitCmd(ctx, root, "reset", "--hard", "--quiet", st.PreviousCommit); err != nil {
return nil, &Error{Reason: "reset_failed", Message: err.Error()}
}
res, err := finishRollback(ctx, opts, pending)
if res != nil {
res.Duration = time.Since(start).Round(time.Millisecond).String()
}
return res, err
}
func shortHash(h string) string {
if len(h) > 8 {
return h[:8]
}
return h
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func restoreDeleted(root string, deleted []string) error {
for _, rel := range deleted {
if err := os.Remove(filepath.Join(root, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) {
return err
}
}
return nil
}
func newBackupDir(root, prefix string) (string, error) {
base := filepath.Join(root, ".update-backup")
if err := os.MkdirAll(base, 0700); err != nil {
return "", err
}
return os.MkdirTemp(base, prefix+"-")
}
func verifyBuildSource(ctx context.Context, root, commit string) error {
head, err := gitCmd(ctx, root, "rev-parse", "HEAD")
if err != nil {
return err
}
if head != commit {
return &Error{Reason: "build_state_mismatch", Message: "编译期间源码提交发生变化,拒绝换入无法配对的二进制"}
}
_, blocking, err := localChanges(ctx, root)
if err != nil {
return err
}
if len(blocking) > 0 {
return &Error{Reason: "local_source_edits", Message: "编译期间源码发生本地改动,已保留二进制和备份,请先处理这些改动"}
}
return nil
}
func validateIncomingFiles(ctx context.Context, root string, paths map[string]bool) error {
for rel := range paths {
if err := validateFilePath(root, rel); err != nil {
return err
}
}
others, err := gitRaw(ctx, root, "ls-files", "--others", "-z")
if err != nil {
return err
}
for _, rel := range nulList(others) {
if paths[rel] && !Protected(rel) {
return &Error{Reason: "local_source_edits", Message: "目标提交会覆盖本地未跟踪的源码文件,已拒绝更新:" + rel, Items: []string{rel}}
}
}
return nil
}