Files
CyberStrikeAI/internal/update/build.go
T
Sycun cb8bc6b824 feat: update this installation's own source in one action
An operator running a fork has no honest way to upgrade: upgrade.sh downloads the
Release tarball of one hard-coded third-party repository and rsyncs it over the
tree, so "upgrade" means "overwrite yourself with somebody else's code", and the
safe alternative is to pull by hand and remember the build command.

internal/update drives the remote the directory already tracks: fetch, fast-forward,
go build into .update-staging, atomic rename of the binary (the previous one kept as
cyberstrike-ai.prev), and a .update-state.json so a rollback has a target that is
not guessed at. Refusals are the point - local source edits and a diverged branch
stop with the file names and commit counts in the error rather than being reset to
match, and operator-owned content (roles/skills/tools/agents/data/config.yaml) is put
aside before the merge and restored over it, with what was kept listed in keptContent
instead of quietly dropped.

Consumers: GET/POST /api/system/update* behind update:read / update:apply (mutations
are process-global, so they need a global-scope session); a console page at
#system-update with an apply button that names why it is blocked, a job polled from
the server and a rollback control; the same three actions as -check-update / -update
/ -update-rollback on the binary; OpenAPI entries for all five endpoints. Permission
gating no longer re-enables a control a page disabled for a reason of its own
(data-state-disabled), which every state-disabled, permission-gated button in the
console was subject to. upgrade.sh becomes a thin shell over the same implementation
and only falls back to the tarball path for installs that are not git work trees.

Docs and the zh/EN/RU dictionaries are updated. The package runs 13 tests against
real temporary git repositories; the HTTP layer, RBAC and the console page each
carry their own contract tests.
2026-10-07 12:07:26 +08:00

148 lines
4.5 KiB
Go

package update
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// toolchain reports whether a Go compiler is reachable from the install tree. An update
// that cannot compile must still update the source and say so plainly, because "no Go on
// this box" is an environment fact the operator can fix, not a reason to refuse the pull.
func toolchain(ctx context.Context, root string) (string, bool) {
path, err := exec.LookPath("go")
if err != nil {
return "", false
}
out, err := exec.CommandContext(ctx, path, "env", "GOVERSION").Output()
if err != nil {
return path, true
}
v := strings.TrimSpace(string(out))
if v == "" {
return path, true
}
return v, true
}
// build compiles the server binary into outPath, never into the tree's own executable
// name: a build that fails halfway must not leave a truncated binary where the running
// one lives. Output is returned on failure because the operator needs the compiler's
// message, not "build failed".
func build(ctx context.Context, root, outPath string) error {
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, "./cmd/server")
cmd.Dir = root
// The module cache goes next to the install rather than into whoever's HOME started
// the service, so a first build under a service account cannot fail on a HOME that is
// not writable.
if home := os.Getenv("HOME"); home == "" || home == "/" {
cmd.Env = append(os.Environ(), "HOME="+root)
}
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s", tail(msg, 4000))
}
if _, err := os.Stat(outPath); err != nil {
return fmt.Errorf("go build reported success but produced no binary at %s", outPath)
}
return nil
}
// installBinary puts src in place of dst and keeps the previous dst as dst+".prev", which
// is what makes a rollback possible without a second download.
//
// The rename is the whole trick: a running executable's file cannot be written to on
// Linux (ETXTBSY) but renaming it aside is always allowed, so the old process keeps its
// inode and the new path is complete before anyone can observe it.
func installBinary(src, dst string) (string, error) {
prev := ""
if fileExists(dst) {
prev = dst + ".prev"
if err := os.Remove(prev); err != nil && !os.IsNotExist(err) {
return "", err
}
if err := os.Rename(dst, prev); err != nil {
return "", fmt.Errorf("保留旧二进制失败:%w", err)
}
}
if err := os.Chmod(src, 0o755); err != nil {
return prev, err
}
if err := os.Rename(src, dst); err != nil {
// Put the kept-previous binary back rather than leaving the install with no
// executable because one rename failed.
if prev != "" && !fileExists(dst) {
_ = os.Rename(prev, dst)
}
return "", fmt.Errorf("换入新二进制失败:%w", err)
}
return prev, nil
}
// restorePrevBinary rolls back one swap. It is deliberately not installBinary in
// reverse: renaming dst onto src first would overwrite the very .prev file being
// restored, so the current binary is discarded instead.
func restorePrevBinary(bin string) error {
prev := bin + ".prev"
if !fileExists(prev) {
return fmt.Errorf("没有旧二进制:%s", prev)
}
if fileExists(bin) {
if err := os.Remove(bin); err != nil {
return err
}
}
if err := os.Chmod(prev, 0o755); err != nil {
return err
}
return os.Rename(prev, bin)
}
// 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 {
if len(s) <= n {
return s
}
return "…(前文省略)\n" + s[len(s)-n:]
}
// copyFile is used for the operator-owned content that an update puts aside and takes
// back. It creates the parent directories because the backup tree mirrors repository
// paths, and it preserves the mode because a tool recipe or a script that lost its exec
// bit stops working in a way nothing reports.
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}