diff --git a/README.md b/README.md index 03e6abe3..e63dcb12 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,8 @@ If server logs show `client sent an HTTP request to an HTTPS server`, a client i `./cyberstrike-ai -update` / `-check-update` / `-update-rollback`): it pulls the newest commit of the **update source repository**'s default branch (the official repository by default; one setting points it at your own fork or a mirror), fast-forwards, rebuilds and swaps the binary atomically, keeps your -`roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it kept), and +`roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it kept; only +`config.yaml`'s `version` field is synced to the new code, so the header badge matches it), and refuses - rather than overwrites - when source is modified locally or the local history has diverged. See the [deployment guide](docs/en-US/deployment.md). diff --git a/README_CN.md b/README_CN.md index c63ee8d2..344a2bf4 100644 --- a/README_CN.md +++ b/README_CN.md @@ -271,7 +271,8 @@ go build -o cyberstrike-ai ./cmd/server **优先用平台自带的「一键更新」**(控制台「系统设置 → 一键更新」,或 `./cyberstrike-ai -update` / `-check-update` / `-update-rollback`):它从**更新源仓库**(默认官方仓库,可一处配置改成自己的二开或镜像) 拉取默认分支的最新代码,快进后重编译并原子换二进制,你的 `roles/skills/tools/agents/data/config.yaml` -会被原样保留(结果里逐个点名保留了什么),本地源码有改动或本地历史与更新源分叉时它会拒绝而不是覆盖。 +会被原样保留(结果里逐个点名保留了什么,只有 `config.yaml` 的 `version` 字段会随新代码同步,让页头版本号与代码对上), +本地源码有改动或本地历史与更新源分叉时它会拒绝而不是覆盖。 详见 [部署指南](docs/zh-CN/deployment.md)。 `upgrade.sh` 仍然可用:本目录是 git 工作树时它就是上面那条命令的薄壳;不是 git 工作树(tarball 安装)时 diff --git a/config.example.yaml b/config.example.yaml index bffd9e62..f8f6e0ba 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -9,7 +9,8 @@ # 系统设置 # ============================================ -# 前端显示的版本号(可选,不填则显示默认版本) +# 前端显示的版本号(可选,不填则显示默认版本)。一键更新完成后,会用新代码里这个文件的值 +# 同步更新你的 config.yaml 的 version 字段(页头版本号与静态资源缓存随代码走)。 version: "v1.7.21" # 服务器配置 server: diff --git a/docs/en-US/developer-guide.md b/docs/en-US/developer-guide.md index f3a2be90..84544a3c 100644 --- a/docs/en-US/developer-guide.md +++ b/docs/en-US/developer-guide.md @@ -92,6 +92,17 @@ installation with one-click update and rollback - but note there is no git histo connection, so the connection itself has no earlier commit to roll back to; the first real update writes the first rollback point. +### The version number follows the code + +The repository's `config.example.yaml` carries the release version this code was cut from +(upstream bumps it in the release commit), while your `config.yaml` is operator data that no +update ever touches. So after a successful merge the update writes the new code's version back +into `config.yaml`'s `version` field (that one line only, temp file plus atomic rename; the job +log records the old and the new number). After a restart the header badge and the static +assets' `?v=` both match the code again - which is also why the frontend cache invalidates +itself on upgrade. A rollback puts the number back too (unless somebody hand-edited it in the +meantime), and connecting a tarball install syncs it as well. + ### The four refusals | situation | reason | meaning and what to do | diff --git a/docs/zh-CN/developer-guide.md b/docs/zh-CN/developer-guide.md index 5269e479..a498368a 100644 --- a/docs/zh-CN/developer-guide.md +++ b/docs/zh-CN/developer-guide.md @@ -90,6 +90,14 @@ update: 接入后它就是正常安装,一键更新与回滚都可用了;注意接入前没有 git 历史,因此「接入」本身没有可回滚的 上一提交,第一个回滚点由接入后的第一次更新写下。 +### 版本号随代码走 + +仓库里的 `config.example.yaml` 载着这份代码对应的发布版本(上游随 release 提交一起改),而你的 +`config.yaml` 是运维数据、任何一次更新都不会碰它。所以更新在合并完成后会把新代码的版本号写回 +`config.yaml` 的 `version` 字段(只动这一行、临时文件 + 原子替换;进度里会有一行「版本号已随新代码 +更新:v1.7.20 → v1.7.21」)。重启后页头的版本徽标与静态资源的 `?v=` 都跟代码对上——升级后前端缓存 +也因此自动失效。回滚会把版本号一起还原(前提是这期间没人手改过它);解压安装「接入」时同样同步一次。 + ### 四种拒绝场景 | 场景 | reason | 含义与处理 | diff --git a/internal/config/config.go b/internal/config/config.go index 4f375ec3..901aec00 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" @@ -1749,6 +1750,77 @@ func LoadUpdateRepo(path string) (string, error) { return strings.TrimSpace(thin.Update.Repo), nil } +// FileVersion 读一个配置文件形态 YAML 的顶层 version 字段。仓库里的 config.example.yaml +// 载着这份代码对应的发布版本(上游随 release 提交一起改),一键更新用它把"当前跑的是什么 +// 版本"同步进运维者的 config.yaml。文件或字段不存在 = 未知,不是错误。 +func FileVersion(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", fmt.Errorf("读取配置文件失败: %w", err) + } + var thin struct { + Version string `yaml:"version"` + } + if err := yaml.Unmarshal(data, &thin); err != nil { + return "", fmt.Errorf("解析配置文件失败: %w", err) + } + return strings.TrimSpace(thin.Version), nil +} + +// versionValuePattern 约束能写进配置的版本号:它来自仓库里的文件,但落到的是运维者的 +// 配置上,带引号或换行的值会把 YAML 写坏。 +var versionValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`) + +// WriteVersion 就地替换配置文件的顶层 version 字段,保留其它每一行的原样(tarball 升级 +// 路径一直这么做);没有这个字段就插到文件开头。返回是否真的改了。写盘走临时文件 + 原子 +// rename——这是运维者的活配置,不能留半截。 +func WriteVersion(path, version string) (bool, error) { + version = strings.TrimSpace(version) + if version == "" { + return false, nil + } + if len(version) > 64 || !versionValuePattern.MatchString(version) { + return false, fmt.Errorf("版本号不合法:%q", version) + } + data, err := os.ReadFile(path) + if err != nil { + return false, err + } + info, err := os.Stat(path) + if err != nil { + return false, err + } + lines := strings.Split(string(data), "\n") + replaced := false + for i, line := range lines { + // 只认顶层(顶格的)version 键;缩进里同名键属于别的段落,不动。 + rest := strings.TrimSpace(strings.TrimPrefix(line, "version")) + if strings.HasPrefix(line, "version") && strings.HasPrefix(rest, ":") { + lines[i] = `version: "` + version + `"` + replaced = true + break + } + } + if !replaced { + lines = append([]string{`version: "` + version + `"`}, lines...) + } + out := strings.Join(lines, "\n") + if out == string(data) { + return false, nil + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(out), info.Mode().Perm()); err != nil { + return false, err + } + if err := os.Rename(tmp, path); err != nil { + return false, err + } + return true, nil +} + func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) { path = strings.TrimSpace(path) if path == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3e767148..3f77d2be 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -371,3 +371,65 @@ func TestLoadUpdateRepoReadsOnlyTheSourceField(t *testing.T) { t.Fatal("a malformed config must be an error, not a silent default") } } + +func TestFileVersionAndWriteVersion(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + // Missing file reads as "unknown", not an error. + if v, err := FileVersion(filepath.Join(dir, "missing.yaml")); err != nil || v != "" { + t.Fatalf("missing file: %q %v", v, err) + } + + original := "version: \"v1.0.0\"\nserver:\n port: 8088\n # a nested word: version: should stay put\nnotes:\n version: nested-untouched\n" + if err := os.WriteFile(path, []byte(original), 0o640); err != nil { + t.Fatal(err) + } + if v, err := FileVersion(path); err != nil || v != "v1.0.0" { + t.Fatalf("version = %q err = %v", v, err) + } + + changed, err := WriteVersion(path, "v1.1.0") + if err != nil || !changed { + t.Fatalf("write: changed=%v err=%v", changed, err) + } + got := readTestFile(t, path) + want := "version: \"v1.1.0\"\nserver:\n port: 8088\n # a nested word: version: should stay put\nnotes:\n version: nested-untouched\n" + if got != want { + t.Fatalf("file after write:\n%q\nwant:\n%q", got, want) + } + if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o640 { + t.Fatalf("mode = %v err = %v, want 0640 preserved", info.Mode(), err) + } + + // Idempotent: the same value is not rewritten. + if changed, err := WriteVersion(path, "v1.1.0"); err != nil || changed { + t.Fatalf("rewriting the same value: changed=%v err=%v", changed, err) + } + + // No top-level field: insert at the top, nested keys stay untouched. + if err := os.WriteFile(path, []byte("server:\n port: 8088\nnotes:\n version: nested\n"), 0o644); err != nil { + t.Fatal(err) + } + if changed, err := WriteVersion(path, "v1.2.0"); err != nil || !changed { + t.Fatalf("insert: changed=%v err=%v", changed, err) + } + got = readTestFile(t, path) + if !strings.HasPrefix(got, "version: \"v1.2.0\"\n") || !strings.Contains(got, " version: nested") { + t.Fatalf("file after insert:\n%q", got) + } + + // A value that would write malformed YAML is refused rather than written. + if _, err := WriteVersion(path, "v1.0.0\"\nserver: hijacked"); err == nil { + t.Fatal("a version containing a quote/newline must be refused") + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} diff --git a/internal/update/adopt.go b/internal/update/adopt.go index 74f4d6f9..cfa10e8f 100644 --- a/internal/update/adopt.go +++ b/internal/update/adopt.go @@ -156,6 +156,11 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error // cleanup above must not remove the repository any more. adopted = true + // 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) + res := &Result{ ToCommit: commit, FilesTouched: len(scan.paths), diff --git a/internal/update/apply.go b/internal/update/apply.go index 6218c4e8..2b3afb7d 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -22,6 +22,11 @@ type State struct { UpdatedCommit string `json:"updated_commit"` BackupDir string `json:"backup_dir"` UpdatedAt string `json:"updated_at"` + // 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) } @@ -140,6 +145,11 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error } } 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) return &Result{ FromCommit: snap.Commit, ToCommit: snap.Commit, BinaryPath: filepath.Join(snap.Root, opts.binaryName()), @@ -181,7 +191,15 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error FilesTouched: len(nulList(files)), } - if err := writeState(root, State{PreviousCommit: snap.Commit, UpdatedCommit: snap.RemoteCommit, BackupDir: backupDir}); err != nil { + // 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. + 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()} } @@ -366,6 +384,9 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) { if err := restorePrevBinary(bin); err != nil { return nil, &Error{Reason: "swap_failed", 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) if err := os.Remove(statePath(root)); err != nil && !os.IsNotExist(err) { return nil, err } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 2a38e910..df61d773 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -417,3 +417,127 @@ func mustGit(t *testing.T, dir string, args ...string) string { } return strings.TrimSpace(string(out)) } + +// The release version rides with the code: after an update the live config has to say the +// version of the code now on disk (the header badge reads it), and a rollback has to put +// the old number back next to the old code. +func TestApplySyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) { + requireGit(t) + tr := newTree(t) + 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\"\nserver:\n port: 8088\n", + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + }) + + res, err := Apply(context.Background(), opts(tr), nil) + if err != nil { + t.Fatalf("apply failed: %v\nresult: %+v", err, res) + } + live := readFile(t, filepath.Join(tr.install, "config.yaml")) + if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") { + t.Fatalf("live config after update = %q, want the new version with everything else untouched", live) + } + st, ok := readState(tr.install) + if !ok || st.VersionBefore != "v1.0.0" || st.VersionAfter != "v1.1.0" { + t.Fatalf("state = %+v, want the version change recorded for the rollback", st) + } + + // Rollback puts the number back with the code. + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "new binary bytes") + writeFile(t, bin+".prev", "old binary bytes") + if _, err := Rollback(context.Background(), Options{Root: tr.install}); 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"`) { + t.Fatalf("live config after rollback = %q, want the old version back", live) + } +} + +func TestRollbackDoesNotClobberAManuallyChangedVersion(t *testing.T) { + requireGit(t) + tr := newTree(t) + 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", + }) + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { + t.Fatal(err) + } + // Somebody pins their own version after the update; a rollback is not theirs to undo. + writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v9.9.9-mine\"\nserver:\n port: 8088\n") + bin := filepath.Join(tr.install, Binary) + writeFile(t, bin, "new") + writeFile(t, bin+".prev", "old") + if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil { + t.Fatal(err) + } + if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v9.9.9-mine") { + t.Fatalf("a hand-set version must survive a rollback, got %q", live) + } +} + +func TestApplyLeavesTheVersionAloneWhenTheTreeCarriesNone(t *testing.T) { + requireGit(t) + tr := newTree(t) + // The fixture's upstream has no config.example.yaml at all: nothing to sync, and the + // live config must not be rewritten with a guess. + writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n") + tr.upstreamCommit(t, "second: bump service", map[string]string{ + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + }) + if _, err := Apply(context.Background(), opts(tr), nil); err != nil { + t.Fatal(err) + } + if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v1.0.0") { + t.Fatalf("live config = %q, want it untouched", live) + } + if st, _ := readState(tr.install); st.VersionAfter != "" || st.VersionBefore != "" { + t.Fatalf("state = %+v, want no version fields when nothing changed", st) + } +} + +func TestAdoptSyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) { + requireGit(t) + tr, dir := newAdoptFixture(t) + writeFile(t, filepath.Join(dir, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n") + tr.upstreamCommit(t, "release: bump version", map[string]string{ + "config.example.yaml": "version: \"v1.1.0\"\n", + }) + + res, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil) + if err != nil { + t.Fatalf("adopt failed: %v\nresult: %+v", err, res) + } + live := readFile(t, filepath.Join(dir, "config.yaml")) + if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") { + t.Fatalf("live config after adopt = %q, want the connected code's version with the rest untouched", live) + } +} + +// A tree that is already current still gets its bookkeeping aligned: the version field is +// the one thing an update may correct with no commits to move (that is exactly the state a +// pre-feature installation is in). +func TestApplyAlignsTheVersionEvenWithNothingToPull(t *testing.T) { + requireGit(t) + tr := newTree(t) + writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n") + writeFile(t, filepath.Join(tr.install, "config.example.yaml"), "version: \"v1.1.0\"\n") + + res, err := Apply(context.Background(), opts(tr), nil) + if err != nil { + t.Fatalf("no-op apply failed: %v", err) + } + if res.Commits != 0 { + t.Fatalf("res = %+v, want zero movement", res) + } + if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, `version: "v1.1.0"`) { + t.Fatalf("live config = %q, want the version aligned even though nothing moved", live) + } + // Bookkeeping only: a version-only correction writes no rollback state. + if _, ok := readState(tr.install); ok { + t.Fatal("a version-only alignment must not claim to be a rollback point") + } +} diff --git a/internal/update/version.go b/internal/update/version.go new file mode 100644 index 00000000..68c281b5 --- /dev/null +++ b/internal/update/version.go @@ -0,0 +1,104 @@ +package update + +import ( + "cyberstrike-ai/internal/config" + "fmt" + "path/filepath" +) + +// The release version travels with the code: config.example.yaml ships in the repository +// and upstream bumps its version field in the release commit, but the live config.yaml is +// the operator's file and no merge ever touches it. So without the steps below an updated +// installation keeps showing the version it was born with - the header badge and the +// static-asset cache buster would both say v-old while the tree runs v-new. The fix is +// deliberately small: read the version the new code carries, write it into the live +// config's version line (byte-for-byte otherwise untouched, atomic rename), and let a +// rollback put the old number back with the old code. + +// liveConfigFile names the operator's configuration inside an install tree: the +// conventional config.yaml, then config.yml. Empty when neither exists (a source +// checkout, where there is nothing to sync). +func liveConfigFile(root string) string { + for _, name := range []string{"config.yaml", "config.yml"} { + path := filepath.Join(root, name) + if fileExists(path) { + return path + } + } + return "" +} + +// installVersion is the release version the code sitting in root carries, read from the +// shipped config.example.yaml; empty when the tree does not say. +func installVersion(root string) string { + version, err := config.FileVersion(filepath.Join(root, "config.example.yaml")) + if err != nil { + return "" + } + return version +} + +// syncVersionToConfig writes the code's release version into the live configuration and +// returns (before, after, error) - both empty when there was nothing to do. A tree +// without a live config, or without a shipped version, is left alone; a current value +// that already matches is not rewritten. +func syncVersionToConfig(root string) (before, after string, err error) { + path := liveConfigFile(root) + if path == "" { + return "", "", nil + } + target := installVersion(root) + if target == "" { + return "", "", nil + } + current, err := config.FileVersion(path) + if err != nil { + return "", "", err + } + if current == target { + return "", "", nil + } + changed, err := config.WriteVersion(path, target) + if err != nil { + return current, "", err + } + if !changed { + return "", "", nil + } + return current, target, nil +} + +// 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) { + if err != nil { + step("version", "版本号写入 config.yaml 失败(不影响本次更新):"+err.Error()) + return + } + if after == "" { + return + } + if before == "" { + step("version", fmt.Sprintf("版本号随新代码写入 config.yaml:%s(重启后页头生效)", after)) + return + } + step("version", fmt.Sprintf("版本号已随新代码更新:%s → %s(config.yaml,重启后页头生效)", before, after)) +} + +// restoreVersionAfterRollback puts the old number back with the old code - but only while +// the live config still carries exactly what the update wrote: a version someone edited by +// hand after the update belongs to them, not to the rollback. +func restoreVersionAfterRollback(root string, st State) { + if st.VersionAfter == "" { + return + } + path := liveConfigFile(root) + if path == "" { + return + } + current, err := config.FileVersion(path) + if err != nil || current != st.VersionAfter { + return + } + _, _ = config.WriteVersion(path, st.VersionBefore) +}