mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 22:17:42 +02:00
1120 lines
42 KiB
Go
1120 lines
42 KiB
Go
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
|
||
}
|